OXYGENHUB
# 3a6effc74835 oxygen / src / cli / identity.rs
26.5 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
/// 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
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) {
75
            candidates.extend(id.delegates.iter().map(|d| d.key));
76
        }
77
    }
78
    candidates.sort();
79
    candidates.dedup();
80
    let matches: Vec<_> = candidates
81
        .into_iter()
82
        .filter(|k| k.starts_with_hex(prefix))
83
        .collect();
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 {
95
        println!(
96
            "{} policy serial {} {}",
97
            ui::done("Applied"),
98
            u.policy.serial,
99
            ui::dim("(push to publish it)")
100
        );
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<()> {
119
    println!(
120
        "{} identity {} <{}>",
121
        ui::done(verb),
122
        ui::author(&r.identity.name),
123
        r.identity.email
124
    );
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() {
128
        println!(
129
            "{}",
130
            ui::kv("delegates", delegate_lines(&r.identity.delegates))
131
        );
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)?,
138
        None => println!(
139
            "  {} {}",
140
            ui::note(),
141
            ui::dim("no policy names the old key; export the new identity for policy holders")
142
        ),
143
    }
144
    Ok(())
145
}
146
147
pub fn create(
148
    name: Option<String>,
149
    email: Option<String>,
150
    local: bool,
151
    expires: Option<u64>,
152
) -> Result<i32> {
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());
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
    );
168
    println!("{}", ui::kv("key", key));
169
    println!(
170
        "{}",
171
        ui::kv("secret", ui::path(repo::default_keys_dir().display()))
172
    );
173
    match scope {
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
        }
187
    }
188
    if !repo.config.sign_changes {
189
        println!("  {} {}", ui::note(), ui::dim("changes will carry this author; run oxy config --user core.sign change to sign each commit"));
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)?;
201
    println!(
202
        "{} {} <{}>",
203
        ui::section("identity"),
204
        ui::author(&id.name),
205
        id.email
206
    );
207
    println!("{}", ui::kv("key", key));
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
    );
219
    if let Some(e) = id.expires {
220
        let expired = id.is_expired_at(Hlc::now().wall_ms);
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
        );
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() {
237
        println!("{}", ui::kv("delegates", delegate_lines(&id.delegates)));
238
    }
239
    if repo.is_revoked(&key)? {
240
        println!(
241
            "{}",
242
            ui::kv(
243
                "status",
244
                ui::err(format!("{} REVOKED", ui::glyph("✖", "x")))
245
            )
246
        );
247
    }
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
    );
273
    Ok(0)
274
}
275
276
pub fn verify(change: Option<&str>, as_json: bool) -> Result<i32> {
277
    let repo = open()?;
278
    let hashes: Vec<ChangeHash> = match change {
279
        Some(c) => vec![repo.resolve_change(c)?],
280
        None => repo
281
            .log()?
282
            .into_iter()
283
            .filter(|e| !e.is_working_copy)
284
            .map(|e| e.hash)
285
            .collect(),
286
    };
287
    let mut bad = 0;
288
    if as_json {
289
        let mut items = Vec::new();
290
        for h in &hashes {
291
            let Ok(c) = repo.store().get_change(h) else {
292
                continue;
293
            };
294
            let (status, key, name, revoked, reason) = match repo.authenticate_change(h)? {
295
                SignatureStatus::Unsigned => ("unsigned", None, None, false, None),
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
                ),
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
    }
327
    for h in hashes {
328
        let Ok(c) = repo.store().get_change(&h) else {
329
            println!(
330
                "{}  {}",
331
                ui::hash(format!("#{}", h.short(12))),
332
                ui::dim("not present (shallow)")
333
            );
334
            continue;
335
        };
336
        let line = signature_line(&repo, &h)?;
337
        let styled = if line.starts_with("INVALID") {
338
            bad += 1;
339
            ui::err(format!("{} {line}", ui::glyph("✖", "x")))
340
        } else if line == "unsigned" {
341
            ui::dim(format!("{} {line}", ui::glyph("·", "-")))
342
        } else {
343
            ui::ok(format!("{} {line}", ui::glyph("✔", "v")))
344
        };
345
        println!(
346
            "{} {}  {styled}",
347
            ui::id(short_id(&c)),
348
            ui::hash(format!("#{}", h.short(12)))
349
        );
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)?;
358
        println!(
359
            "{} {key} = {value} {}",
360
            ui::done("Set"),
361
            ui::dim(format!("(in {})", path.display()))
362
        );
363
    } else {
364
        let mut repo = open()?;
365
        repo.config.set(key, value)?;
366
        repo.save_config()?;
367
        println!(
368
            "{} {key} = {value} {}",
369
            ui::done("Set"),
370
            ui::dim("(in this repository)")
371
        );
372
    }
373
    Ok(0)
374
}
375
376
pub fn export(path: Option<PathBuf>) -> Result<i32> {
377
    let repo = open()?;
378
    let Some(key) = repo.config.author_key else {
379
        anyhow::bail!("no identity configured; run `oxy identity create`");
380
    };
381
    let id = repo.identity(&key)?;
382
    let path = path.unwrap_or_else(|| PathBuf::from(format!("{}.identity", key.short(16))));
383
    std::fs::write(&path, id.encode())?;
384
    println!(
385
        "{} identity {} <{}> ({}) to {}",
386
        ui::done("Wrote"),
387
        ui::author(&id.name),
388
        id.email,
389
        key.short(12),
390
        ui::path(path.display())
391
    );
392
    Ok(0)
393
}
394
395
pub fn rotate() -> Result<i32> {
396
    let mut repo = open()?;
397
    let r = repo.rotate_identity()?;
398
    report_rotation(&r, "Rotated")?;
399
    Ok(0)
400
}
401
402
pub fn renew(expires: u64) -> Result<i32> {
403
    let mut repo = open()?;
404
    let r = repo.renew_identity(expiry_from_days(expires))?;
405
    report_rotation(&r, "Renewed")?;
406
    Ok(0)
407
}
408
409
pub fn revoke(key: Option<&str>, reason: &str) -> Result<i32> {
410
    let repo = open()?;
411
    let key = match key {
412
        Some(k) => resolve_key(&repo, k)?,
413
        None => repo
414
            .config
415
            .author_key
416
            .ok_or_else(|| anyhow::anyhow!("no identity configured"))?,
417
    };
418
    let update = repo.revoke_identity(&key, reason)?;
419
    println!(
420
        "{} key {}{}",
421
        ui::done("Revoked"),
422
        ui::hash(key.short(12)),
423
        if reason.is_empty() {
424
            String::new()
425
        } else {
426
            format!(": {reason}")
427
        }
428
    );
429
    match update {
430
        Some(u) => report_update(&u, None)?,
431
        None => println!(
432
            "  {} {}",
433
            ui::note(),
434
            ui::dim("recorded locally; no policy named the key")
435
        ),
436
    }
437
    // A revoked delegate comes off the identity too, so the successor no
438
    // longer names it (spec §14.2).
439
    let own = repo.config.author_key;
440
    let delegated = own.is_some_and(|o| {
441
        repo.identity(&o)
442
            .is_ok_and(|id| id.delegate(&key).is_some())
443
    });
444
    if delegated {
445
        let mut repo = repo;
446
        let r = repo.delegate_remove(&key)?;
447
        report_rotation(&r, "Undelegated")?;
448
        return Ok(0);
449
    }
450
    if repo.config.author_key == Some(key) {
451
        println!(
452
            "  {} {}",
453
            ui::caution(),
454
            ui::warn("this was your configured key; run oxy identity create or rotate")
455
        );
456
    }
457
    Ok(0)
458
}
459
460
pub fn delegate_add(identity_file: &Path, scope: &str, expires: Option<&str>) -> Result<i32> {
461
    let mut repo = open()?;
462
    let id = Identity::decode(&std::fs::read(identity_file)?)?;
463
    let scope = Scope::parse(scope).map_err(|e| anyhow::anyhow!("{e}"))?;
464
    let expires = expires.map(expiry_from_duration).transpose()?;
465
    let r = repo.delegate_add(id, scope, expires)?;
466
    report_rotation(&r, "Delegated")?;
467
    if !scope.has(Scope::REFS) {
468
        println!(
469
            "  {} {}",
470
            ui::note(),
471
            ui::dim("it may not move references: a push or tag it attests is refused")
472
        );
473
    }
474
    Ok(0)
475
}
476
477
pub fn delegate_remove(key: &str) -> Result<i32> {
478
    let mut repo = open()?;
479
    let k = resolve_key(&repo, key)?;
480
    let r = repo.delegate_remove(&k)?;
481
    report_rotation(&r, "Undelegated")?;
482
    Ok(0)
483
}
484
485
pub fn delegate_list() -> Result<i32> {
486
    let repo = open()?;
487
    let Some(key) = repo.config.author_key else {
488
        println!("No identity configured; run `oxy identity create`.");
489
        return Ok(0);
490
    };
491
    let id = repo.identity(&key)?;
492
    if id.delegates.is_empty() {
493
        println!("{}", ui::dim("No delegates."));
494
    }
495
    let now = Hlc::now().wall_ms;
496
    for d in &id.delegates {
497
        let who = repo
498
            .identity(&d.key)
499
            .map(|i| format!("{} <{}>", i.name, i.email))
500
            .unwrap_or_else(|_| "(unknown identity)".into());
501
        let until = match d.expires {
502
            Some(e) if now > e => ui::err(format!("expired {}", ui::ago(e))),
503
            Some(e) => ui::dim(format!("until {}", when(e))),
504
            None => String::new(),
505
        };
506
        println!(
507
            "  {} {}  {}  {} {}",
508
            ui::ok(ui::glyph("🔑", "*")),
509
            ui::hash(d.key.short(12)),
510
            ui::author(who),
511
            ui::dim(d.scope.to_string()),
512
            until
513
        );
514
    }
515
    Ok(0)
516
}
517
518
pub fn policy_root_add(identity_file: &Path, out: Option<PathBuf>) -> Result<i32> {
519
    let repo = open()?;
520
    let id = Identity::decode(&std::fs::read(identity_file)?)?;
521
    let key = id.hash();
522
    let u = repo.policy_root_add(id.clone())?;
523
    println!(
524
        "{} root {} <{}> ({})",
525
        ui::done("Added"),
526
        ui::author(&id.name),
527
        id.email,
528
        key.short(12)
529
    );
530
    report_update(&u, out)?;
531
    Ok(0)
532
}
533
534
pub fn policy_root_remove(key: &str, out: Option<PathBuf>) -> Result<i32> {
535
    let repo = open()?;
536
    let k = resolve_key(&repo, key)?;
537
    let u = repo.policy_root_remove(&k)?;
538
    println!("{} root {}", ui::done("Removed"), ui::hash(k.short(12)));
539
    report_update(&u, out)?;
540
    Ok(0)
541
}
542
543
pub fn policy_threshold(n: u32, out: Option<PathBuf>) -> Result<i32> {
544
    let repo = open()?;
545
    let u = repo.policy_set_threshold(n)?;
546
    println!("{} root threshold to {n}", ui::done("Set"));
547
    report_update(&u, out)?;
548
    Ok(0)
549
}
550
551
pub fn policy_sign(proposal: &Path, out: Option<PathBuf>) -> Result<i32> {
552
    let repo = open()?;
553
    let sp = SignedPolicy::decode(&std::fs::read(proposal)?)?;
554
    let u = repo.policy_sign(sp)?;
555
    println!("{} policy serial {}", ui::done("Signed"), u.policy.serial);
556
    report_update(&u, Some(out.unwrap_or_else(|| proposal.to_path_buf())))?;
557
    Ok(0)
558
}
559
560
pub fn policy_init() -> Result<i32> {
561
    let repo = open()?;
562
    let sp = repo.policy_init()?;
563
    println!(
564
        "{} policy with root key {}",
565
        ui::done("Created"),
566
        sp.policy.roots[0].short(12)
567
    );
568
    report_update(&sp, None)?;
569
    Ok(0)
570
}
571
572
pub fn policy_show(as_json: bool) -> Result<i32> {
573
    let repo = open()?;
574
    let Some((r, sp)) = repo.current_policy()? else {
575
        if as_json {
576
            println!("null");
577
        } else {
578
            println!(
579
                "No policy: this repository is unverified. Run `oxy policy init` to create one."
580
            );
581
        }
582
        return Ok(0);
583
    };
584
    let p = &sp.policy;
585
    if as_json {
586
        let ident = |k: &oxy_core::hash::KeyId| {
587
            let (name, email) = p
588
                .identity(k)
589
                .map(|i| (i.name.clone(), i.email.clone()))
590
                .unwrap_or_default();
591
            json::obj([
592
                ("key", json::s(&k.to_hex())),
593
                ("name", json::s(&name)),
594
                ("email", json::s(&email)),
595
            ])
596
        };
597
        println!(
598
            "{}",
599
            json::obj([
600
                ("hash", json::s(&r.to_hex())),
601
                ("serial", json::n(p.serial)),
602
                ("expires_ms", json::n(p.expires)),
603
                (
604
                    "expired",
605
                    json::b(p.is_expired(oxy_core::objects::Hlc::now().wall_ms))
606
                ),
607
                ("root_threshold", json::n(p.root_threshold)),
608
                ("roots", json::arr(p.roots.iter().map(ident))),
609
                (
610
                    "rules",
611
                    json::arr(p.rules.iter().map(|rule| {
612
                        json::obj([
613
                            ("pattern", json::s(&rule.pattern)),
614
                            ("keys", json::arr(rule.keys.iter().map(ident))),
615
                            ("threshold", json::n(rule.threshold)),
616
                            ("require", json::s(rule.require.name())),
617
                            ("allow_conflicts", json::b(rule.allow_conflicts)),
618
                            (
619
                                "subjects",
620
                                json::arr(rule.subjects.iter().map(|g| {
621
                                    json::obj([
622
                                        ("subject", json::s(&g.pattern)),
623
                                        ("issuer", ident(&g.issuer)),
624
                                        ("scope", json::s(&g.scope.to_string())),
625
                                    ])
626
                                })),
627
                            ),
628
                        ])
629
                    })),
630
                ),
631
                ("revoked", json::arr(p.revoked.iter().map(ident))),
632
            ])
633
        );
634
        return Ok(0);
635
    }
636
    let name = |k: &oxy_core::hash::KeyId| {
637
        p.identity(k)
638
            .map(|i| format!("{} <{}>", i.name, i.email))
639
            .unwrap_or_else(|| "(unknown identity)".into())
640
    };
641
    let expired = p.is_expired(oxy_core::objects::Hlc::now().wall_ms);
642
    println!(
643
        "{} {}{}serial {}{}expires {}{}",
644
        ui::section("policy"),
645
        ui::hash(format!("#{}", r.short(12))),
646
        ui::sep(),
647
        p.serial,
648
        ui::sep(),
649
        when(p.expires),
650
        if expired {
651
            ui::err(format!("  {} EXPIRED", ui::glyph("✖", "x")))
652
        } else {
653
            String::new()
654
        }
655
    );
656
    println!(
657
        "{}",
658
        ui::section(format!(
659
            "roots {}",
660
            ui::dim(format!("(threshold {})", p.root_threshold))
661
        ))
662
    );
663
    for k in &p.roots {
664
        println!(
665
            "  {} {}  {}",
666
            ui::ok(ui::glyph("🔑", "*")),
667
            ui::hash(k.short(12)),
668
            ui::author(name(k))
669
        );
670
    }
671
    println!("{}", ui::section("rules"));
672
    for rule in &p.rules {
673
        println!(
674
            "  {} {:12} {}",
675
            ui::ok(ui::glyph("▸", ">")),
676
            ui::bookmark(&rule.pattern),
677
            ui::dim(format!(
678
                "threshold {}, require {}, conflicts {}",
679
                rule.threshold,
680
                rule.require.name(),
681
                if rule.allow_conflicts {
682
                    "allowed"
683
                } else {
684
                    "refused"
685
                }
686
            ))
687
        );
688
        for k in &rule.keys {
689
            println!("      {}  {}", ui::hash(k.short(12)), ui::author(name(k)));
690
        }
691
        for g in &rule.subjects {
692
            println!(
693
                "      {}  {} {}",
694
                ui::author(&g.pattern),
695
                ui::dim(format!(
696
                    "certified by {} ({}),",
697
                    name(&g.issuer),
698
                    g.issuer.short(12)
699
                )),
700
                ui::dim(format!("scope {}", g.scope))
701
            );
702
        }
703
    }
704
    if !p.revoked.is_empty() {
705
        println!("{}", ui::section("revoked"));
706
        for k in &p.revoked {
707
            println!(
708
                "  {} {}  {}",
709
                ui::err(ui::glyph("✖", "x")),
710
                ui::hash(k.short(12)),
711
                ui::author(name(k))
712
            );
713
        }
714
    }
715
    Ok(0)
716
}
717
718
pub fn policy_grant(
719
    identity_file: &Path,
720
    pattern: Option<&str>,
721
    out: Option<PathBuf>,
722
) -> Result<i32> {
723
    let repo = open()?;
724
    let id = Identity::decode(&std::fs::read(identity_file)?)?;
725
    let key = id.hash();
726
    let u = repo.policy_grant(id.clone(), pattern)?;
727
    println!(
728
        "{} {} <{}> ({}) on {}",
729
        ui::done("Granted"),
730
        ui::author(&id.name),
731
        id.email,
732
        key.short(12),
733
        ui::bookmark(pattern.unwrap_or("*"))
734
    );
735
    report_update(&u, out)?;
736
    Ok(0)
737
}
738
739
pub fn policy_grant_subject(
740
    subject: &str,
741
    issuer_file: &Path,
742
    scope: &str,
743
    pattern: Option<&str>,
744
    out: Option<PathBuf>,
745
) -> Result<i32> {
746
    let repo = open()?;
747
    let issuer = Identity::decode(&std::fs::read(issuer_file)?)
748
        .map_err(|e| anyhow::anyhow!("{}: not an identity file: {e}", issuer_file.display()))?;
749
    let scope = oxy_core::identity::Scope::parse(scope).map_err(|e| anyhow::anyhow!(e))?;
750
    let key = issuer.hash();
751
    let u = repo.policy_grant_subject(subject, issuer.clone(), scope, pattern)?;
752
    println!(
753
        "{} {} certified by {} ({}) on {}, scope {}",
754
        ui::done("Granted"),
755
        ui::author(subject),
756
        issuer.name,
757
        key.short(12),
758
        ui::bookmark(pattern.unwrap_or("*")),
759
        scope
760
    );
761
    report_update(&u, out)?;
762
    Ok(0)
763
}
764
765
pub fn policy_revoke(key: &str, out: Option<PathBuf>) -> Result<i32> {
766
    let repo = open()?;
767
    if repo.current_policy()?.is_none() {
768
        anyhow::bail!("this repository has no policy");
769
    }
770
    let full = resolve_key(&repo, key)?;
771
    let u = repo.policy_revoke(&full)?;
772
    println!("{} {}", ui::done("Revoked"), ui::hash(full.short(12)));
773
    report_update(&u, out)?;
774
    Ok(0)
775
}
776
777
/// `oxy seal [CHANGE..]`: seal the named changes, or every sealable one.
778
pub fn seal(specs: &[String], as_json: bool) -> Result<i32> {
779
    let repo = Repo::open(&std::env::current_dir()?)?;
780
    let hashes: Vec<_> = if specs.is_empty() {
781
        repo.sealable()?
782
    } else {
783
        specs
784
            .iter()
785
            .map(|s| repo.resolve_change(s))
786
            .collect::<std::result::Result<_, _>>()?
787
    };
788
    if hashes.is_empty() {
789
        if as_json {
790
            println!("{{\"sealed\":[]}}");
791
        } else {
792
            println!(
793
                "{} nothing to seal: every change you can authenticate already is",
794
                ui::done("Sealed")
795
            );
796
        }
797
        return Ok(0);
798
    }
799
    let mut refs = Vec::new();
800
    for chunk in hashes.chunks(oxy_core::attest::MAX_SEAL_HASHES) {
801
        refs.push(repo.seal(chunk)?);
802
    }
803
    if as_json {
804
        let list: Vec<String> = hashes
805
            .iter()
806
            .map(|h| format!("\"{}\"", h.to_hex()))
807
            .collect();
808
        let seals: Vec<String> = refs.iter().map(|r| format!("\"{}\"", r.to_hex())).collect();
809
        println!(
810
            "{{\"sealed\":[{}],\"seals\":[{}]}}",
811
            list.join(","),
812
            seals.join(",")
813
        );
814
    } else {
815
        println!(
816
            "{} {} change{} in {} seal{}; they travel with your next push",
817
            ui::done("Sealed"),
818
            hashes.len(),
819
            if hashes.len() == 1 { "" } else { "s" },
820
            refs.len(),
821
            if refs.len() == 1 { "" } else { "s" }
822
        );
823
        for h in &hashes {
824
            let title = repo
825
                .store()
826
                .get_change(h)
827
                .map(|c| c.message.lines().next().unwrap_or("").to_string())
828
                .unwrap_or_default();
829
            println!("  {} {}", ui::hash(h.short(12)), title);
830
        }
831
    }
832
    Ok(0)
833
}
834
835
/// `oxy identity sign-challenge HEX`: the composite signature over a
836
/// host's challenge, as hex of the CBOR signature (hub spec §5.3).
837
pub fn sign_challenge(challenge_hex: &str, as_json: bool) -> Result<i32> {
838
    let repo = open()?;
839
    let Some((key, sk)) = repo.secret_key()? else {
840
        anyhow::bail!("no secret key: run `oxy identity create` first");
841
    };
842
    let challenge = hex_bytes(challenge_hex.trim())
843
        .ok_or_else(|| anyhow::anyhow!("the challenge must be hex"))?;
844
    let sig = sk
845
        .sign_challenge(&key, &challenge)
846
        .map_err(|e| anyhow::anyhow!("{e}"))?;
847
    let encoded = hex_string(&oxy_core::identity::Signature::encode_list(&[sig]));
848
    if as_json {
849
        println!(
850
            "{{\"key\":\"{}\",\"signature\":\"{}\"}}",
851
            key.to_hex(),
852
            encoded
853
        );
854
    } else {
855
        println!("{encoded}");
856
    }
857
    Ok(0)
858
}
859
860
fn hex_bytes(s: &str) -> Option<Vec<u8>> {
861
    if !s.len().is_multiple_of(2) {
862
        return None;
863
    }
864
    (0..s.len())
865
        .step_by(2)
866
        .map(|i| u8::from_str_radix(&s[i..i + 2], 16).ok())
867
        .collect()
868
}
869
870
fn hex_string(b: &[u8]) -> String {
871
    b.iter().map(|x| format!("{x:02x}")).collect()
872
}