OXYGENHUB
# 543bbf64d127 oxygen / src / cli / identity.rs
23.1 KiB · Rust · last changed Revsets: trust predicates, and -r on show, verify and abandon 1 month ago source blame history raw
fce44ae9 Andy McCall ↶ 1
//! Identity, verification, and configuration commands.
2
3
use super::format::{short_id, signature_line, when};
75167672 Andy McCall 4
use super::json;
fce44ae9 Andy McCall ↶ 5
use super::open;
135212de Andy McCall ↶ 6
use super::ui;
fce44ae9 Andy McCall ↶ 7
use anyhow::Result;
8
use oxy_core::hash::ChangeHash;
75167672 Andy McCall 9
use oxy_core::hash::KeyId;
83e0677b Andy McCall ↶ 10
use oxy_core::identity::Identity;
28fc23d7 Andy McCall ↶ 11
use oxy_core::objects::Hlc;
12
use oxy_core::protocol::SignedPolicy;
c6aa5bd9 Andy McCall ↶ 13
use oxy_core::repo::{self, IdentityScope, PolicyUpdate, Repo, Rotation, SignatureStatus};
83e0677b Andy McCall ↶ 14
use std::path::{Path, PathBuf};
fce44ae9 Andy McCall ↶ 15
28fc23d7 Andy McCall ↶ 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();
75167672 Andy McCall 40
    let matches: Vec<_> = candidates
41
        .into_iter()
42
        .filter(|k| k.starts_with_hex(prefix))
43
        .collect();
28fc23d7 Andy McCall ↶ 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 {
75167672 Andy McCall 55
        println!(
56
            "{} policy serial {} {}",
57
            ui::done("Applied"),
58
            u.policy.serial,
59
            ui::dim("(push to publish it)")
60
        );
28fc23d7 Andy McCall ↶ 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<()> {
75167672 Andy McCall 79
    println!(
80
        "{} identity {} <{}>",
81
        ui::done(verb),
82
        ui::author(&r.identity.name),
83
        r.identity.email
84
    );
28fc23d7 Andy McCall ↶ 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() {
75167672 Andy McCall 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
        );
28fc23d7 Andy McCall ↶ 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)?,
75167672 Andy McCall 106
        None => println!(
107
            "  {} {}",
108
            ui::note(),
109
            ui::dim("no policy names the old key; export the new identity for policy holders")
110
        ),
28fc23d7 Andy McCall ↶ 111
    }
112
    Ok(())
113
}
114
75167672 Andy McCall 115
pub fn create(
116
    name: Option<String>,
117
    email: Option<String>,
118
    local: bool,
119
    expires: Option<u64>,
120
) -> Result<i32> {
fce44ae9 Andy McCall ↶ 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());
75167672 Andy McCall 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
    );
94084087 Andy McCall ↶ 136
    println!("{}", ui::kv("key", key));
75167672 Andy McCall 137
    println!(
138
        "{}",
139
        ui::kv("secret", ui::path(repo::default_keys_dir().display()))
140
    );
fce44ae9 Andy McCall ↶ 141
    match scope {
75167672 Andy McCall 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
        }
fce44ae9 Andy McCall ↶ 155
    }
156
    if !repo.config.sign_changes {
94084087 Andy McCall ↶ 157
        println!("  {} {}", ui::note(), ui::dim("changes will carry this author; run oxy config --user core.sign change to sign each commit"));
fce44ae9 Andy McCall ↶ 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)?;
75167672 Andy McCall 169
    println!(
170
        "{} {} <{}>",
171
        ui::section("identity"),
172
        ui::author(&id.name),
173
        id.email
174
    );
94084087 Andy McCall ↶ 175
    println!("{}", ui::kv("key", key));
75167672 Andy McCall 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
    );
28fc23d7 Andy McCall ↶ 187
    if let Some(e) = id.expires {
188
        let expired = id.is_expired_at(Hlc::now().wall_ms);
75167672 Andy McCall 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
        );
28fc23d7 Andy McCall ↶ 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() {
75167672 Andy McCall 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
        );
28fc23d7 Andy McCall ↶ 216
    }
217
    if repo.is_revoked(&key)? {
75167672 Andy McCall 218
        println!(
219
            "{}",
220
            ui::kv(
221
                "status",
222
                ui::err(format!("{} REVOKED", ui::glyph("✖", "x")))
223
            )
224
        );
28fc23d7 Andy McCall ↶ 225
    }
75167672 Andy McCall 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
    );
fce44ae9 Andy McCall ↶ 251
    Ok(0)
252
}
253
2c9ddac3 Andy McCall ↶ 254
pub fn verify(change: Option<&str>, revset: Option<&str>, as_json: bool) -> Result<i32> {
fce44ae9 Andy McCall ↶ 255
    let repo = open()?;
2c9ddac3 Andy McCall ↶ 256
    let hashes: Vec<ChangeHash> = match (change, revset) {
257
        (_, Some(r)) => {
258
            let wc = repo.working_copy().ok().map(|(h, _)| h);
259
            repo.revset(r)?
260
                .hashes
261
                .into_iter()
262
                .filter(|h| Some(*h) != wc)
263
                .collect()
264
        }
265
        (Some(c), None) => vec![repo.resolve_change(c)?],
266
        (None, None) => repo
75167672 Andy McCall 267
            .log()?
268
            .into_iter()
269
            .filter(|e| !e.is_working_copy)
270
            .map(|e| e.hash)
271
            .collect(),
fce44ae9 Andy McCall ↶ 272
    };
273
    let mut bad = 0;
c6aa5bd9 Andy McCall ↶ 274
    if as_json {
275
        let mut items = Vec::new();
276
        for h in &hashes {
75167672 Andy McCall 277
            let Ok(c) = repo.store().get_change(h) else {
278
                continue;
279
            };
c6aa5bd9 Andy McCall ↶ 280
            let (status, key, name, revoked, reason) = match repo.authenticate_change(h)? {
281
                SignatureStatus::Unsigned => ("unsigned", None, None, false, None),
75167672 Andy McCall 282
                SignatureStatus::Valid {
283
                    key,
284
                    name,
285
                    sealed,
286
                    revoked,
287
                    ..
288
                } => (
289
                    if sealed { "sealed" } else { "signed" },
290
                    Some(key.to_hex()),
291
                    Some(name),
292
                    revoked,
293
                    None,
294
                ),
c6aa5bd9 Andy McCall ↶ 295
                SignatureStatus::Invalid(why) => {
296
                    bad += 1;
297
                    ("invalid", None, None, false, Some(why))
298
                }
299
            };
300
            items.push(json::obj([
301
                ("hash", json::s(&h.to_hex())),
302
                ("change_id", json::s(&c.change_id.to_hex())),
303
                ("status", json::s(status)),
304
                ("key", json::opt(key.as_deref(), json::s)),
305
                ("name", json::opt(name.as_deref(), json::s)),
306
                ("revoked", json::b(revoked)),
307
                ("reason", json::opt(reason.as_deref(), json::s)),
308
            ]));
309
        }
310
        println!("{}", json::arr(items));
311
        return Ok(if bad > 0 { 1 } else { 0 });
312
    }
fce44ae9 Andy McCall ↶ 313
    for h in hashes {
2863456a Andy McCall ↶ 314
        let Ok(c) = repo.store().get_change(&h) else {
75167672 Andy McCall 315
            println!(
316
                "{}  {}",
317
                ui::hash(format!("#{}", h.short(12))),
318
                ui::dim("not present (shallow)")
319
            );
2863456a Andy McCall ↶ 320
            continue;
321
        };
fce44ae9 Andy McCall ↶ 322
        let line = signature_line(&repo, &h)?;
135212de Andy McCall ↶ 323
        let styled = if line.starts_with("INVALID") {
fce44ae9 Andy McCall ↶ 324
            bad += 1;
94084087 Andy McCall ↶ 325
            ui::err(format!("{} {line}", ui::glyph("✖", "x")))
135212de Andy McCall ↶ 326
        } else if line == "unsigned" {
94084087 Andy McCall ↶ 327
            ui::dim(format!("{} {line}", ui::glyph("·", "-")))
135212de Andy McCall ↶ 328
        } else {
94084087 Andy McCall ↶ 329
            ui::ok(format!("{} {line}", ui::glyph("✔", "v")))
135212de Andy McCall ↶ 330
        };
75167672 Andy McCall 331
        println!(
332
            "{} {}  {styled}",
333
            ui::id(short_id(&c)),
334
            ui::hash(format!("#{}", h.short(12)))
335
        );
fce44ae9 Andy McCall ↶ 336
    }
337
    Ok(if bad > 0 { 1 } else { 0 })
338
}
339
340
pub fn config(key: &str, value: &str, user: bool) -> Result<i32> {
341
    if user {
342
        let path = repo::default_user_config();
343
        repo::Config::set_user(&path, key, value)?;
75167672 Andy McCall 344
        println!(
345
            "{} {key} = {value} {}",
346
            ui::done("Set"),
347
            ui::dim(format!("(in {})", path.display()))
348
        );
fce44ae9 Andy McCall ↶ 349
    } else {
350
        let mut repo = open()?;
351
        repo.config.set(key, value)?;
352
        repo.save_config()?;
75167672 Andy McCall 353
        println!(
354
            "{} {key} = {value} {}",
355
            ui::done("Set"),
356
            ui::dim("(in this repository)")
357
        );
fce44ae9 Andy McCall ↶ 358
    }
359
    Ok(0)
360
}
83e0677b Andy McCall ↶ 361
362
pub fn export(path: Option<PathBuf>) -> Result<i32> {
363
    let repo = open()?;
364
    let Some(key) = repo.config.author_key else {
365
        anyhow::bail!("no identity configured; run `oxy identity create`");
366
    };
367
    let id = repo.identity(&key)?;
368
    let path = path.unwrap_or_else(|| PathBuf::from(format!("{}.identity", key.short(16))));
369
    std::fs::write(&path, id.encode())?;
75167672 Andy McCall 370
    println!(
371
        "{} identity {} <{}> ({}) to {}",
372
        ui::done("Wrote"),
373
        ui::author(&id.name),
374
        id.email,
375
        key.short(12),
376
        ui::path(path.display())
377
    );
83e0677b Andy McCall ↶ 378
    Ok(0)
379
}
380
28fc23d7 Andy McCall ↶ 381
pub fn rotate() -> Result<i32> {
382
    let mut repo = open()?;
383
    let r = repo.rotate_identity()?;
384
    report_rotation(&r, "Rotated")?;
385
    Ok(0)
386
}
387
388
pub fn renew(expires: u64) -> Result<i32> {
389
    let mut repo = open()?;
390
    let r = repo.renew_identity(expiry_from_days(expires))?;
391
    report_rotation(&r, "Renewed")?;
392
    Ok(0)
393
}
394
395
pub fn revoke(key: Option<&str>, reason: &str) -> Result<i32> {
396
    let repo = open()?;
397
    let key = match key {
398
        Some(k) => resolve_key(&repo, k)?,
75167672 Andy McCall 399
        None => repo
400
            .config
401
            .author_key
402
            .ok_or_else(|| anyhow::anyhow!("no identity configured"))?,
28fc23d7 Andy McCall ↶ 403
    };
404
    let update = repo.revoke_identity(&key, reason)?;
75167672 Andy McCall 405
    println!(
406
        "{} key {}{}",
407
        ui::done("Revoked"),
408
        ui::hash(key.short(12)),
409
        if reason.is_empty() {
410
            String::new()
411
        } else {
412
            format!(": {reason}")
413
        }
414
    );
28fc23d7 Andy McCall ↶ 415
    match update {
416
        Some(u) => report_update(&u, None)?,
75167672 Andy McCall 417
        None => println!(
418
            "  {} {}",
419
            ui::note(),
420
            ui::dim("recorded locally; no policy named the key")
421
        ),
28fc23d7 Andy McCall ↶ 422
    }
423
    if repo.config.author_key == Some(key) {
75167672 Andy McCall 424
        println!(
425
            "  {} {}",
426
            ui::caution(),
427
            ui::warn("this was your configured key; run oxy identity create or rotate")
428
        );
28fc23d7 Andy McCall ↶ 429
    }
430
    Ok(0)
431
}
432
433
pub fn delegate_add(identity_file: &Path) -> Result<i32> {
434
    let mut repo = open()?;
435
    let id = Identity::decode(&std::fs::read(identity_file)?)?;
436
    let r = repo.delegate_add(id)?;
437
    report_rotation(&r, "Delegated")?;
438
    Ok(0)
439
}
440
441
pub fn delegate_remove(key: &str) -> Result<i32> {
442
    let mut repo = open()?;
443
    let k = resolve_key(&repo, key)?;
444
    let r = repo.delegate_remove(&k)?;
445
    report_rotation(&r, "Undelegated")?;
446
    Ok(0)
447
}
448
449
pub fn delegate_list() -> Result<i32> {
450
    let repo = open()?;
451
    let Some(key) = repo.config.author_key else {
452
        println!("No identity configured; run `oxy identity create`.");
453
        return Ok(0);
454
    };
455
    let id = repo.identity(&key)?;
456
    if id.delegates.is_empty() {
457
        println!("{}", ui::dim("No delegates."));
458
    }
459
    for d in &id.delegates {
75167672 Andy McCall 460
        let who = repo
461
            .identity(d)
462
            .map(|i| format!("{} <{}>", i.name, i.email))
463
            .unwrap_or_else(|_| "(unknown identity)".into());
464
        println!(
465
            "  {} {}  {}",
466
            ui::ok(ui::glyph("🔑", "*")),
467
            ui::hash(d.short(12)),
468
            ui::author(who)
469
        );
28fc23d7 Andy McCall ↶ 470
    }
471
    Ok(0)
472
}
473
474
pub fn policy_root_add(identity_file: &Path, out: Option<PathBuf>) -> Result<i32> {
475
    let repo = open()?;
476
    let id = Identity::decode(&std::fs::read(identity_file)?)?;
477
    let key = id.hash();
478
    let u = repo.policy_root_add(id.clone())?;
75167672 Andy McCall 479
    println!(
480
        "{} root {} <{}> ({})",
481
        ui::done("Added"),
482
        ui::author(&id.name),
483
        id.email,
484
        key.short(12)
485
    );
28fc23d7 Andy McCall ↶ 486
    report_update(&u, out)?;
487
    Ok(0)
488
}
489
490
pub fn policy_root_remove(key: &str, out: Option<PathBuf>) -> Result<i32> {
491
    let repo = open()?;
492
    let k = resolve_key(&repo, key)?;
493
    let u = repo.policy_root_remove(&k)?;
494
    println!("{} root {}", ui::done("Removed"), ui::hash(k.short(12)));
495
    report_update(&u, out)?;
496
    Ok(0)
497
}
498
499
pub fn policy_threshold(n: u32, out: Option<PathBuf>) -> Result<i32> {
500
    let repo = open()?;
501
    let u = repo.policy_set_threshold(n)?;
502
    println!("{} root threshold to {n}", ui::done("Set"));
503
    report_update(&u, out)?;
504
    Ok(0)
505
}
506
507
pub fn policy_sign(proposal: &Path, out: Option<PathBuf>) -> Result<i32> {
508
    let repo = open()?;
509
    let sp = SignedPolicy::decode(&std::fs::read(proposal)?)?;
510
    let u = repo.policy_sign(sp)?;
511
    println!("{} policy serial {}", ui::done("Signed"), u.policy.serial);
512
    report_update(&u, Some(out.unwrap_or_else(|| proposal.to_path_buf())))?;
513
    Ok(0)
514
}
515
83e0677b Andy McCall ↶ 516
pub fn policy_init() -> Result<i32> {
517
    let repo = open()?;
518
    let sp = repo.policy_init()?;
75167672 Andy McCall 519
    println!(
520
        "{} policy with root key {}",
521
        ui::done("Created"),
522
        sp.policy.roots[0].short(12)
523
    );
28fc23d7 Andy McCall ↶ 524
    report_update(&sp, None)?;
83e0677b Andy McCall ↶ 525
    Ok(0)
526
}
527
c6aa5bd9 Andy McCall ↶ 528
pub fn policy_show(as_json: bool) -> Result<i32> {
83e0677b Andy McCall ↶ 529
    let repo = open()?;
530
    let Some((r, sp)) = repo.current_policy()? else {
c6aa5bd9 Andy McCall ↶ 531
        if as_json {
532
            println!("null");
533
        } else {
75167672 Andy McCall 534
            println!(
535
                "No policy: this repository is unverified. Run `oxy policy init` to create one."
536
            );
c6aa5bd9 Andy McCall ↶ 537
        }
83e0677b Andy McCall ↶ 538
        return Ok(0);
539
    };
540
    let p = &sp.policy;
c6aa5bd9 Andy McCall ↶ 541
    if as_json {
542
        let ident = |k: &oxy_core::hash::KeyId| {
75167672 Andy McCall 543
            let (name, email) = p
544
                .identity(k)
545
                .map(|i| (i.name.clone(), i.email.clone()))
546
                .unwrap_or_default();
547
            json::obj([
548
                ("key", json::s(&k.to_hex())),
549
                ("name", json::s(&name)),
550
                ("email", json::s(&email)),
551
            ])
c6aa5bd9 Andy McCall ↶ 552
        };
553
        println!(
554
            "{}",
555
            json::obj([
556
                ("hash", json::s(&r.to_hex())),
557
                ("serial", json::n(p.serial)),
558
                ("expires_ms", json::n(p.expires)),
75167672 Andy McCall 559
                (
560
                    "expired",
561
                    json::b(p.is_expired(oxy_core::objects::Hlc::now().wall_ms))
562
                ),
c6aa5bd9 Andy McCall ↶ 563
                ("root_threshold", json::n(p.root_threshold)),
564
                ("roots", json::arr(p.roots.iter().map(ident))),
565
                (
566
                    "rules",
567
                    json::arr(p.rules.iter().map(|rule| {
568
                        json::obj([
569
                            ("pattern", json::s(&rule.pattern)),
570
                            ("keys", json::arr(rule.keys.iter().map(ident))),
571
                            ("threshold", json::n(rule.threshold)),
572
                            ("require", json::s(rule.require.name())),
573
                            ("allow_conflicts", json::b(rule.allow_conflicts)),
574
                        ])
575
                    })),
576
                ),
577
                ("revoked", json::arr(p.revoked.iter().map(ident))),
578
            ])
579
        );
580
        return Ok(0);
581
    }
75167672 Andy McCall 582
    let name = |k: &oxy_core::hash::KeyId| {
583
        p.identity(k)
584
            .map(|i| format!("{} <{}>", i.name, i.email))
585
            .unwrap_or_else(|| "(unknown identity)".into())
586
    };
94084087 Andy McCall ↶ 587
    let expired = p.is_expired(oxy_core::objects::Hlc::now().wall_ms);
75167672 Andy McCall 588
    println!(
589
        "{} {}{}serial {}{}expires {}{}",
590
        ui::section("policy"),
591
        ui::hash(format!("#{}", r.short(12))),
592
        ui::sep(),
593
        p.serial,
594
        ui::sep(),
595
        when(p.expires),
596
        if expired {
597
            ui::err(format!("  {} EXPIRED", ui::glyph("✖", "x")))
598
        } else {
599
            String::new()
600
        }
601
    );
602
    println!(
603
        "{}",
604
        ui::section(format!(
605
            "roots {}",
606
            ui::dim(format!("(threshold {})", p.root_threshold))
607
        ))
608
    );
83e0677b Andy McCall ↶ 609
    for k in &p.roots {
75167672 Andy McCall 610
        println!(
611
            "  {} {}  {}",
612
            ui::ok(ui::glyph("🔑", "*")),
613
            ui::hash(k.short(12)),
614
            ui::author(name(k))
615
        );
83e0677b Andy McCall ↶ 616
    }
94084087 Andy McCall ↶ 617
    println!("{}", ui::section("rules"));
83e0677b Andy McCall ↶ 618
    for rule in &p.rules {
75167672 Andy McCall 619
        println!(
620
            "  {} {:12} {}",
621
            ui::ok(ui::glyph("▸", ">")),
622
            ui::bookmark(&rule.pattern),
623
            ui::dim(format!(
624
                "threshold {}, require {}, conflicts {}",
625
                rule.threshold,
626
                rule.require.name(),
627
                if rule.allow_conflicts {
628
                    "allowed"
629
                } else {
630
                    "refused"
631
                }
632
            ))
633
        );
83e0677b Andy McCall ↶ 634
        for k in &rule.keys {
94084087 Andy McCall ↶ 635
            println!("      {}  {}", ui::hash(k.short(12)), ui::author(name(k)));
83e0677b Andy McCall ↶ 636
        }
637
    }
638
    if !p.revoked.is_empty() {
94084087 Andy McCall ↶ 639
        println!("{}", ui::section("revoked"));
83e0677b Andy McCall ↶ 640
        for k in &p.revoked {
75167672 Andy McCall 641
            println!(
642
                "  {} {}  {}",
643
                ui::err(ui::glyph("✖", "x")),
644
                ui::hash(k.short(12)),
645
                ui::author(name(k))
646
            );
83e0677b Andy McCall ↶ 647
        }
648
    }
649
    Ok(0)
650
}
651
75167672 Andy McCall 652
pub fn policy_grant(
653
    identity_file: &Path,
654
    pattern: Option<&str>,
655
    out: Option<PathBuf>,
656
) -> Result<i32> {
83e0677b Andy McCall ↶ 657
    let repo = open()?;
658
    let id = Identity::decode(&std::fs::read(identity_file)?)?;
659
    let key = id.hash();
28fc23d7 Andy McCall ↶ 660
    let u = repo.policy_grant(id.clone(), pattern)?;
75167672 Andy McCall 661
    println!(
662
        "{} {} <{}> ({}) on {}",
663
        ui::done("Granted"),
664
        ui::author(&id.name),
665
        id.email,
666
        key.short(12),
667
        ui::bookmark(pattern.unwrap_or("*"))
668
    );
28fc23d7 Andy McCall ↶ 669
    report_update(&u, out)?;
83e0677b Andy McCall ↶ 670
    Ok(0)
671
}
672
28fc23d7 Andy McCall ↶ 673
pub fn policy_revoke(key: &str, out: Option<PathBuf>) -> Result<i32> {
83e0677b Andy McCall ↶ 674
    let repo = open()?;
28fc23d7 Andy McCall ↶ 675
    if repo.current_policy()?.is_none() {
83e0677b Andy McCall ↶ 676
        anyhow::bail!("this repository has no policy");
28fc23d7 Andy McCall ↶ 677
    }
678
    let full = resolve_key(&repo, key)?;
679
    let u = repo.policy_revoke(&full)?;
680
    println!("{} {}", ui::done("Revoked"), ui::hash(full.short(12)));
681
    report_update(&u, out)?;
83e0677b Andy McCall ↶ 682
    Ok(0)
683
}
05e71ed6 Andy McCall ↶ 684
685
/// `oxy seal [CHANGE..]`: seal the named changes, or every sealable one.
686
pub fn seal(specs: &[String], as_json: bool) -> Result<i32> {
687
    let repo = Repo::open(&std::env::current_dir()?)?;
688
    let hashes: Vec<_> = if specs.is_empty() {
689
        repo.sealable()?
690
    } else {
b0e64e81 Andy McCall ↶ 691
        specs
692
            .iter()
693
            .map(|s| repo.resolve_change(s))
694
            .collect::<std::result::Result<_, _>>()?
05e71ed6 Andy McCall ↶ 695
    };
696
    if hashes.is_empty() {
697
        if as_json {
698
            println!("{{\"sealed\":[]}}");
699
        } else {
b0e64e81 Andy McCall ↶ 700
            println!(
701
                "{} nothing to seal: every change you can authenticate already is",
702
                ui::done("Sealed")
703
            );
05e71ed6 Andy McCall ↶ 704
        }
705
        return Ok(0);
706
    }
707
    let mut refs = Vec::new();
708
    for chunk in hashes.chunks(oxy_core::attest::MAX_SEAL_HASHES) {
709
        refs.push(repo.seal(chunk)?);
710
    }
711
    if as_json {
b0e64e81 Andy McCall ↶ 712
        let list: Vec<String> = hashes
713
            .iter()
714
            .map(|h| format!("\"{}\"", h.to_hex()))
715
            .collect();
05e71ed6 Andy McCall ↶ 716
        let seals: Vec<String> = refs.iter().map(|r| format!("\"{}\"", r.to_hex())).collect();
b0e64e81 Andy McCall ↶ 717
        println!(
718
            "{{\"sealed\":[{}],\"seals\":[{}]}}",
719
            list.join(","),
720
            seals.join(",")
721
        );
05e71ed6 Andy McCall ↶ 722
    } else {
b0e64e81 Andy McCall ↶ 723
        println!(
724
            "{} {} change{} in {} seal{}; they travel with your next push",
725
            ui::done("Sealed"),
726
            hashes.len(),
727
            if hashes.len() == 1 { "" } else { "s" },
728
            refs.len(),
729
            if refs.len() == 1 { "" } else { "s" }
730
        );
05e71ed6 Andy McCall ↶ 731
        for h in &hashes {
b0e64e81 Andy McCall ↶ 732
            let title = repo
733
                .store()
734
                .get_change(h)
735
                .map(|c| c.message.lines().next().unwrap_or("").to_string())
736
                .unwrap_or_default();
05e71ed6 Andy McCall ↶ 737
            println!("  {} {}", ui::hash(h.short(12)), title);
738
        }
739
    }
740
    Ok(0)
741
}
7aa2dc93 Andy McCall ↶ 742
743
/// `oxy identity sign-challenge HEX`: the composite signature over a
744
/// host's challenge, as hex of the CBOR signature (hub spec §5.3).
745
pub fn sign_challenge(challenge_hex: &str, as_json: bool) -> Result<i32> {
746
    let repo = open()?;
747
    let Some((key, sk)) = repo.secret_key()? else {
748
        anyhow::bail!("no secret key: run `oxy identity create` first");
749
    };
119bbf50 Andy McCall ↶ 750
    let challenge = hex_bytes(challenge_hex.trim())
751
        .ok_or_else(|| anyhow::anyhow!("the challenge must be hex"))?;
752
    let sig = sk
753
        .sign_challenge(&key, &challenge)
754
        .map_err(|e| anyhow::anyhow!("{e}"))?;
7aa2dc93 Andy McCall ↶ 755
    let encoded = hex_string(&oxy_core::identity::Signature::encode_list(&[sig]));
756
    if as_json {
119bbf50 Andy McCall ↶ 757
        println!(
758
            "{{\"key\":\"{}\",\"signature\":\"{}\"}}",
759
            key.to_hex(),
760
            encoded
761
        );
7aa2dc93 Andy McCall ↶ 762
    } else {
763
        println!("{encoded}");
764
    }
765
    Ok(0)
766
}
767
768
fn hex_bytes(s: &str) -> Option<Vec<u8>> {
769
    if !s.len().is_multiple_of(2) {
770
        return None;
771
    }
119bbf50 Andy McCall ↶ 772
    (0..s.len())
773
        .step_by(2)
774
        .map(|i| u8::from_str_radix(&s[i..i + 2], 16).ok())
775
        .collect()
7aa2dc93 Andy McCall ↶ 776
}
777
778
fn hex_string(b: &[u8]) -> String {
779
    b.iter().map(|x| format!("{x:02x}")).collect()
780
}