OXYGENHUB
# 84b9e9ac0ab4 oxygen / src / cli / identity.rs
22.3 KiB · Rust · last changed Challenge signatures and a push report for hosts 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::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>) -> Result<i32> {
355
    let repo = open()?;
356
    let Some(key) = repo.config.author_key else {
357
        anyhow::bail!("no identity configured; run `oxy identity create`");
358
    };
359
    let id = repo.identity(&key)?;
360
    let path = path.unwrap_or_else(|| PathBuf::from(format!("{}.identity", key.short(16))));
361
    std::fs::write(&path, id.encode())?;
362
    println!(
363
        "{} identity {} <{}> ({}) to {}",
364
        ui::done("Wrote"),
365
        ui::author(&id.name),
366
        id.email,
367
        key.short(12),
368
        ui::path(path.display())
369
    );
370
    Ok(0)
371
}
372
373
pub fn rotate() -> Result<i32> {
374
    let mut repo = open()?;
375
    let r = repo.rotate_identity()?;
376
    report_rotation(&r, "Rotated")?;
377
    Ok(0)
378
}
379
380
pub fn renew(expires: u64) -> Result<i32> {
381
    let mut repo = open()?;
382
    let r = repo.renew_identity(expiry_from_days(expires))?;
383
    report_rotation(&r, "Renewed")?;
384
    Ok(0)
385
}
386
387
pub fn revoke(key: Option<&str>, reason: &str) -> Result<i32> {
388
    let repo = open()?;
389
    let key = match key {
390
        Some(k) => resolve_key(&repo, k)?,
391
        None => repo
392
            .config
393
            .author_key
394
            .ok_or_else(|| anyhow::anyhow!("no identity configured"))?,
395
    };
396
    let update = repo.revoke_identity(&key, reason)?;
397
    println!(
398
        "{} key {}{}",
399
        ui::done("Revoked"),
400
        ui::hash(key.short(12)),
401
        if reason.is_empty() {
402
            String::new()
403
        } else {
404
            format!(": {reason}")
405
        }
406
    );
407
    match update {
408
        Some(u) => report_update(&u, None)?,
409
        None => println!(
410
            "  {} {}",
411
            ui::note(),
412
            ui::dim("recorded locally; no policy named the key")
413
        ),
414
    }
415
    if repo.config.author_key == Some(key) {
416
        println!(
417
            "  {} {}",
418
            ui::caution(),
419
            ui::warn("this was your configured key; run oxy identity create or rotate")
420
        );
421
    }
422
    Ok(0)
423
}
424
425
pub fn delegate_add(identity_file: &Path) -> Result<i32> {
426
    let mut repo = open()?;
427
    let id = Identity::decode(&std::fs::read(identity_file)?)?;
428
    let r = repo.delegate_add(id)?;
429
    report_rotation(&r, "Delegated")?;
430
    Ok(0)
431
}
432
433
pub fn delegate_remove(key: &str) -> Result<i32> {
434
    let mut repo = open()?;
435
    let k = resolve_key(&repo, key)?;
436
    let r = repo.delegate_remove(&k)?;
437
    report_rotation(&r, "Undelegated")?;
438
    Ok(0)
439
}
440
441
pub fn delegate_list() -> Result<i32> {
442
    let repo = open()?;
443
    let Some(key) = repo.config.author_key else {
444
        println!("No identity configured; run `oxy identity create`.");
445
        return Ok(0);
446
    };
447
    let id = repo.identity(&key)?;
448
    if id.delegates.is_empty() {
449
        println!("{}", ui::dim("No delegates."));
450
    }
451
    for d in &id.delegates {
452
        let who = repo
453
            .identity(d)
454
            .map(|i| format!("{} <{}>", i.name, i.email))
455
            .unwrap_or_else(|_| "(unknown identity)".into());
456
        println!(
457
            "  {} {}  {}",
458
            ui::ok(ui::glyph("🔑", "*")),
459
            ui::hash(d.short(12)),
460
            ui::author(who)
461
        );
462
    }
463
    Ok(0)
464
}
465
466
pub fn policy_root_add(identity_file: &Path, out: Option<PathBuf>) -> Result<i32> {
467
    let repo = open()?;
468
    let id = Identity::decode(&std::fs::read(identity_file)?)?;
469
    let key = id.hash();
470
    let u = repo.policy_root_add(id.clone())?;
471
    println!(
472
        "{} root {} <{}> ({})",
473
        ui::done("Added"),
474
        ui::author(&id.name),
475
        id.email,
476
        key.short(12)
477
    );
478
    report_update(&u, out)?;
479
    Ok(0)
480
}
481
482
pub fn policy_root_remove(key: &str, out: Option<PathBuf>) -> Result<i32> {
483
    let repo = open()?;
484
    let k = resolve_key(&repo, key)?;
485
    let u = repo.policy_root_remove(&k)?;
486
    println!("{} root {}", ui::done("Removed"), ui::hash(k.short(12)));
487
    report_update(&u, out)?;
488
    Ok(0)
489
}
490
491
pub fn policy_threshold(n: u32, out: Option<PathBuf>) -> Result<i32> {
492
    let repo = open()?;
493
    let u = repo.policy_set_threshold(n)?;
494
    println!("{} root threshold to {n}", ui::done("Set"));
495
    report_update(&u, out)?;
496
    Ok(0)
497
}
498
499
pub fn policy_sign(proposal: &Path, out: Option<PathBuf>) -> Result<i32> {
500
    let repo = open()?;
501
    let sp = SignedPolicy::decode(&std::fs::read(proposal)?)?;
502
    let u = repo.policy_sign(sp)?;
503
    println!("{} policy serial {}", ui::done("Signed"), u.policy.serial);
504
    report_update(&u, Some(out.unwrap_or_else(|| proposal.to_path_buf())))?;
505
    Ok(0)
506
}
507
508
pub fn policy_init() -> Result<i32> {
509
    let repo = open()?;
510
    let sp = repo.policy_init()?;
511
    println!(
512
        "{} policy with root key {}",
513
        ui::done("Created"),
514
        sp.policy.roots[0].short(12)
515
    );
516
    report_update(&sp, None)?;
517
    Ok(0)
518
}
519
520
pub fn policy_show(as_json: bool) -> Result<i32> {
521
    let repo = open()?;
522
    let Some((r, sp)) = repo.current_policy()? else {
523
        if as_json {
524
            println!("null");
525
        } else {
526
            println!(
527
                "No policy: this repository is unverified. Run `oxy policy init` to create one."
528
            );
529
        }
530
        return Ok(0);
531
    };
532
    let p = &sp.policy;
533
    if as_json {
534
        let ident = |k: &oxy_core::hash::KeyId| {
535
            let (name, email) = p
536
                .identity(k)
537
                .map(|i| (i.name.clone(), i.email.clone()))
538
                .unwrap_or_default();
539
            json::obj([
540
                ("key", json::s(&k.to_hex())),
541
                ("name", json::s(&name)),
542
                ("email", json::s(&email)),
543
            ])
544
        };
545
        println!(
546
            "{}",
547
            json::obj([
548
                ("hash", json::s(&r.to_hex())),
549
                ("serial", json::n(p.serial)),
550
                ("expires_ms", json::n(p.expires)),
551
                (
552
                    "expired",
553
                    json::b(p.is_expired(oxy_core::objects::Hlc::now().wall_ms))
554
                ),
555
                ("root_threshold", json::n(p.root_threshold)),
556
                ("roots", json::arr(p.roots.iter().map(ident))),
557
                (
558
                    "rules",
559
                    json::arr(p.rules.iter().map(|rule| {
560
                        json::obj([
561
                            ("pattern", json::s(&rule.pattern)),
562
                            ("keys", json::arr(rule.keys.iter().map(ident))),
563
                            ("threshold", json::n(rule.threshold)),
564
                            ("require", json::s(rule.require.name())),
565
                            ("allow_conflicts", json::b(rule.allow_conflicts)),
566
                        ])
567
                    })),
568
                ),
569
                ("revoked", json::arr(p.revoked.iter().map(ident))),
570
            ])
571
        );
572
        return Ok(0);
573
    }
574
    let name = |k: &oxy_core::hash::KeyId| {
575
        p.identity(k)
576
            .map(|i| format!("{} <{}>", i.name, i.email))
577
            .unwrap_or_else(|| "(unknown identity)".into())
578
    };
579
    let expired = p.is_expired(oxy_core::objects::Hlc::now().wall_ms);
580
    println!(
581
        "{} {}{}serial {}{}expires {}{}",
582
        ui::section("policy"),
583
        ui::hash(format!("#{}", r.short(12))),
584
        ui::sep(),
585
        p.serial,
586
        ui::sep(),
587
        when(p.expires),
588
        if expired {
589
            ui::err(format!("  {} EXPIRED", ui::glyph("✖", "x")))
590
        } else {
591
            String::new()
592
        }
593
    );
594
    println!(
595
        "{}",
596
        ui::section(format!(
597
            "roots {}",
598
            ui::dim(format!("(threshold {})", p.root_threshold))
599
        ))
600
    );
601
    for k in &p.roots {
602
        println!(
603
            "  {} {}  {}",
604
            ui::ok(ui::glyph("🔑", "*")),
605
            ui::hash(k.short(12)),
606
            ui::author(name(k))
607
        );
608
    }
609
    println!("{}", ui::section("rules"));
610
    for rule in &p.rules {
611
        println!(
612
            "  {} {:12} {}",
613
            ui::ok(ui::glyph("▸", ">")),
614
            ui::bookmark(&rule.pattern),
615
            ui::dim(format!(
616
                "threshold {}, require {}, conflicts {}",
617
                rule.threshold,
618
                rule.require.name(),
619
                if rule.allow_conflicts {
620
                    "allowed"
621
                } else {
622
                    "refused"
623
                }
624
            ))
625
        );
626
        for k in &rule.keys {
627
            println!("      {}  {}", ui::hash(k.short(12)), ui::author(name(k)));
628
        }
629
    }
630
    if !p.revoked.is_empty() {
631
        println!("{}", ui::section("revoked"));
632
        for k in &p.revoked {
633
            println!(
634
                "  {} {}  {}",
635
                ui::err(ui::glyph("✖", "x")),
636
                ui::hash(k.short(12)),
637
                ui::author(name(k))
638
            );
639
        }
640
    }
641
    Ok(0)
642
}
643
644
pub fn policy_grant(
645
    identity_file: &Path,
646
    pattern: Option<&str>,
647
    out: Option<PathBuf>,
648
) -> Result<i32> {
649
    let repo = open()?;
650
    let id = Identity::decode(&std::fs::read(identity_file)?)?;
651
    let key = id.hash();
652
    let u = repo.policy_grant(id.clone(), pattern)?;
653
    println!(
654
        "{} {} <{}> ({}) on {}",
655
        ui::done("Granted"),
656
        ui::author(&id.name),
657
        id.email,
658
        key.short(12),
659
        ui::bookmark(pattern.unwrap_or("*"))
660
    );
661
    report_update(&u, out)?;
662
    Ok(0)
663
}
664
665
pub fn policy_revoke(key: &str, out: Option<PathBuf>) -> Result<i32> {
666
    let repo = open()?;
667
    if repo.current_policy()?.is_none() {
668
        anyhow::bail!("this repository has no policy");
669
    }
670
    let full = resolve_key(&repo, key)?;
671
    let u = repo.policy_revoke(&full)?;
672
    println!("{} {}", ui::done("Revoked"), ui::hash(full.short(12)));
673
    report_update(&u, out)?;
674
    Ok(0)
675
}
676
677
/// `oxy seal [CHANGE..]`: seal the named changes, or every sealable one.
678
pub fn seal(specs: &[String], as_json: bool) -> Result<i32> {
679
    let repo = Repo::open(&std::env::current_dir()?)?;
680
    let hashes: Vec<_> = if specs.is_empty() {
681
        repo.sealable()?
682
    } else {
683
        specs.iter().map(|s| repo.resolve_change(s)).collect::<std::result::Result<_, _>>()?
684
    };
685
    if hashes.is_empty() {
686
        if as_json {
687
            println!("{{\"sealed\":[]}}");
688
        } else {
689
            println!("{} nothing to seal: every change you can authenticate already is", ui::done("Sealed"));
690
        }
691
        return Ok(0);
692
    }
693
    let mut refs = Vec::new();
694
    for chunk in hashes.chunks(oxy_core::attest::MAX_SEAL_HASHES) {
695
        refs.push(repo.seal(chunk)?);
696
    }
697
    if as_json {
698
        let list: Vec<String> = hashes.iter().map(|h| format!("\"{}\"", h.to_hex())).collect();
699
        let seals: Vec<String> = refs.iter().map(|r| format!("\"{}\"", r.to_hex())).collect();
700
        println!("{{\"sealed\":[{}],\"seals\":[{}]}}", list.join(","), seals.join(","));
701
    } else {
702
        println!("{} {} change{} in {} seal{}; they travel with your next push", ui::done("Sealed"), hashes.len(), if hashes.len() == 1 { "" } else { "s" }, refs.len(), if refs.len() == 1 { "" } else { "s" });
703
        for h in &hashes {
704
            let title = repo.store().get_change(h).map(|c| c.message.lines().next().unwrap_or("").to_string()).unwrap_or_default();
705
            println!("  {} {}", ui::hash(h.short(12)), title);
706
        }
707
    }
708
    Ok(0)
709
}
710
711
/// `oxy identity sign-challenge HEX`: the composite signature over a
712
/// host's challenge, as hex of the CBOR signature (hub spec §5.3).
713
pub fn sign_challenge(challenge_hex: &str, as_json: bool) -> Result<i32> {
714
    let repo = open()?;
715
    let Some((key, sk)) = repo.secret_key()? else {
716
        anyhow::bail!("no secret key: run `oxy identity create` first");
717
    };
718
    let challenge = hex_bytes(challenge_hex.trim()).ok_or_else(|| anyhow::anyhow!("the challenge must be hex"))?;
719
    let sig = sk.sign_challenge(&key, &challenge).map_err(|e| anyhow::anyhow!("{e}"))?;
720
    let encoded = hex_string(&oxy_core::identity::Signature::encode_list(&[sig]));
721
    if as_json {
722
        println!("{{\"key\":\"{}\",\"signature\":\"{}\"}}", key.to_hex(), encoded);
723
    } else {
724
        println!("{encoded}");
725
    }
726
    Ok(0)
727
}
728
729
fn hex_bytes(s: &str) -> Option<Vec<u8>> {
730
    if !s.len().is_multiple_of(2) {
731
        return None;
732
    }
733
    (0..s.len()).step_by(2).map(|i| u8::from_str_radix(&s[i..i + 2], 16).ok()).collect()
734
}
735
736
fn hex_string(b: &[u8]) -> String {
737
    b.iter().map(|x| format!("{x:02x}")).collect()
738
}