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