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