OXYGENHUB
# 02e691881a4e oxygen / src / cli / identity.rs
25.2 KiB · Rust · last changed oxy receipt export --format slsa: SLSA provenance cosign can check 19 days ago source blame history raw
fce44ae9 Andy McCall ↶ 1
//! Identity, verification, and configuration commands.
2
3
use super::format::{short_id, signature_line, when};
75167672 Andy McCall 4
use super::json;
fce44ae9 Andy McCall ↶ 5
use super::open;
135212de Andy McCall ↶ 6
use super::ui;
fce44ae9 Andy McCall ↶ 7
use anyhow::Result;
8
use oxy_core::hash::ChangeHash;
75167672 Andy McCall 9
use oxy_core::hash::KeyId;
7ba155f2 Andy McCall ↶ 10
use oxy_core::identity::{Delegate, Identity, Scope};
28fc23d7 Andy McCall ↶ 11
use oxy_core::objects::Hlc;
12
use oxy_core::protocol::SignedPolicy;
c6aa5bd9 Andy McCall ↶ 13
use oxy_core::repo::{self, IdentityScope, PolicyUpdate, Repo, Rotation, SignatureStatus};
83e0677b Andy McCall ↶ 14
use std::path::{Path, PathBuf};
fce44ae9 Andy McCall ↶ 15
28fc23d7 Andy McCall ↶ 16
const DAY_MS: u64 = 24 * 60 * 60 * 1000;
17
18
fn expiry_from_days(days: u64) -> Option<u64> {
19
    (days > 0).then(|| Hlc::now().wall_ms + days * DAY_MS)
20
}
21
7ba155f2 Andy McCall ↶ 22
/// `30m`, `8h`, `7d`, or a bare number of days, as a wall time from now.
23
fn expiry_from_duration(s: &str) -> Result<u64> {
24
    let s = s.trim();
25
    let (n, unit) = match s.char_indices().find(|(_, c)| !c.is_ascii_digit()) {
26
        Some((i, _)) => (&s[..i], &s[i..]),
27
        None => (s, "d"),
28
    };
29
    let n: u64 = n.parse().map_err(|_| {
30
        anyhow::anyhow!("--expires takes 30m, 8h, 7d or a number of days, not {s:?}")
31
    })?;
32
    let ms = match unit {
33
        "m" => 60_000,
34
        "h" => 3_600_000,
35
        "d" => DAY_MS,
36
        _ => anyhow::bail!("--expires takes 30m, 8h, 7d or a number of days, not {s:?}"),
37
    };
38
    if n == 0 {
39
        anyhow::bail!("--expires needs a length of time; leave it out for no expiry");
40
    }
41
    Ok(Hlc::now().wall_ms + n * ms)
42
}
43
44
/// One line per delegate: key, scope, expiry.
45
fn delegate_lines(delegates: &[Delegate]) -> String {
46
    delegates
47
        .iter()
48
        .map(|d| {
49
            format!(
50
                "{} ({}{})",
51
                d.key.short(12),
52
                d.scope,
53
                d.expires
54
                    .map(|e| format!(", until {}", when(e)))
55
                    .unwrap_or_default()
56
            )
57
        })
58
        .collect::<Vec<_>>()
59
        .join(" ")
60
}
61
28fc23d7 Andy McCall ↶ 62
/// Resolve a KeyId prefix among the identities the repository knows.
63
fn resolve_key(repo: &Repo, prefix: &str) -> Result<KeyId> {
64
    if let Some(k) = KeyId::from_hex(prefix) {
65
        return Ok(k);
66
    }
67
    let mut candidates: Vec<KeyId> = Vec::new();
68
    if let Some((_, sp)) = repo.current_policy()? {
69
        candidates.extend(sp.policy.identities.iter().map(|i| i.hash()));
70
        candidates.extend(sp.policy.revoked.iter().copied());
71
    }
72
    if let Some(own) = repo.config.author_key {
73
        candidates.push(own);
74
        if let Ok(id) = repo.identity(&own) {
7ba155f2 Andy McCall ↶ 75
            candidates.extend(id.delegates.iter().map(|d| d.key));
28fc23d7 Andy McCall ↶ 76
        }
77
    }
78
    candidates.sort();
79
    candidates.dedup();
75167672 Andy McCall 80
    let matches: Vec<_> = candidates
81
        .into_iter()
82
        .filter(|k| k.starts_with_hex(prefix))
83
        .collect();
28fc23d7 Andy McCall ↶ 84
    match matches.len() {
85
        1 => Ok(matches[0]),
86
        0 => anyhow::bail!("no known key starts with {prefix:?}"),
87
        _ => anyhow::bail!("{prefix:?} is ambiguous"),
88
    }
89
}
90
91
/// Report a policy update: applied, or written out as a proposal that
92
/// other root holders sign with `oxy policy sign`.
93
fn report_update(u: &PolicyUpdate, out: Option<PathBuf>) -> Result<()> {
94
    if u.applied {
75167672 Andy McCall 95
        println!(
96
            "{} policy serial {} {}",
97
            ui::done("Applied"),
98
            u.policy.serial,
99
            ui::dim("(push to publish it)")
100
        );
28fc23d7 Andy McCall ↶ 101
        return Ok(());
102
    }
103
    let path = out.unwrap_or_else(|| PathBuf::from(format!("policy-{}.proposal", u.policy.serial)));
104
    std::fs::write(&path, u.signed().encode())?;
105
    println!(
106
        "{} policy serial {} needs {} of {} root signatures; has {}. Wrote {} for another root to run {}",
107
        ui::caution(),
108
        u.policy.serial,
109
        u.policy.root_threshold,
110
        u.policy.roots.len(),
111
        u.sigs.len(),
112
        ui::path(path.display()),
113
        ui::dim(format!("oxy policy sign {}", path.display()))
114
    );
115
    Ok(())
116
}
117
118
fn report_rotation(r: &Rotation, verb: &str) -> Result<()> {
75167672 Andy McCall 119
    println!(
120
        "{} identity {} <{}>",
121
        ui::done(verb),
122
        ui::author(&r.identity.name),
123
        r.identity.email
124
    );
28fc23d7 Andy McCall ↶ 125
    println!("{}", ui::kv("old key", ui::dim(r.old.to_hex())));
126
    println!("{}", ui::kv("new key", r.new.to_hex()));
127
    if !r.identity.delegates.is_empty() {
75167672 Andy McCall 128
        println!(
129
            "{}",
7ba155f2 Andy McCall ↶ 130
            ui::kv("delegates", delegate_lines(&r.identity.delegates))
75167672 Andy McCall 131
        );
28fc23d7 Andy McCall ↶ 132
    }
133
    if let Some(e) = r.identity.expires {
134
        println!("{}", ui::kv("expires", when(e)));
135
    }
136
    match &r.policy {
137
        Some(u) => report_update(u, None)?,
75167672 Andy McCall 138
        None => println!(
139
            "  {} {}",
140
            ui::note(),
141
            ui::dim("no policy names the old key; export the new identity for policy holders")
142
        ),
28fc23d7 Andy McCall ↶ 143
    }
144
    Ok(())
145
}
146
75167672 Andy McCall 147
pub fn create(
148
    name: Option<String>,
149
    email: Option<String>,
150
    local: bool,
151
    expires: Option<u64>,
152
) -> Result<i32> {
fce44ae9 Andy McCall ↶ 153
    let mut repo = open()?;
154
    let name = name.unwrap_or_else(|| repo.config.author_name.clone());
155
    let email = email.unwrap_or_else(|| repo.config.author_email.clone());
75167672 Andy McCall 156
    let scope = if local {
157
        IdentityScope::Repo
158
    } else {
159
        IdentityScope::User
160
    };
161
    let (key, _) =
162
        repo.create_identity_with(&name, &email, scope, expires.and_then(expiry_from_days))?;
163
    println!(
164
        "{} identity {} <{email}>",
165
        ui::done("Created"),
166
        ui::author(&name)
167
    );
94084087 Andy McCall ↶ 168
    println!("{}", ui::kv("key", key));
75167672 Andy McCall 169
    println!(
170
        "{}",
171
        ui::kv("secret", ui::path(repo::default_keys_dir().display()))
172
    );
fce44ae9 Andy McCall ↶ 173
    match scope {
75167672 Andy McCall 174
        IdentityScope::User => println!(
175
            "{}",
176
            ui::kv(
177
                "scope",
178
                format!(
179
                    "default author in {}",
180
                    ui::path(repo.user_config_path().display())
181
                )
182
            )
183
        ),
184
        IdentityScope::Repo => {
185
            println!("{}", ui::kv("scope", "this repository's .oxy/config only"))
186
        }
fce44ae9 Andy McCall ↶ 187
    }
188
    if !repo.config.sign_changes {
94084087 Andy McCall ↶ 189
        println!("  {} {}", ui::note(), ui::dim("changes will carry this author; run oxy config --user core.sign change to sign each commit"));
fce44ae9 Andy McCall ↶ 190
    }
191
    Ok(0)
192
}
193
194
pub fn show() -> Result<i32> {
195
    let repo = open()?;
196
    let Some(key) = repo.config.author_key else {
197
        println!("No identity configured; run `oxy identity create`.");
198
        return Ok(0);
199
    };
200
    let id = repo.identity(&key)?;
75167672 Andy McCall 201
    println!(
202
        "{} {} <{}>",
203
        ui::section("identity"),
204
        ui::author(&id.name),
205
        id.email
206
    );
94084087 Andy McCall ↶ 207
    println!("{}", ui::kv("key", key));
75167672 Andy McCall 208
    println!(
209
        "{}",
210
        ui::kv(
211
            "created",
212
            format!(
213
                "{} {}",
214
                when(id.created),
215
                ui::dim(format!("({})", ui::ago(id.created)))
216
            )
217
        )
218
    );
28fc23d7 Andy McCall ↶ 219
    if let Some(e) = id.expires {
220
        let expired = id.is_expired_at(Hlc::now().wall_ms);
75167672 Andy McCall 221
        println!(
222
            "{}",
223
            ui::kv(
224
                "expires",
225
                if expired {
226
                    ui::err(format!("{} EXPIRED", when(e)))
227
                } else {
228
                    when(e)
229
                }
230
            )
231
        );
28fc23d7 Andy McCall ↶ 232
    }
233
    if let Some(p) = id.previous {
234
        println!("{}", ui::kv("previous", ui::dim(p.short(12))));
235
    }
236
    if !id.delegates.is_empty() {
7ba155f2 Andy McCall ↶ 237
        println!("{}", ui::kv("delegates", delegate_lines(&id.delegates)));
28fc23d7 Andy McCall ↶ 238
    }
239
    if repo.is_revoked(&key)? {
75167672 Andy McCall 240
        println!(
241
            "{}",
242
            ui::kv(
243
                "status",
244
                ui::err(format!("{} REVOKED", ui::glyph("✖", "x")))
245
            )
246
        );
28fc23d7 Andy McCall ↶ 247
    }
75167672 Andy McCall 248
    println!(
249
        "{}",
250
        ui::kv(
251
            "secret",
252
            if repo.secret_key()?.is_some() {
253
                ui::ok(format!("{} available", ui::glyph("✔", "*")))
254
            } else {
255
                ui::err(format!(
256
                    "{} not found in the key directory",
257
                    ui::glyph("✖", "x")
258
                ))
259
            }
260
        )
261
    );
262
    println!(
263
        "{}",
264
        ui::kv(
265
            "signing",
266
            if repo.config.sign_changes {
267
                "every change (core.sign = change)"
268
            } else {
269
                "at push via seals (core.sign = seal)"
270
            }
271
        )
272
    );
fce44ae9 Andy McCall ↶ 273
    Ok(0)
274
}
275
c6aa5bd9 Andy McCall ↶ 276
pub fn verify(change: Option<&str>, as_json: bool) -> Result<i32> {
fce44ae9 Andy McCall ↶ 277
    let repo = open()?;
278
    let hashes: Vec<ChangeHash> = match change {
279
        Some(c) => vec![repo.resolve_change(c)?],
75167672 Andy McCall 280
        None => repo
281
            .log()?
282
            .into_iter()
283
            .filter(|e| !e.is_working_copy)
284
            .map(|e| e.hash)
285
            .collect(),
fce44ae9 Andy McCall ↶ 286
    };
287
    let mut bad = 0;
c6aa5bd9 Andy McCall ↶ 288
    if as_json {
289
        let mut items = Vec::new();
290
        for h in &hashes {
75167672 Andy McCall 291
            let Ok(c) = repo.store().get_change(h) else {
292
                continue;
293
            };
c6aa5bd9 Andy McCall ↶ 294
            let (status, key, name, revoked, reason) = match repo.authenticate_change(h)? {
295
                SignatureStatus::Unsigned => ("unsigned", None, None, false, None),
75167672 Andy McCall 296
                SignatureStatus::Valid {
297
                    key,
298
                    name,
299
                    sealed,
300
                    revoked,
301
                    ..
302
                } => (
303
                    if sealed { "sealed" } else { "signed" },
304
                    Some(key.to_hex()),
305
                    Some(name),
306
                    revoked,
307
                    None,
308
                ),
c6aa5bd9 Andy McCall ↶ 309
                SignatureStatus::Invalid(why) => {
310
                    bad += 1;
311
                    ("invalid", None, None, false, Some(why))
312
                }
313
            };
314
            items.push(json::obj([
315
                ("hash", json::s(&h.to_hex())),
316
                ("change_id", json::s(&c.change_id.to_hex())),
317
                ("status", json::s(status)),
318
                ("key", json::opt(key.as_deref(), json::s)),
319
                ("name", json::opt(name.as_deref(), json::s)),
320
                ("revoked", json::b(revoked)),
321
                ("reason", json::opt(reason.as_deref(), json::s)),
322
            ]));
323
        }
324
        println!("{}", json::arr(items));
325
        return Ok(if bad > 0 { 1 } else { 0 });
326
    }
fce44ae9 Andy McCall ↶ 327
    for h in hashes {
2863456a Andy McCall ↶ 328
        let Ok(c) = repo.store().get_change(&h) else {
75167672 Andy McCall 329
            println!(
330
                "{}  {}",
331
                ui::hash(format!("#{}", h.short(12))),
332
                ui::dim("not present (shallow)")
333
            );
2863456a Andy McCall ↶ 334
            continue;
335
        };
fce44ae9 Andy McCall ↶ 336
        let line = signature_line(&repo, &h)?;
135212de Andy McCall ↶ 337
        let styled = if line.starts_with("INVALID") {
fce44ae9 Andy McCall ↶ 338
            bad += 1;
94084087 Andy McCall ↶ 339
            ui::err(format!("{} {line}", ui::glyph("✖", "x")))
135212de Andy McCall ↶ 340
        } else if line == "unsigned" {
94084087 Andy McCall ↶ 341
            ui::dim(format!("{} {line}", ui::glyph("·", "-")))
135212de Andy McCall ↶ 342
        } else {
94084087 Andy McCall ↶ 343
            ui::ok(format!("{} {line}", ui::glyph("✔", "v")))
135212de Andy McCall ↶ 344
        };
75167672 Andy McCall 345
        println!(
346
            "{} {}  {styled}",
347
            ui::id(short_id(&c)),
348
            ui::hash(format!("#{}", h.short(12)))
349
        );
fce44ae9 Andy McCall ↶ 350
    }
351
    Ok(if bad > 0 { 1 } else { 0 })
352
}
353
354
pub fn config(key: &str, value: &str, user: bool) -> Result<i32> {
355
    if user {
356
        let path = repo::default_user_config();
357
        repo::Config::set_user(&path, key, value)?;
75167672 Andy McCall 358
        println!(
359
            "{} {key} = {value} {}",
360
            ui::done("Set"),
361
            ui::dim(format!("(in {})", path.display()))
362
        );
fce44ae9 Andy McCall ↶ 363
    } else {
364
        let mut repo = open()?;
365
        repo.config.set(key, value)?;
366
        repo.save_config()?;
75167672 Andy McCall 367
        println!(
368
            "{} {key} = {value} {}",
369
            ui::done("Set"),
370
            ui::dim("(in this repository)")
371
        );
fce44ae9 Andy McCall ↶ 372
    }
373
    Ok(0)
374
}
83e0677b Andy McCall ↶ 375
a39754d0 Andy McCall ↶ 376
pub fn export(path: Option<PathBuf>, pem: bool) -> Result<i32> {
83e0677b Andy McCall ↶ 377
    let repo = open()?;
a39754d0 Andy McCall ↶ 378
    // The PEM is for checking what this home signs, so it is the signing
379
    // key's: a delegate's when `author.signer` names one.
380
    let key = if pem {
381
        repo.signing_key()
382
    } else {
383
        repo.config.author_key
384
    };
385
    let Some(key) = key else {
83e0677b Andy McCall ↶ 386
        anyhow::bail!("no identity configured; run `oxy identity create`");
387
    };
388
    let id = repo.identity(&key)?;
a39754d0 Andy McCall ↶ 389
    let ext = if pem { "pem" } else { "identity" };
390
    let path = path.unwrap_or_else(|| PathBuf::from(format!("{}.{ext}", key.short(16))));
391
    if pem {
392
        std::fs::write(&path, oxy_core::dsse::public_pem(&id))?;
393
    } else {
394
        std::fs::write(&path, id.encode())?;
395
    }
75167672 Andy McCall 396
    println!(
a39754d0 Andy McCall ↶ 397
        "{} {} {} <{}> ({}) to {}",
75167672 Andy McCall 398
        ui::done("Wrote"),
a39754d0 Andy McCall ↶ 399
        if pem { "the public key of" } else { "identity" },
75167672 Andy McCall 400
        ui::author(&id.name),
401
        id.email,
402
        key.short(12),
403
        ui::path(path.display())
404
    );
83e0677b Andy McCall ↶ 405
    Ok(0)
406
}
407
28fc23d7 Andy McCall ↶ 408
pub fn rotate() -> Result<i32> {
409
    let mut repo = open()?;
410
    let r = repo.rotate_identity()?;
411
    report_rotation(&r, "Rotated")?;
412
    Ok(0)
413
}
414
415
pub fn renew(expires: u64) -> Result<i32> {
416
    let mut repo = open()?;
417
    let r = repo.renew_identity(expiry_from_days(expires))?;
418
    report_rotation(&r, "Renewed")?;
419
    Ok(0)
420
}
421
422
pub fn revoke(key: Option<&str>, reason: &str) -> Result<i32> {
423
    let repo = open()?;
424
    let key = match key {
425
        Some(k) => resolve_key(&repo, k)?,
75167672 Andy McCall 426
        None => repo
427
            .config
428
            .author_key
429
            .ok_or_else(|| anyhow::anyhow!("no identity configured"))?,
28fc23d7 Andy McCall ↶ 430
    };
431
    let update = repo.revoke_identity(&key, reason)?;
75167672 Andy McCall 432
    println!(
433
        "{} key {}{}",
434
        ui::done("Revoked"),
435
        ui::hash(key.short(12)),
436
        if reason.is_empty() {
437
            String::new()
438
        } else {
439
            format!(": {reason}")
440
        }
441
    );
28fc23d7 Andy McCall ↶ 442
    match update {
443
        Some(u) => report_update(&u, None)?,
75167672 Andy McCall 444
        None => println!(
445
            "  {} {}",
446
            ui::note(),
447
            ui::dim("recorded locally; no policy named the key")
448
        ),
28fc23d7 Andy McCall ↶ 449
    }
7ba155f2 Andy McCall ↶ 450
    // A revoked delegate comes off the identity too, so the successor no
451
    // longer names it (spec §14.2).
452
    let own = repo.config.author_key;
453
    let delegated = own.is_some_and(|o| {
454
        repo.identity(&o)
455
            .is_ok_and(|id| id.delegate(&key).is_some())
456
    });
457
    if delegated {
458
        let mut repo = repo;
459
        let r = repo.delegate_remove(&key)?;
460
        report_rotation(&r, "Undelegated")?;
461
        return Ok(0);
462
    }
28fc23d7 Andy McCall ↶ 463
    if repo.config.author_key == Some(key) {
75167672 Andy McCall 464
        println!(
465
            "  {} {}",
466
            ui::caution(),
467
            ui::warn("this was your configured key; run oxy identity create or rotate")
468
        );
28fc23d7 Andy McCall ↶ 469
    }
470
    Ok(0)
471
}
472
7ba155f2 Andy McCall ↶ 473
pub fn delegate_add(identity_file: &Path, scope: &str, expires: Option<&str>) -> Result<i32> {
28fc23d7 Andy McCall ↶ 474
    let mut repo = open()?;
475
    let id = Identity::decode(&std::fs::read(identity_file)?)?;
7ba155f2 Andy McCall ↶ 476
    let scope = Scope::parse(scope).map_err(|e| anyhow::anyhow!("{e}"))?;
477
    let expires = expires.map(expiry_from_duration).transpose()?;
478
    let r = repo.delegate_add(id, scope, expires)?;
28fc23d7 Andy McCall ↶ 479
    report_rotation(&r, "Delegated")?;
7ba155f2 Andy McCall ↶ 480
    if !scope.has(Scope::REFS) {
481
        println!(
482
            "  {} {}",
483
            ui::note(),
484
            ui::dim("it may not move references: a push or tag it attests is refused")
485
        );
486
    }
28fc23d7 Andy McCall ↶ 487
    Ok(0)
488
}
489
490
pub fn delegate_remove(key: &str) -> Result<i32> {
491
    let mut repo = open()?;
492
    let k = resolve_key(&repo, key)?;
493
    let r = repo.delegate_remove(&k)?;
494
    report_rotation(&r, "Undelegated")?;
495
    Ok(0)
496
}
497
498
pub fn delegate_list() -> Result<i32> {
499
    let repo = open()?;
500
    let Some(key) = repo.config.author_key else {
501
        println!("No identity configured; run `oxy identity create`.");
502
        return Ok(0);
503
    };
504
    let id = repo.identity(&key)?;
505
    if id.delegates.is_empty() {
506
        println!("{}", ui::dim("No delegates."));
507
    }
7ba155f2 Andy McCall ↶ 508
    let now = Hlc::now().wall_ms;
28fc23d7 Andy McCall ↶ 509
    for d in &id.delegates {
75167672 Andy McCall 510
        let who = repo
7ba155f2 Andy McCall ↶ 511
            .identity(&d.key)
75167672 Andy McCall 512
            .map(|i| format!("{} <{}>", i.name, i.email))
513
            .unwrap_or_else(|_| "(unknown identity)".into());
7ba155f2 Andy McCall ↶ 514
        let until = match d.expires {
515
            Some(e) if now > e => ui::err(format!("expired {}", ui::ago(e))),
516
            Some(e) => ui::dim(format!("until {}", when(e))),
517
            None => String::new(),
518
        };
75167672 Andy McCall 519
        println!(
7ba155f2 Andy McCall ↶ 520
            "  {} {}  {}  {} {}",
75167672 Andy McCall 521
            ui::ok(ui::glyph("🔑", "*")),
7ba155f2 Andy McCall ↶ 522
            ui::hash(d.key.short(12)),
523
            ui::author(who),
524
            ui::dim(d.scope.to_string()),
525
            until
75167672 Andy McCall 526
        );
28fc23d7 Andy McCall ↶ 527
    }
528
    Ok(0)
529
}
530
531
pub fn policy_root_add(identity_file: &Path, out: Option<PathBuf>) -> Result<i32> {
532
    let repo = open()?;
533
    let id = Identity::decode(&std::fs::read(identity_file)?)?;
534
    let key = id.hash();
535
    let u = repo.policy_root_add(id.clone())?;
75167672 Andy McCall 536
    println!(
537
        "{} root {} <{}> ({})",
538
        ui::done("Added"),
539
        ui::author(&id.name),
540
        id.email,
541
        key.short(12)
542
    );
28fc23d7 Andy McCall ↶ 543
    report_update(&u, out)?;
544
    Ok(0)
545
}
546
547
pub fn policy_root_remove(key: &str, out: Option<PathBuf>) -> Result<i32> {
548
    let repo = open()?;
549
    let k = resolve_key(&repo, key)?;
550
    let u = repo.policy_root_remove(&k)?;
551
    println!("{} root {}", ui::done("Removed"), ui::hash(k.short(12)));
552
    report_update(&u, out)?;
553
    Ok(0)
554
}
555
556
pub fn policy_threshold(n: u32, out: Option<PathBuf>) -> Result<i32> {
557
    let repo = open()?;
558
    let u = repo.policy_set_threshold(n)?;
559
    println!("{} root threshold to {n}", ui::done("Set"));
560
    report_update(&u, out)?;
561
    Ok(0)
562
}
563
564
pub fn policy_sign(proposal: &Path, out: Option<PathBuf>) -> Result<i32> {
565
    let repo = open()?;
566
    let sp = SignedPolicy::decode(&std::fs::read(proposal)?)?;
567
    let u = repo.policy_sign(sp)?;
568
    println!("{} policy serial {}", ui::done("Signed"), u.policy.serial);
569
    report_update(&u, Some(out.unwrap_or_else(|| proposal.to_path_buf())))?;
570
    Ok(0)
571
}
572
83e0677b Andy McCall ↶ 573
pub fn policy_init() -> Result<i32> {
574
    let repo = open()?;
575
    let sp = repo.policy_init()?;
75167672 Andy McCall 576
    println!(
577
        "{} policy with root key {}",
578
        ui::done("Created"),
579
        sp.policy.roots[0].short(12)
580
    );
28fc23d7 Andy McCall ↶ 581
    report_update(&sp, None)?;
83e0677b Andy McCall ↶ 582
    Ok(0)
583
}
584
c6aa5bd9 Andy McCall ↶ 585
pub fn policy_show(as_json: bool) -> Result<i32> {
83e0677b Andy McCall ↶ 586
    let repo = open()?;
587
    let Some((r, sp)) = repo.current_policy()? else {
c6aa5bd9 Andy McCall ↶ 588
        if as_json {
589
            println!("null");
590
        } else {
75167672 Andy McCall 591
            println!(
592
                "No policy: this repository is unverified. Run `oxy policy init` to create one."
593
            );
c6aa5bd9 Andy McCall ↶ 594
        }
83e0677b Andy McCall ↶ 595
        return Ok(0);
596
    };
597
    let p = &sp.policy;
c6aa5bd9 Andy McCall ↶ 598
    if as_json {
599
        let ident = |k: &oxy_core::hash::KeyId| {
75167672 Andy McCall 600
            let (name, email) = p
601
                .identity(k)
602
                .map(|i| (i.name.clone(), i.email.clone()))
603
                .unwrap_or_default();
604
            json::obj([
605
                ("key", json::s(&k.to_hex())),
606
                ("name", json::s(&name)),
607
                ("email", json::s(&email)),
608
            ])
c6aa5bd9 Andy McCall ↶ 609
        };
610
        println!(
611
            "{}",
612
            json::obj([
613
                ("hash", json::s(&r.to_hex())),
614
                ("serial", json::n(p.serial)),
615
                ("expires_ms", json::n(p.expires)),
75167672 Andy McCall 616
                (
617
                    "expired",
618
                    json::b(p.is_expired(oxy_core::objects::Hlc::now().wall_ms))
619
                ),
c6aa5bd9 Andy McCall ↶ 620
                ("root_threshold", json::n(p.root_threshold)),
621
                ("roots", json::arr(p.roots.iter().map(ident))),
622
                (
623
                    "rules",
624
                    json::arr(p.rules.iter().map(|rule| {
625
                        json::obj([
626
                            ("pattern", json::s(&rule.pattern)),
627
                            ("keys", json::arr(rule.keys.iter().map(ident))),
628
                            ("threshold", json::n(rule.threshold)),
629
                            ("require", json::s(rule.require.name())),
630
                            ("allow_conflicts", json::b(rule.allow_conflicts)),
631
                        ])
632
                    })),
633
                ),
634
                ("revoked", json::arr(p.revoked.iter().map(ident))),
635
            ])
636
        );
637
        return Ok(0);
638
    }
75167672 Andy McCall 639
    let name = |k: &oxy_core::hash::KeyId| {
640
        p.identity(k)
641
            .map(|i| format!("{} <{}>", i.name, i.email))
642
            .unwrap_or_else(|| "(unknown identity)".into())
643
    };
94084087 Andy McCall ↶ 644
    let expired = p.is_expired(oxy_core::objects::Hlc::now().wall_ms);
75167672 Andy McCall 645
    println!(
646
        "{} {}{}serial {}{}expires {}{}",
647
        ui::section("policy"),
648
        ui::hash(format!("#{}", r.short(12))),
649
        ui::sep(),
650
        p.serial,
651
        ui::sep(),
652
        when(p.expires),
653
        if expired {
654
            ui::err(format!("  {} EXPIRED", ui::glyph("✖", "x")))
655
        } else {
656
            String::new()
657
        }
658
    );
659
    println!(
660
        "{}",
661
        ui::section(format!(
662
            "roots {}",
663
            ui::dim(format!("(threshold {})", p.root_threshold))
664
        ))
665
    );
83e0677b Andy McCall ↶ 666
    for k in &p.roots {
75167672 Andy McCall 667
        println!(
668
            "  {} {}  {}",
669
            ui::ok(ui::glyph("🔑", "*")),
670
            ui::hash(k.short(12)),
671
            ui::author(name(k))
672
        );
83e0677b Andy McCall ↶ 673
    }
94084087 Andy McCall ↶ 674
    println!("{}", ui::section("rules"));
83e0677b Andy McCall ↶ 675
    for rule in &p.rules {
75167672 Andy McCall 676
        println!(
677
            "  {} {:12} {}",
678
            ui::ok(ui::glyph("▸", ">")),
679
            ui::bookmark(&rule.pattern),
680
            ui::dim(format!(
681
                "threshold {}, require {}, conflicts {}",
682
                rule.threshold,
683
                rule.require.name(),
684
                if rule.allow_conflicts {
685
                    "allowed"
686
                } else {
687
                    "refused"
688
                }
689
            ))
690
        );
83e0677b Andy McCall ↶ 691
        for k in &rule.keys {
94084087 Andy McCall ↶ 692
            println!("      {}  {}", ui::hash(k.short(12)), ui::author(name(k)));
83e0677b Andy McCall ↶ 693
        }
694
    }
695
    if !p.revoked.is_empty() {
94084087 Andy McCall ↶ 696
        println!("{}", ui::section("revoked"));
83e0677b Andy McCall ↶ 697
        for k in &p.revoked {
75167672 Andy McCall 698
            println!(
699
                "  {} {}  {}",
700
                ui::err(ui::glyph("✖", "x")),
701
                ui::hash(k.short(12)),
702
                ui::author(name(k))
703
            );
83e0677b Andy McCall ↶ 704
        }
705
    }
706
    Ok(0)
707
}
708
75167672 Andy McCall 709
pub fn policy_grant(
710
    identity_file: &Path,
711
    pattern: Option<&str>,
712
    out: Option<PathBuf>,
713
) -> Result<i32> {
83e0677b Andy McCall ↶ 714
    let repo = open()?;
715
    let id = Identity::decode(&std::fs::read(identity_file)?)?;
716
    let key = id.hash();
28fc23d7 Andy McCall ↶ 717
    let u = repo.policy_grant(id.clone(), pattern)?;
75167672 Andy McCall 718
    println!(
719
        "{} {} <{}> ({}) on {}",
720
        ui::done("Granted"),
721
        ui::author(&id.name),
722
        id.email,
723
        key.short(12),
724
        ui::bookmark(pattern.unwrap_or("*"))
725
    );
28fc23d7 Andy McCall ↶ 726
    report_update(&u, out)?;
83e0677b Andy McCall ↶ 727
    Ok(0)
728
}
729
28fc23d7 Andy McCall ↶ 730
pub fn policy_revoke(key: &str, out: Option<PathBuf>) -> Result<i32> {
83e0677b Andy McCall ↶ 731
    let repo = open()?;
28fc23d7 Andy McCall ↶ 732
    if repo.current_policy()?.is_none() {
83e0677b Andy McCall ↶ 733
        anyhow::bail!("this repository has no policy");
28fc23d7 Andy McCall ↶ 734
    }
735
    let full = resolve_key(&repo, key)?;
736
    let u = repo.policy_revoke(&full)?;
737
    println!("{} {}", ui::done("Revoked"), ui::hash(full.short(12)));
738
    report_update(&u, out)?;
83e0677b Andy McCall ↶ 739
    Ok(0)
740
}
05e71ed6 Andy McCall ↶ 741
742
/// `oxy seal [CHANGE..]`: seal the named changes, or every sealable one.
743
pub fn seal(specs: &[String], as_json: bool) -> Result<i32> {
744
    let repo = Repo::open(&std::env::current_dir()?)?;
745
    let hashes: Vec<_> = if specs.is_empty() {
746
        repo.sealable()?
747
    } else {
b0e64e81 Andy McCall ↶ 748
        specs
749
            .iter()
750
            .map(|s| repo.resolve_change(s))
751
            .collect::<std::result::Result<_, _>>()?
05e71ed6 Andy McCall ↶ 752
    };
753
    if hashes.is_empty() {
754
        if as_json {
755
            println!("{{\"sealed\":[]}}");
756
        } else {
b0e64e81 Andy McCall ↶ 757
            println!(
758
                "{} nothing to seal: every change you can authenticate already is",
759
                ui::done("Sealed")
760
            );
05e71ed6 Andy McCall ↶ 761
        }
762
        return Ok(0);
763
    }
764
    let mut refs = Vec::new();
765
    for chunk in hashes.chunks(oxy_core::attest::MAX_SEAL_HASHES) {
766
        refs.push(repo.seal(chunk)?);
767
    }
768
    if as_json {
b0e64e81 Andy McCall ↶ 769
        let list: Vec<String> = hashes
770
            .iter()
771
            .map(|h| format!("\"{}\"", h.to_hex()))
772
            .collect();
05e71ed6 Andy McCall ↶ 773
        let seals: Vec<String> = refs.iter().map(|r| format!("\"{}\"", r.to_hex())).collect();
b0e64e81 Andy McCall ↶ 774
        println!(
775
            "{{\"sealed\":[{}],\"seals\":[{}]}}",
776
            list.join(","),
777
            seals.join(",")
778
        );
05e71ed6 Andy McCall ↶ 779
    } else {
b0e64e81 Andy McCall ↶ 780
        println!(
781
            "{} {} change{} in {} seal{}; they travel with your next push",
782
            ui::done("Sealed"),
783
            hashes.len(),
784
            if hashes.len() == 1 { "" } else { "s" },
785
            refs.len(),
786
            if refs.len() == 1 { "" } else { "s" }
787
        );
05e71ed6 Andy McCall ↶ 788
        for h in &hashes {
b0e64e81 Andy McCall ↶ 789
            let title = repo
790
                .store()
791
                .get_change(h)
792
                .map(|c| c.message.lines().next().unwrap_or("").to_string())
793
                .unwrap_or_default();
05e71ed6 Andy McCall ↶ 794
            println!("  {} {}", ui::hash(h.short(12)), title);
795
        }
796
    }
797
    Ok(0)
798
}
7aa2dc93 Andy McCall ↶ 799
800
/// `oxy identity sign-challenge HEX`: the composite signature over a
801
/// host's challenge, as hex of the CBOR signature (hub spec §5.3).
802
pub fn sign_challenge(challenge_hex: &str, as_json: bool) -> Result<i32> {
803
    let repo = open()?;
804
    let Some((key, sk)) = repo.secret_key()? else {
805
        anyhow::bail!("no secret key: run `oxy identity create` first");
806
    };
119bbf50 Andy McCall ↶ 807
    let challenge = hex_bytes(challenge_hex.trim())
808
        .ok_or_else(|| anyhow::anyhow!("the challenge must be hex"))?;
809
    let sig = sk
810
        .sign_challenge(&key, &challenge)
811
        .map_err(|e| anyhow::anyhow!("{e}"))?;
7aa2dc93 Andy McCall ↶ 812
    let encoded = hex_string(&oxy_core::identity::Signature::encode_list(&[sig]));
813
    if as_json {
119bbf50 Andy McCall ↶ 814
        println!(
815
            "{{\"key\":\"{}\",\"signature\":\"{}\"}}",
816
            key.to_hex(),
817
            encoded
818
        );
7aa2dc93 Andy McCall ↶ 819
    } else {
820
        println!("{encoded}");
821
    }
822
    Ok(0)
823
}
824
825
fn hex_bytes(s: &str) -> Option<Vec<u8>> {
826
    if !s.len().is_multiple_of(2) {
827
        return None;
828
    }
119bbf50 Andy McCall ↶ 829
    (0..s.len())
830
        .step_by(2)
831
        .map(|i| u8::from_str_radix(&s[i..i + 2], 16).ok())
832
        .collect()
7aa2dc93 Andy McCall ↶ 833
}
834
835
fn hex_string(b: &[u8]) -> String {
836
    b.iter().map(|x| format!("{x:02x}")).collect()
837
}