OXYGENHUB
# e5d9b0513f3e oxygen / src / cli / identity.rs
24.8 KiB · Rust · last changed Scoped, expiring delegates, and revocation by the delegator 1 month 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
                    })),
620
                ),
621
                ("revoked", json::arr(p.revoked.iter().map(ident))),
622
            ])
623
        );
624
        return Ok(0);
625
    }
626
    let name = |k: &oxy_core::hash::KeyId| {
627
        p.identity(k)
628
            .map(|i| format!("{} <{}>", i.name, i.email))
629
            .unwrap_or_else(|| "(unknown identity)".into())
630
    };
631
    let expired = p.is_expired(oxy_core::objects::Hlc::now().wall_ms);
632
    println!(
633
        "{} {}{}serial {}{}expires {}{}",
634
        ui::section("policy"),
635
        ui::hash(format!("#{}", r.short(12))),
636
        ui::sep(),
637
        p.serial,
638
        ui::sep(),
639
        when(p.expires),
640
        if expired {
641
            ui::err(format!("  {} EXPIRED", ui::glyph("✖", "x")))
642
        } else {
643
            String::new()
644
        }
645
    );
646
    println!(
647
        "{}",
648
        ui::section(format!(
649
            "roots {}",
650
            ui::dim(format!("(threshold {})", p.root_threshold))
651
        ))
652
    );
653
    for k in &p.roots {
654
        println!(
655
            "  {} {}  {}",
656
            ui::ok(ui::glyph("🔑", "*")),
657
            ui::hash(k.short(12)),
658
            ui::author(name(k))
659
        );
660
    }
661
    println!("{}", ui::section("rules"));
662
    for rule in &p.rules {
663
        println!(
664
            "  {} {:12} {}",
665
            ui::ok(ui::glyph("▸", ">")),
666
            ui::bookmark(&rule.pattern),
667
            ui::dim(format!(
668
                "threshold {}, require {}, conflicts {}",
669
                rule.threshold,
670
                rule.require.name(),
671
                if rule.allow_conflicts {
672
                    "allowed"
673
                } else {
674
                    "refused"
675
                }
676
            ))
677
        );
678
        for k in &rule.keys {
679
            println!("      {}  {}", ui::hash(k.short(12)), ui::author(name(k)));
680
        }
681
    }
682
    if !p.revoked.is_empty() {
683
        println!("{}", ui::section("revoked"));
684
        for k in &p.revoked {
685
            println!(
686
                "  {} {}  {}",
687
                ui::err(ui::glyph("✖", "x")),
688
                ui::hash(k.short(12)),
689
                ui::author(name(k))
690
            );
691
        }
692
    }
693
    Ok(0)
694
}
695
696
pub fn policy_grant(
697
    identity_file: &Path,
698
    pattern: Option<&str>,
699
    out: Option<PathBuf>,
700
) -> Result<i32> {
701
    let repo = open()?;
702
    let id = Identity::decode(&std::fs::read(identity_file)?)?;
703
    let key = id.hash();
704
    let u = repo.policy_grant(id.clone(), pattern)?;
705
    println!(
706
        "{} {} <{}> ({}) on {}",
707
        ui::done("Granted"),
708
        ui::author(&id.name),
709
        id.email,
710
        key.short(12),
711
        ui::bookmark(pattern.unwrap_or("*"))
712
    );
713
    report_update(&u, out)?;
714
    Ok(0)
715
}
716
717
pub fn policy_revoke(key: &str, out: Option<PathBuf>) -> Result<i32> {
718
    let repo = open()?;
719
    if repo.current_policy()?.is_none() {
720
        anyhow::bail!("this repository has no policy");
721
    }
722
    let full = resolve_key(&repo, key)?;
723
    let u = repo.policy_revoke(&full)?;
724
    println!("{} {}", ui::done("Revoked"), ui::hash(full.short(12)));
725
    report_update(&u, out)?;
726
    Ok(0)
727
}
728
729
/// `oxy seal [CHANGE..]`: seal the named changes, or every sealable one.
730
pub fn seal(specs: &[String], as_json: bool) -> Result<i32> {
731
    let repo = Repo::open(&std::env::current_dir()?)?;
732
    let hashes: Vec<_> = if specs.is_empty() {
733
        repo.sealable()?
734
    } else {
735
        specs
736
            .iter()
737
            .map(|s| repo.resolve_change(s))
738
            .collect::<std::result::Result<_, _>>()?
739
    };
740
    if hashes.is_empty() {
741
        if as_json {
742
            println!("{{\"sealed\":[]}}");
743
        } else {
744
            println!(
745
                "{} nothing to seal: every change you can authenticate already is",
746
                ui::done("Sealed")
747
            );
748
        }
749
        return Ok(0);
750
    }
751
    let mut refs = Vec::new();
752
    for chunk in hashes.chunks(oxy_core::attest::MAX_SEAL_HASHES) {
753
        refs.push(repo.seal(chunk)?);
754
    }
755
    if as_json {
756
        let list: Vec<String> = hashes
757
            .iter()
758
            .map(|h| format!("\"{}\"", h.to_hex()))
759
            .collect();
760
        let seals: Vec<String> = refs.iter().map(|r| format!("\"{}\"", r.to_hex())).collect();
761
        println!(
762
            "{{\"sealed\":[{}],\"seals\":[{}]}}",
763
            list.join(","),
764
            seals.join(",")
765
        );
766
    } else {
767
        println!(
768
            "{} {} change{} in {} seal{}; they travel with your next push",
769
            ui::done("Sealed"),
770
            hashes.len(),
771
            if hashes.len() == 1 { "" } else { "s" },
772
            refs.len(),
773
            if refs.len() == 1 { "" } else { "s" }
774
        );
775
        for h in &hashes {
776
            let title = repo
777
                .store()
778
                .get_change(h)
779
                .map(|c| c.message.lines().next().unwrap_or("").to_string())
780
                .unwrap_or_default();
781
            println!("  {} {}", ui::hash(h.short(12)), title);
782
        }
783
    }
784
    Ok(0)
785
}
786
787
/// `oxy identity sign-challenge HEX`: the composite signature over a
788
/// host's challenge, as hex of the CBOR signature (hub spec §5.3).
789
pub fn sign_challenge(challenge_hex: &str, as_json: bool) -> Result<i32> {
790
    let repo = open()?;
791
    let Some((key, sk)) = repo.secret_key()? else {
792
        anyhow::bail!("no secret key: run `oxy identity create` first");
793
    };
794
    let challenge = hex_bytes(challenge_hex.trim())
795
        .ok_or_else(|| anyhow::anyhow!("the challenge must be hex"))?;
796
    let sig = sk
797
        .sign_challenge(&key, &challenge)
798
        .map_err(|e| anyhow::anyhow!("{e}"))?;
799
    let encoded = hex_string(&oxy_core::identity::Signature::encode_list(&[sig]));
800
    if as_json {
801
        println!(
802
            "{{\"key\":\"{}\",\"signature\":\"{}\"}}",
803
            key.to_hex(),
804
            encoded
805
        );
806
    } else {
807
        println!("{encoded}");
808
    }
809
    Ok(0)
810
}
811
812
fn hex_bytes(s: &str) -> Option<Vec<u8>> {
813
    if !s.len().is_multiple_of(2) {
814
        return None;
815
    }
816
    (0..s.len())
817
        .step_by(2)
818
        .map(|i| u8::from_str_radix(&s[i..i + 2], 16).ok())
819
        .collect()
820
}
821
822
fn hex_string(b: &[u8]) -> String {
823
    b.iter().map(|x| format!("{x:02x}")).collect()
824
}