OXYGENHUB
# cea2e2f860c5 oxygen / src / cli / identity.rs
31.1 KiB · Rust · last changed A subrepo put back after its removal is pinned in the change that registers it; oxy identity create needs no repository (closes #152, closes #144) 7 days ago source blame history raw
1
//! Identity, verification, and configuration commands.
2
3
use super::format::{short_id, signature_line, when};
4
use super::json;
5
use super::open;
6
use super::ui;
7
use anyhow::Result;
8
use oxy_core::hash::ChangeHash;
9
use oxy_core::hash::KeyId;
10
use oxy_core::identity::{Delegate, Identity, Scope};
11
use oxy_core::objects::Hlc;
12
use oxy_core::protocol::SignedPolicy;
13
use oxy_core::repo::{self, IdentityScope, PolicyUpdate, Repo, Rotation, SignatureStatus};
14
use std::path::{Path, PathBuf};
15
16
const DAY_MS: u64 = 24 * 60 * 60 * 1000;
17
18
fn expiry_from_days(days: u64) -> Option<u64> {
19
    (days > 0).then(|| Hlc::now().wall_ms + days * DAY_MS)
20
}
21
22
/// `30m`, `8h`, `7d`, or a bare number of days, as a wall time from now.
23
fn expiry_from_duration(s: &str) -> Result<u64> {
24
    let s = s.trim();
25
    let (n, unit) = match s.char_indices().find(|(_, c)| !c.is_ascii_digit()) {
26
        Some((i, _)) => (&s[..i], &s[i..]),
27
        None => (s, "d"),
28
    };
29
    let n: u64 = n.parse().map_err(|_| {
30
        anyhow::anyhow!("--expires takes 30m, 8h, 7d or a number of days, not {s:?}")
31
    })?;
32
    let ms = match unit {
33
        "m" => 60_000,
34
        "h" => 3_600_000,
35
        "d" => DAY_MS,
36
        _ => anyhow::bail!("--expires takes 30m, 8h, 7d or a number of days, not {s:?}"),
37
    };
38
    if n == 0 {
39
        anyhow::bail!("--expires needs a length of time; leave it out for no expiry");
40
    }
41
    Ok(Hlc::now().wall_ms + n * ms)
42
}
43
44
/// Print the delegates, one per line: who, their key, what they may do
45
/// and until when, so an agent's name is seen, not only its key.
46
fn print_delegates(repo: &Repo, delegates: &[Delegate]) {
47
    if delegates.is_empty() {
48
        return;
49
    }
50
    println!("{}", ui::kv("delegates", delegates.len().to_string()));
51
    let now = Hlc::now().wall_ms;
52
    for d in delegates {
53
        let who = repo
54
            .identity(&d.key)
55
            .map(|i| format!("{} <{}>", i.name, i.email))
56
            .unwrap_or_else(|_| "(unknown identity)".into());
57
        let until = match d.expires {
58
            Some(e) if now > e => ui::err(format!("expired {}", ui::ago(e))),
59
            Some(e) => ui::dim(format!("until {}", when(e))),
60
            None => String::new(),
61
        };
62
        println!(
63
            "    {}  {}  {} {}",
64
            ui::author(who),
65
            ui::hash(d.key.short(12)),
66
            ui::dim(d.scope.to_string()),
67
            until
68
        );
69
    }
70
}
71
72
/// Resolve a KeyId prefix among the identities the repository knows.
73
fn resolve_key(repo: &Repo, prefix: &str) -> Result<KeyId> {
74
    if let Some(k) = KeyId::from_hex(prefix) {
75
        return Ok(k);
76
    }
77
    let mut candidates: Vec<KeyId> = Vec::new();
78
    if let Some((_, sp)) = repo.current_policy()? {
79
        candidates.extend(sp.policy.identities.iter().map(|i| i.hash()));
80
        candidates.extend(sp.policy.revoked.iter().copied());
81
    }
82
    if let Some(own) = repo.config.author_key {
83
        candidates.push(own);
84
        if let Ok(id) = repo.identity(&own) {
85
            candidates.extend(id.delegates.iter().map(|d| d.key));
86
        }
87
    }
88
    candidates.sort();
89
    candidates.dedup();
90
    let matches: Vec<_> = candidates
91
        .into_iter()
92
        .filter(|k| k.starts_with_hex(prefix))
93
        .collect();
94
    match matches.len() {
95
        1 => Ok(matches[0]),
96
        0 => anyhow::bail!("no known key starts with {prefix:?}"),
97
        _ => anyhow::bail!("{prefix:?} is ambiguous"),
98
    }
99
}
100
101
/// Report a policy update: applied, or written out as a proposal that
102
/// other root holders sign with `oxy policy sign`.
103
fn report_update(u: &PolicyUpdate, out: Option<PathBuf>) -> Result<()> {
104
    if u.applied {
105
        println!(
106
            "{} policy serial {} {}",
107
            ui::done("Applied"),
108
            u.policy.serial,
109
            ui::dim("(push to publish it)")
110
        );
111
        return Ok(());
112
    }
113
    let path = out.unwrap_or_else(|| PathBuf::from(format!("policy-{}.proposal", u.policy.serial)));
114
    std::fs::write(&path, u.signed().encode())?;
115
    println!(
116
        "{} policy serial {} needs {} of {} root signatures; has {}. Wrote {} for another root to run {}",
117
        ui::caution(),
118
        u.policy.serial,
119
        u.policy.root_threshold,
120
        u.policy.roots.len(),
121
        u.sigs.len(),
122
        ui::path(path.display()),
123
        ui::dim(format!("oxy policy sign {}", path.display()))
124
    );
125
    Ok(())
126
}
127
128
fn report_rotation(repo: &Repo, r: &Rotation, verb: &str) -> Result<()> {
129
    println!(
130
        "{} identity {} <{}>",
131
        ui::done(verb),
132
        ui::author(&r.identity.name),
133
        r.identity.email
134
    );
135
    println!("{}", ui::kv("old key", ui::dim(r.old.to_hex())));
136
    println!("{}", ui::kv("new key", r.new.to_hex()));
137
    print_delegates(repo, &r.identity.delegates);
138
    if let Some(e) = r.identity.expires {
139
        println!("{}", ui::kv("expires", when(e)));
140
    }
141
    match &r.policy {
142
        Some(u) => report_update(u, None)?,
143
        None => println!(
144
            "  {} {}",
145
            ui::note(),
146
            ui::dim("no policy names the old key; export the new identity for policy holders")
147
        ),
148
    }
149
    Ok(())
150
}
151
152
pub fn create(
153
    name: Option<String>,
154
    email: Option<String>,
155
    local: bool,
156
    expires: Option<u64>,
157
) -> Result<i32> {
158
    // A first identity comes before any repository (oxygen#144): only
159
    // --local needs one; inside one, it is kept there too.
160
    let mut repo = match open() {
161
        Ok(r) => r,
162
        Err(e) if local => return Err(e),
163
        Err(_) => {
164
            let (key, id) = repo::create_user_identity(
165
                name.as_deref(),
166
                email.as_deref(),
167
                expires.and_then(expiry_from_days),
168
            )?;
169
            println!(
170
                "{} identity {} <{}>",
171
                ui::done("Created"),
172
                ui::author(&id.name),
173
                id.email
174
            );
175
            println!("{}", ui::kv("key", key));
176
            println!(
177
                "{}",
178
                ui::kv("secret", ui::path(repo::default_keys_dir().display()))
179
            );
180
            println!(
181
                "{}",
182
                ui::kv(
183
                    "scope",
184
                    format!(
185
                        "default author in {}",
186
                        ui::path(repo::default_user_config().display())
187
                    )
188
                )
189
            );
190
            println!("  {} {}", ui::note(), ui::dim("changes in every repository carry this author; run oxy config --user core.sign change to sign each commit"));
191
            return Ok(0);
192
        }
193
    };
194
    let name = name.unwrap_or_else(|| repo.config.author_name.clone());
195
    let email = email.unwrap_or_else(|| repo.config.author_email.clone());
196
    let scope = if local {
197
        IdentityScope::Repo
198
    } else {
199
        IdentityScope::User
200
    };
201
    let (key, _) =
202
        repo.create_identity_with(&name, &email, scope, expires.and_then(expiry_from_days))?;
203
    println!(
204
        "{} identity {} <{email}>",
205
        ui::done("Created"),
206
        ui::author(&name)
207
    );
208
    println!("{}", ui::kv("key", key));
209
    println!(
210
        "{}",
211
        ui::kv("secret", ui::path(repo::default_keys_dir().display()))
212
    );
213
    match scope {
214
        IdentityScope::User => println!(
215
            "{}",
216
            ui::kv(
217
                "scope",
218
                format!(
219
                    "default author in {}",
220
                    ui::path(repo.user_config_path().display())
221
                )
222
            )
223
        ),
224
        IdentityScope::Repo => {
225
            println!("{}", ui::kv("scope", "this repository's .oxy/config only"))
226
        }
227
    }
228
    if !repo.config.sign_changes {
229
        println!("  {} {}", ui::note(), ui::dim("changes will carry this author; run oxy config --user core.sign change to sign each commit"));
230
    }
231
    Ok(0)
232
}
233
234
pub fn show() -> Result<i32> {
235
    let repo = open()?;
236
    let Some(key) = repo.config.author_key else {
237
        println!("No identity configured; run `oxy identity create`.");
238
        return Ok(0);
239
    };
240
    let id = repo.identity(&key)?;
241
    println!(
242
        "{} {} <{}>",
243
        ui::section("identity"),
244
        ui::author(&id.name),
245
        id.email
246
    );
247
    println!("{}", ui::kv("key", key));
248
    println!(
249
        "{}",
250
        ui::kv(
251
            "created",
252
            format!(
253
                "{} {}",
254
                when(id.created),
255
                ui::dim(format!("({})", ui::ago(id.created)))
256
            )
257
        )
258
    );
259
    if let Some(e) = id.expires {
260
        let expired = id.is_expired_at(Hlc::now().wall_ms);
261
        println!(
262
            "{}",
263
            ui::kv(
264
                "expires",
265
                if expired {
266
                    ui::err(format!("{} EXPIRED", when(e)))
267
                } else {
268
                    when(e)
269
                }
270
            )
271
        );
272
    }
273
    if let Some(p) = id.previous {
274
        println!("{}", ui::kv("previous", ui::dim(p.short(12))));
275
    }
276
    // A repository that pins one key while the user file names another is
277
    // how checkouts drift apart after a rotation (oxygen#69).
278
    if let Some(pinned) = repo
279
        .config
280
        .repo_layer()
281
        .get("author.key")
282
        .and_then(|k| oxy_core::hash::KeyId::from_hex(k))
283
    {
284
        let user_key = repo::read_config_file(&repo::default_user_config())
285
            .ok()
286
            .and_then(|m| {
287
                m.get("author.key")
288
                    .and_then(|k| oxy_core::hash::KeyId::from_hex(k))
289
            });
290
        if let Some(u) = user_key.filter(|u| *u != pinned) {
291
            let related = |from: oxy_core::hash::KeyId, to: oxy_core::hash::KeyId| {
292
                let mut cur = Some(from);
293
                for _ in 0..64 {
294
                    match cur {
295
                        Some(k) if k == to => return true,
296
                        Some(k) => cur = repo.identity(&k).ok().and_then(|i| i.previous),
297
                        None => break,
298
                    }
299
                }
300
                false
301
            };
302
            let how = if related(pinned, u) {
303
                "an older key of this identity"
304
            } else if related(u, pinned) {
305
                "a newer key of this identity"
306
            } else {
307
                "another key"
308
            };
309
            println!(
310
                "  {} {}",
311
                ui::caution(),
312
                ui::dim(format!(
313
                    "this repository pins author.key {}; your user config names {how}, {}. Other checkouts follow the user config: `oxy config --unset author.key` here, or `oxy config --user author.key {}`",
314
                    pinned.short(12),
315
                    u.short(12),
316
                    pinned.to_hex()
317
                ))
318
            );
319
        }
320
    }
321
    print_delegates(&repo, &id.delegates);
322
    if repo.is_revoked(&key)? {
323
        println!(
324
            "{}",
325
            ui::kv(
326
                "status",
327
                ui::err(format!("{} REVOKED", ui::glyph("✖", "x")))
328
            )
329
        );
330
    }
331
    println!(
332
        "{}",
333
        ui::kv(
334
            "secret",
335
            if repo.secret_key()?.is_some() {
336
                ui::ok(format!("{} available", ui::glyph("✔", "*")))
337
            } else {
338
                ui::err(format!(
339
                    "{} not found in the key directory",
340
                    ui::glyph("✖", "x")
341
                ))
342
            }
343
        )
344
    );
345
    println!(
346
        "{}",
347
        ui::kv(
348
            "signing",
349
            if repo.config.sign_changes {
350
                "every change (core.sign = change)"
351
            } else {
352
                "at push via seals (core.sign = seal)"
353
            }
354
        )
355
    );
356
    Ok(0)
357
}
358
359
pub fn verify(change: Option<&str>, revset: Option<&str>, as_json: bool) -> Result<i32> {
360
    let repo = open()?;
361
    let hashes: Vec<ChangeHash> = match (change, revset) {
362
        (_, Some(r)) => {
363
            let wc = repo.working_copy().ok().map(|(h, _)| h);
364
            repo.revset(r)?
365
                .hashes
366
                .into_iter()
367
                .filter(|h| Some(*h) != wc)
368
                .collect()
369
        }
370
        (Some(c), None) => vec![repo.resolve_change(c)?],
371
        (None, None) => repo
372
            .log()?
373
            .into_iter()
374
            .filter(|e| !e.is_working_copy)
375
            .map(|e| e.hash)
376
            .collect(),
377
    };
378
    let mut bad = 0;
379
    if as_json {
380
        let mut items = Vec::new();
381
        for h in &hashes {
382
            let Ok(c) = repo.store().get_change(h) else {
383
                continue;
384
            };
385
            let (status, key, name, revoked, reason) = match repo.authenticate_change(h)? {
386
                SignatureStatus::Unsigned => ("unsigned", None, None, false, None),
387
                SignatureStatus::Valid {
388
                    key,
389
                    name,
390
                    sealed,
391
                    revoked,
392
                    ..
393
                } => (
394
                    if sealed { "sealed" } else { "signed" },
395
                    Some(key.to_hex()),
396
                    Some(name),
397
                    revoked,
398
                    None,
399
                ),
400
                SignatureStatus::Invalid(why) => {
401
                    bad += 1;
402
                    ("invalid", None, None, false, Some(why))
403
                }
404
            };
405
            items.push(json::obj([
406
                ("hash", json::s(&h.to_hex())),
407
                ("change_id", json::s(&c.change_id.to_hex())),
408
                ("status", json::s(status)),
409
                ("key", json::opt(key.as_deref(), json::s)),
410
                ("name", json::opt(name.as_deref(), json::s)),
411
                ("revoked", json::b(revoked)),
412
                ("reason", json::opt(reason.as_deref(), json::s)),
413
            ]));
414
        }
415
        println!("{}", json::arr(items));
416
        return Ok(if bad > 0 { 1 } else { 0 });
417
    }
418
    for h in hashes {
419
        let Ok(c) = repo.store().get_change(&h) else {
420
            println!(
421
                "{}  {}",
422
                ui::hash(format!("#{}", h.short(12))),
423
                ui::dim("not present (shallow)")
424
            );
425
            continue;
426
        };
427
        let line = signature_line(&repo, &h)?;
428
        let styled = if line.starts_with("INVALID") {
429
            bad += 1;
430
            ui::err(format!("{} {line}", ui::glyph("✖", "x")))
431
        } else if line == "unsigned" {
432
            ui::dim(format!("{} {line}", ui::glyph("·", "-")))
433
        } else {
434
            ui::ok(format!("{} {line}", ui::glyph("✔", "v")))
435
        };
436
        println!(
437
            "{} {}  {styled}",
438
            ui::id(short_id(&c)),
439
            ui::hash(format!("#{}", h.short(12)))
440
        );
441
    }
442
    Ok(if bad > 0 { 1 } else { 0 })
443
}
444
445
pub fn config(key: &str, value: &str, user: bool, unset: bool) -> Result<i32> {
446
    // An empty value removes the key, which is what --unset says plainly.
447
    let value = if unset { "" } else { value };
448
    let said = if unset {
449
        format!("{} {key}", ui::done("Unset"))
450
    } else {
451
        format!("{} {key} = {value}", ui::done("Set"))
452
    };
453
    if user {
454
        let path = repo::default_user_config();
455
        repo::Config::set_user(&path, key, value)?;
456
        println!("{said} {}", ui::dim(format!("(in {})", path.display())));
457
    } else {
458
        let mut repo = open()?;
459
        repo.config.set(key, value)?;
460
        repo.save_config()?;
461
        println!("{said} {}", ui::dim("(in this repository)"));
462
    }
463
    Ok(0)
464
}
465
466
pub fn export(path: Option<PathBuf>, pem: bool) -> Result<i32> {
467
    let repo = open()?;
468
    // The PEM is for checking what this home signs, so it is the signing
469
    // key's: a delegate's when `author.signer` names one.
470
    let key = if pem {
471
        repo.signing_key()
472
    } else {
473
        repo.config.author_key
474
    };
475
    let Some(key) = key else {
476
        anyhow::bail!("no identity configured; run `oxy identity create`");
477
    };
478
    let id = repo.identity(&key)?;
479
    let ext = if pem { "pem" } else { "identity" };
480
    let path = path.unwrap_or_else(|| PathBuf::from(format!("{}.{ext}", key.short(16))));
481
    if pem {
482
        std::fs::write(&path, oxy_core::dsse::public_pem(&id))?;
483
    } else {
484
        std::fs::write(&path, id.encode())?;
485
    }
486
    println!(
487
        "{} {} {} <{}> ({}) to {}",
488
        ui::done("Wrote"),
489
        if pem { "the public key of" } else { "identity" },
490
        ui::author(&id.name),
491
        id.email,
492
        key.short(12),
493
        ui::path(path.display())
494
    );
495
    Ok(0)
496
}
497
498
pub fn rotate() -> Result<i32> {
499
    let mut repo = open()?;
500
    let r = repo.rotate_identity()?;
501
    report_rotation(&repo, &r, "Rotated")?;
502
    Ok(0)
503
}
504
505
pub fn renew(expires: u64) -> Result<i32> {
506
    let mut repo = open()?;
507
    let r = repo.renew_identity(expiry_from_days(expires))?;
508
    report_rotation(&repo, &r, "Renewed")?;
509
    Ok(0)
510
}
511
512
pub fn revoke(key: Option<&str>, reason: &str) -> Result<i32> {
513
    let repo = open()?;
514
    let key = match key {
515
        Some(k) => resolve_key(&repo, k)?,
516
        None => repo
517
            .config
518
            .author_key
519
            .ok_or_else(|| anyhow::anyhow!("no identity configured"))?,
520
    };
521
    let update = repo.revoke_identity(&key, reason)?;
522
    println!(
523
        "{} key {}{}",
524
        ui::done("Revoked"),
525
        ui::hash(key.short(12)),
526
        if reason.is_empty() {
527
            String::new()
528
        } else {
529
            format!(": {reason}")
530
        }
531
    );
532
    match update {
533
        Some(u) => report_update(&u, None)?,
534
        None => println!(
535
            "  {} {}",
536
            ui::note(),
537
            ui::dim("recorded locally; no policy named the key")
538
        ),
539
    }
540
    // A revoked delegate comes off the identity too, so the successor no
541
    // longer names it (spec §14.2).
542
    let own = repo.config.author_key;
543
    let delegated = own.is_some_and(|o| {
544
        repo.identity(&o)
545
            .is_ok_and(|id| id.delegate(&key).is_some())
546
    });
547
    if delegated {
548
        let mut repo = repo;
549
        let r = repo.delegate_remove(&key)?;
550
        report_rotation(&repo, &r, "Undelegated")?;
551
        return Ok(0);
552
    }
553
    if repo.config.author_key == Some(key) {
554
        println!(
555
            "  {} {}",
556
            ui::caution(),
557
            ui::warn("this was your configured key; run oxy identity create or rotate")
558
        );
559
    }
560
    Ok(0)
561
}
562
563
pub fn delegate_add(identity_files: &[PathBuf], scope: &str, expires: Option<&str>) -> Result<i32> {
564
    let mut repo = open()?;
565
    let ids = identity_files
566
        .iter()
567
        .map(|f| {
568
            Identity::decode(
569
                &std::fs::read(f).map_err(|e| anyhow::anyhow!("{}: {e}", f.display()))?,
570
            )
571
            .map_err(Into::into)
572
        })
573
        .collect::<Result<Vec<_>>>()?;
574
    let scope = Scope::parse(scope).map_err(|e| anyhow::anyhow!("{e}"))?;
575
    let expires = expires.map(expiry_from_duration).transpose()?;
576
    let r = repo.delegate_add_many(ids, scope, expires)?;
577
    report_rotation(&repo, &r, "Delegated")?;
578
    println!(
579
        "  {} {}",
580
        ui::note(),
581
        ui::dim("an agent configured with your author.key needs the new key: oxy config --user author.key NEW in its home")
582
    );
583
    if !scope.has(Scope::REFS) {
584
        println!(
585
            "  {} {}",
586
            ui::note(),
587
            ui::dim("it may not move references: a push or tag it attests is refused")
588
        );
589
    }
590
    Ok(0)
591
}
592
593
pub fn delegate_remove(key: &str) -> Result<i32> {
594
    let mut repo = open()?;
595
    let k = resolve_key(&repo, key)?;
596
    let r = repo.delegate_remove(&k)?;
597
    report_rotation(&repo, &r, "Undelegated")?;
598
    Ok(0)
599
}
600
601
pub fn delegate_list() -> Result<i32> {
602
    let repo = open()?;
603
    let Some(key) = repo.config.author_key else {
604
        println!("No identity configured; run `oxy identity create`.");
605
        return Ok(0);
606
    };
607
    let id = repo.identity(&key)?;
608
    if id.delegates.is_empty() {
609
        println!("{}", ui::dim("No delegates."));
610
    }
611
    let now = Hlc::now().wall_ms;
612
    for d in &id.delegates {
613
        let who = repo
614
            .identity(&d.key)
615
            .map(|i| format!("{} <{}>", i.name, i.email))
616
            .unwrap_or_else(|_| "(unknown identity)".into());
617
        let until = match d.expires {
618
            Some(e) if now > e => ui::err(format!("expired {}", ui::ago(e))),
619
            Some(e) => ui::dim(format!("until {}", when(e))),
620
            None => String::new(),
621
        };
622
        println!(
623
            "  {} {}  {}  {} {}",
624
            ui::ok(ui::glyph("🔑", "*")),
625
            ui::hash(d.key.short(12)),
626
            ui::author(who),
627
            ui::dim(d.scope.to_string()),
628
            until
629
        );
630
    }
631
    Ok(0)
632
}
633
634
pub fn policy_root_add(identity_file: &Path, out: Option<PathBuf>) -> Result<i32> {
635
    let repo = open()?;
636
    let id = Identity::decode(&std::fs::read(identity_file)?)?;
637
    let key = id.hash();
638
    let u = repo.policy_root_add(id.clone())?;
639
    println!(
640
        "{} root {} <{}> ({})",
641
        ui::done("Added"),
642
        ui::author(&id.name),
643
        id.email,
644
        key.short(12)
645
    );
646
    report_update(&u, out)?;
647
    Ok(0)
648
}
649
650
pub fn policy_root_remove(key: &str, out: Option<PathBuf>) -> Result<i32> {
651
    let repo = open()?;
652
    let k = resolve_key(&repo, key)?;
653
    let u = repo.policy_root_remove(&k)?;
654
    println!("{} root {}", ui::done("Removed"), ui::hash(k.short(12)));
655
    report_update(&u, out)?;
656
    Ok(0)
657
}
658
659
pub fn policy_threshold(n: u32, out: Option<PathBuf>) -> Result<i32> {
660
    let repo = open()?;
661
    let u = repo.policy_set_threshold(n)?;
662
    println!("{} root threshold to {n}", ui::done("Set"));
663
    report_update(&u, out)?;
664
    Ok(0)
665
}
666
667
pub fn policy_sign(proposal: &Path, out: Option<PathBuf>) -> Result<i32> {
668
    let repo = open()?;
669
    let sp = SignedPolicy::decode(&std::fs::read(proposal)?)?;
670
    let u = repo.policy_sign(sp)?;
671
    println!("{} policy serial {}", ui::done("Signed"), u.policy.serial);
672
    report_update(&u, Some(out.unwrap_or_else(|| proposal.to_path_buf())))?;
673
    Ok(0)
674
}
675
676
pub fn policy_init() -> Result<i32> {
677
    let repo = open()?;
678
    let sp = repo.policy_init()?;
679
    println!(
680
        "{} policy with root key {}",
681
        ui::done("Created"),
682
        sp.policy.roots[0].short(12)
683
    );
684
    report_update(&sp, None)?;
685
    Ok(0)
686
}
687
688
pub fn policy_show(as_json: bool) -> Result<i32> {
689
    let repo = open()?;
690
    let Some((r, sp)) = repo.current_policy()? else {
691
        if as_json {
692
            println!("null");
693
        } else {
694
            println!(
695
                "No policy: this repository is unverified. Run `oxy policy init` to create one."
696
            );
697
        }
698
        return Ok(0);
699
    };
700
    let p = &sp.policy;
701
    if as_json {
702
        let ident = |k: &oxy_core::hash::KeyId| {
703
            let (name, email) = p
704
                .identity(k)
705
                .map(|i| (i.name.clone(), i.email.clone()))
706
                .unwrap_or_default();
707
            json::obj([
708
                ("key", json::s(&k.to_hex())),
709
                ("name", json::s(&name)),
710
                ("email", json::s(&email)),
711
            ])
712
        };
713
        println!(
714
            "{}",
715
            json::obj([
716
                ("hash", json::s(&r.to_hex())),
717
                ("serial", json::n(p.serial)),
718
                ("expires_ms", json::n(p.expires)),
719
                (
720
                    "expired",
721
                    json::b(p.is_expired(oxy_core::objects::Hlc::now().wall_ms))
722
                ),
723
                ("root_threshold", json::n(p.root_threshold)),
724
                ("roots", json::arr(p.roots.iter().map(ident))),
725
                (
726
                    "rules",
727
                    json::arr(p.rules.iter().map(|rule| {
728
                        json::obj([
729
                            ("pattern", json::s(&rule.pattern)),
730
                            ("keys", json::arr(rule.keys.iter().map(ident))),
731
                            ("threshold", json::n(rule.threshold)),
732
                            ("require", json::s(rule.require.name())),
733
                            ("allow_conflicts", json::b(rule.allow_conflicts)),
734
                            (
735
                                "subjects",
736
                                json::arr(rule.subjects.iter().map(|g| {
737
                                    json::obj([
738
                                        ("subject", json::s(&g.pattern)),
739
                                        ("issuer", ident(&g.issuer)),
740
                                        ("scope", json::s(&g.scope.to_string())),
741
                                    ])
742
                                })),
743
                            ),
744
                        ])
745
                    })),
746
                ),
747
                ("revoked", json::arr(p.revoked.iter().map(ident))),
748
            ])
749
        );
750
        return Ok(0);
751
    }
752
    let name = |k: &oxy_core::hash::KeyId| {
753
        p.identity(k)
754
            .map(|i| format!("{} <{}>", i.name, i.email))
755
            .unwrap_or_else(|| "(unknown identity)".into())
756
    };
757
    let expired = p.is_expired(oxy_core::objects::Hlc::now().wall_ms);
758
    println!(
759
        "{} {}{}serial {}{}expires {}{}",
760
        ui::section("policy"),
761
        ui::hash(format!("#{}", r.short(12))),
762
        ui::sep(),
763
        p.serial,
764
        ui::sep(),
765
        when(p.expires),
766
        if expired {
767
            ui::err(format!("  {} EXPIRED", ui::glyph("✖", "x")))
768
        } else {
769
            String::new()
770
        }
771
    );
772
    println!(
773
        "{}",
774
        ui::section(format!(
775
            "roots {}",
776
            ui::dim(format!("(threshold {})", p.root_threshold))
777
        ))
778
    );
779
    for k in &p.roots {
780
        println!(
781
            "  {} {}  {}",
782
            ui::ok(ui::glyph("🔑", "*")),
783
            ui::hash(k.short(12)),
784
            ui::author(name(k))
785
        );
786
    }
787
    println!("{}", ui::section("rules"));
788
    for rule in &p.rules {
789
        println!(
790
            "  {} {:12} {}",
791
            ui::ok(ui::glyph("▸", ">")),
792
            ui::bookmark(&rule.pattern),
793
            ui::dim(format!(
794
                "threshold {}, require {}, conflicts {}",
795
                rule.threshold,
796
                rule.require.name(),
797
                if rule.allow_conflicts {
798
                    "allowed"
799
                } else {
800
                    "refused"
801
                }
802
            ))
803
        );
804
        for k in &rule.keys {
805
            println!("      {}  {}", ui::hash(k.short(12)), ui::author(name(k)));
806
        }
807
        for g in &rule.subjects {
808
            println!(
809
                "      {}  {} {}",
810
                ui::author(&g.pattern),
811
                ui::dim(format!(
812
                    "certified by {} ({}),",
813
                    name(&g.issuer),
814
                    g.issuer.short(12)
815
                )),
816
                ui::dim(format!("scope {}", g.scope))
817
            );
818
        }
819
    }
820
    if !p.revoked.is_empty() {
821
        println!("{}", ui::section("revoked"));
822
        for k in &p.revoked {
823
            println!(
824
                "  {} {}  {}",
825
                ui::err(ui::glyph("✖", "x")),
826
                ui::hash(k.short(12)),
827
                ui::author(name(k))
828
            );
829
        }
830
    }
831
    Ok(0)
832
}
833
834
pub fn policy_grant(
835
    identity_file: &Path,
836
    pattern: Option<&str>,
837
    out: Option<PathBuf>,
838
) -> Result<i32> {
839
    let repo = open()?;
840
    let id = Identity::decode(&std::fs::read(identity_file)?)?;
841
    let key = id.hash();
842
    let u = repo.policy_grant(id.clone(), pattern)?;
843
    println!(
844
        "{} {} <{}> ({}) on {}",
845
        ui::done("Granted"),
846
        ui::author(&id.name),
847
        id.email,
848
        key.short(12),
849
        ui::bookmark(pattern.unwrap_or("*"))
850
    );
851
    report_update(&u, out)?;
852
    Ok(0)
853
}
854
855
pub fn policy_grant_subject(
856
    subject: &str,
857
    issuer_file: &Path,
858
    scope: &str,
859
    pattern: Option<&str>,
860
    out: Option<PathBuf>,
861
) -> Result<i32> {
862
    let repo = open()?;
863
    let issuer = Identity::decode(&std::fs::read(issuer_file)?)
864
        .map_err(|e| anyhow::anyhow!("{}: not an identity file: {e}", issuer_file.display()))?;
865
    let scope = oxy_core::identity::Scope::parse(scope).map_err(|e| anyhow::anyhow!(e))?;
866
    let key = issuer.hash();
867
    let u = repo.policy_grant_subject(subject, issuer.clone(), scope, pattern)?;
868
    println!(
869
        "{} {} certified by {} ({}) on {}, scope {}",
870
        ui::done("Granted"),
871
        ui::author(subject),
872
        issuer.name,
873
        key.short(12),
874
        ui::bookmark(pattern.unwrap_or("*")),
875
        scope
876
    );
877
    report_update(&u, out)?;
878
    Ok(0)
879
}
880
881
pub fn policy_revoke(key: &str, out: Option<PathBuf>) -> Result<i32> {
882
    let repo = open()?;
883
    if repo.current_policy()?.is_none() {
884
        anyhow::bail!("this repository has no policy");
885
    }
886
    let full = resolve_key(&repo, key)?;
887
    let u = repo.policy_revoke(&full)?;
888
    println!("{} {}", ui::done("Revoked"), ui::hash(full.short(12)));
889
    report_update(&u, out)?;
890
    Ok(0)
891
}
892
893
/// `oxy seal [CHANGE..]`: seal the named changes, or every sealable one.
894
pub fn seal(specs: &[String], as_json: bool) -> Result<i32> {
895
    let repo = Repo::open(&std::env::current_dir()?)?;
896
    let hashes: Vec<_> = if specs.is_empty() {
897
        repo.sealable()?
898
    } else {
899
        specs
900
            .iter()
901
            .map(|s| repo.resolve_change(s))
902
            .collect::<std::result::Result<_, _>>()?
903
    };
904
    if hashes.is_empty() {
905
        if as_json {
906
            println!("{{\"sealed\":[]}}");
907
        } else {
908
            println!(
909
                "{} nothing to seal: every change you can authenticate already is",
910
                ui::done("Sealed")
911
            );
912
        }
913
        return Ok(0);
914
    }
915
    let mut refs = Vec::new();
916
    for chunk in hashes.chunks(oxy_core::attest::MAX_SEAL_HASHES) {
917
        refs.push(repo.seal(chunk)?);
918
    }
919
    if as_json {
920
        let list: Vec<String> = hashes
921
            .iter()
922
            .map(|h| format!("\"{}\"", h.to_hex()))
923
            .collect();
924
        let seals: Vec<String> = refs.iter().map(|r| format!("\"{}\"", r.to_hex())).collect();
925
        println!(
926
            "{{\"sealed\":[{}],\"seals\":[{}]}}",
927
            list.join(","),
928
            seals.join(",")
929
        );
930
    } else {
931
        println!(
932
            "{} {} change{} in {} seal{}; they travel with your next push",
933
            ui::done("Sealed"),
934
            hashes.len(),
935
            if hashes.len() == 1 { "" } else { "s" },
936
            refs.len(),
937
            if refs.len() == 1 { "" } else { "s" }
938
        );
939
        for h in &hashes {
940
            let title = repo
941
                .store()
942
                .get_change(h)
943
                .map(|c| c.message.lines().next().unwrap_or("").to_string())
944
                .unwrap_or_default();
945
            println!("  {} {}", ui::hash(h.short(12)), title);
946
        }
947
    }
948
    Ok(0)
949
}
950
951
/// `oxy identity sign-challenge HEX`: the composite signature over a
952
/// host's challenge, as hex of the CBOR signature (hub spec §5.3).
953
pub fn sign_challenge(challenge_hex: &str, as_json: bool) -> Result<i32> {
954
    let repo = open()?;
955
    let Some((key, sk)) = repo.secret_key()? else {
956
        anyhow::bail!("no secret key: run `oxy identity create` first");
957
    };
958
    let challenge = hex_bytes(challenge_hex.trim())
959
        .ok_or_else(|| anyhow::anyhow!("the challenge must be hex"))?;
960
    let sig = sk
961
        .sign_challenge(&key, &challenge)
962
        .map_err(|e| anyhow::anyhow!("{e}"))?;
963
    let encoded = hex_string(&oxy_core::identity::Signature::encode_list(&[sig]));
964
    if as_json {
965
        println!(
966
            "{{\"key\":\"{}\",\"signature\":\"{}\"}}",
967
            key.to_hex(),
968
            encoded
969
        );
970
    } else {
971
        println!("{encoded}");
972
    }
973
    Ok(0)
974
}
975
976
fn hex_bytes(s: &str) -> Option<Vec<u8>> {
977
    if !s.len().is_multiple_of(2) {
978
        return None;
979
    }
980
    (0..s.len())
981
        .step_by(2)
982
        .map(|i| u8::from_str_radix(&s[i..i + 2], 16).ok())
983
        .collect()
984
}
985
986
fn hex_string(b: &[u8]) -> String {
987
    b.iter().map(|x| format!("{x:02x}")).collect()
988
}