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