OXYGENHUB
◆ main oxygen / src / cli / identity.rs
34.2 KiB · Rust · last changed A stale author.key can no longer fork the identity (closes #163) 1 day 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
    );
aded4110 Andy McCall ↶ 135
    // Which key it started from, and where that came from (oxygen#163).
136
    let from = if repo.config.is_repo_set("author.key") {
137
        "this repository's author.key"
138
    } else {
139
        "the user config's author.key"
140
    };
141
    println!(
142
        "{}",
143
        ui::kv(
144
            "old key",
145
            format!(
146
                "{} {}",
147
                ui::dim(r.old.to_hex()),
148
                ui::dim(format!("(from {from})"))
149
            )
150
        )
151
    );
28fc23d7 Andy McCall ↶ 152
    println!("{}", ui::kv("new key", r.new.to_hex()));
5cde7882 Andy McCall ↶ 153
    print_delegates(repo, &r.identity.delegates);
28fc23d7 Andy McCall ↶ 154
    if let Some(e) = r.identity.expires {
155
        println!("{}", ui::kv("expires", when(e)));
156
    }
157
    match &r.policy {
158
        Some(u) => report_update(u, None)?,
75167672 Andy McCall 159
        None => println!(
160
            "  {} {}",
161
            ui::note(),
162
            ui::dim("no policy names the old key; export the new identity for policy holders")
163
        ),
28fc23d7 Andy McCall ↶ 164
    }
165
    Ok(())
166
}
167
75167672 Andy McCall 168
pub fn create(
169
    name: Option<String>,
170
    email: Option<String>,
171
    local: bool,
172
    expires: Option<u64>,
173
) -> Result<i32> {
64dc5197 Andy McCall ↶ 174
    // A first identity comes before any repository (oxygen#144): only
175
    // --local needs one; inside one, it is kept there too.
176
    let mut repo = match open() {
177
        Ok(r) => r,
178
        Err(e) if local => return Err(e),
179
        Err(_) => {
180
            let (key, id) = repo::create_user_identity(
181
                name.as_deref(),
182
                email.as_deref(),
183
                expires.and_then(expiry_from_days),
184
            )?;
185
            println!(
186
                "{} identity {} <{}>",
187
                ui::done("Created"),
188
                ui::author(&id.name),
189
                id.email
190
            );
191
            println!("{}", ui::kv("key", key));
192
            println!(
193
                "{}",
194
                ui::kv("secret", ui::path(repo::default_keys_dir().display()))
195
            );
196
            println!(
197
                "{}",
198
                ui::kv(
199
                    "scope",
200
                    format!(
201
                        "default author in {}",
202
                        ui::path(repo::default_user_config().display())
203
                    )
204
                )
205
            );
206
            println!("  {} {}", ui::note(), ui::dim("changes in every repository carry this author; run oxy config --user core.sign change to sign each commit"));
207
            return Ok(0);
208
        }
209
    };
fce44ae9 Andy McCall ↶ 210
    let name = name.unwrap_or_else(|| repo.config.author_name.clone());
211
    let email = email.unwrap_or_else(|| repo.config.author_email.clone());
75167672 Andy McCall 212
    let scope = if local {
213
        IdentityScope::Repo
214
    } else {
215
        IdentityScope::User
216
    };
217
    let (key, _) =
218
        repo.create_identity_with(&name, &email, scope, expires.and_then(expiry_from_days))?;
219
    println!(
220
        "{} identity {} <{email}>",
221
        ui::done("Created"),
222
        ui::author(&name)
223
    );
94084087 Andy McCall ↶ 224
    println!("{}", ui::kv("key", key));
75167672 Andy McCall 225
    println!(
226
        "{}",
227
        ui::kv("secret", ui::path(repo::default_keys_dir().display()))
228
    );
fce44ae9 Andy McCall ↶ 229
    match scope {
75167672 Andy McCall 230
        IdentityScope::User => println!(
231
            "{}",
232
            ui::kv(
233
                "scope",
234
                format!(
235
                    "default author in {}",
236
                    ui::path(repo.user_config_path().display())
237
                )
238
            )
239
        ),
240
        IdentityScope::Repo => {
241
            println!("{}", ui::kv("scope", "this repository's .oxy/config only"))
242
        }
fce44ae9 Andy McCall ↶ 243
    }
244
    if !repo.config.sign_changes {
94084087 Andy McCall ↶ 245
        println!("  {} {}", ui::note(), ui::dim("changes will carry this author; run oxy config --user core.sign change to sign each commit"));
fce44ae9 Andy McCall ↶ 246
    }
247
    Ok(0)
248
}
249
250
pub fn show() -> Result<i32> {
251
    let repo = open()?;
252
    let Some(key) = repo.config.author_key else {
253
        println!("No identity configured; run `oxy identity create`.");
254
        return Ok(0);
255
    };
256
    let id = repo.identity(&key)?;
75167672 Andy McCall 257
    println!(
258
        "{} {} <{}>",
259
        ui::section("identity"),
260
        ui::author(&id.name),
261
        id.email
262
    );
94084087 Andy McCall ↶ 263
    println!("{}", ui::kv("key", key));
75167672 Andy McCall 264
    println!(
265
        "{}",
266
        ui::kv(
267
            "created",
268
            format!(
269
                "{} {}",
270
                when(id.created),
271
                ui::dim(format!("({})", ui::ago(id.created)))
272
            )
273
        )
274
    );
28fc23d7 Andy McCall ↶ 275
    if let Some(e) = id.expires {
276
        let expired = id.is_expired_at(Hlc::now().wall_ms);
75167672 Andy McCall 277
        println!(
278
            "{}",
279
            ui::kv(
280
                "expires",
281
                if expired {
282
                    ui::err(format!("{} EXPIRED", when(e)))
283
                } else {
284
                    when(e)
285
                }
286
            )
287
        );
28fc23d7 Andy McCall ↶ 288
    }
289
    if let Some(p) = id.previous {
290
        println!("{}", ui::kv("previous", ui::dim(p.short(12))));
291
    }
614f55e1 Andy McCall ↶ 292
    // A repository that pins one key while the user file names another is
293
    // how checkouts drift apart after a rotation (oxygen#69).
294
    if let Some(pinned) = repo
295
        .config
296
        .repo_layer()
297
        .get("author.key")
298
        .and_then(|k| oxy_core::hash::KeyId::from_hex(k))
299
    {
300
        let user_key = repo::read_config_file(&repo::default_user_config())
301
            .ok()
302
            .and_then(|m| {
303
                m.get("author.key")
304
                    .and_then(|k| oxy_core::hash::KeyId::from_hex(k))
305
            });
306
        if let Some(u) = user_key.filter(|u| *u != pinned) {
307
            let related = |from: oxy_core::hash::KeyId, to: oxy_core::hash::KeyId| {
308
                let mut cur = Some(from);
309
                for _ in 0..64 {
310
                    match cur {
311
                        Some(k) if k == to => return true,
312
                        Some(k) => cur = repo.identity(&k).ok().and_then(|i| i.previous),
313
                        None => break,
314
                    }
315
                }
316
                false
317
            };
318
            let how = if related(pinned, u) {
319
                "an older key of this identity"
320
            } else if related(u, pinned) {
321
                "a newer key of this identity"
322
            } else {
323
                "another key"
324
            };
325
            println!(
326
                "  {} {}",
327
                ui::caution(),
328
                ui::dim(format!(
329
                    "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 {}`",
330
                    pinned.short(12),
331
                    u.short(12),
332
                    pinned.to_hex()
aded4110 Andy McCall ↶ 333
                ))
334
            );
335
        }
336
    }
337
    // A key with a successor is stale, and a key with two is a fork: both
338
    // are how an identity splits without anyone noticing (oxygen#163).
339
    let next = repo.successors(&key)?;
340
    if let Some(n) = next.first() {
341
        println!(
342
            "  {} {}",
343
            ui::caution(),
344
            ui::warn(format!(
345
                "this key is not your identity's newest: {} succeeds it, {} is the newest. Rotating or delegating from it would fork the identity; point author.key at the newest",
346
                n.short(12),
347
                repo.newest(n)?.short(12)
348
            ))
349
        );
350
    }
351
    let mut cur = Some(key);
352
    for _ in 0..256 {
353
        let Some(k) = cur else { break };
354
        let after = repo.successors(&k)?;
355
        if after.len() > 1 {
356
            println!(
357
                "  {} {}",
358
                ui::caution(),
359
                ui::err(format!(
360
                    "the identity forks at {}: {} each succeed it. Keep one branch: revoke the others' keys (oxy identity revoke KEY) and point every home's author.key at the one you keep",
361
                    k.short(12),
362
                    after.iter().map(|a| a.short(12)).collect::<Vec<_>>().join(" and ")
614f55e1 Andy McCall ↶ 363
                ))
364
            );
aded4110 Andy McCall ↶ 365
            break;
614f55e1 Andy McCall ↶ 366
        }
aded4110 Andy McCall ↶ 367
        cur = repo.identity(&k).ok().and_then(|i| i.previous);
614f55e1 Andy McCall ↶ 368
    }
5cde7882 Andy McCall ↶ 369
    print_delegates(&repo, &id.delegates);
28fc23d7 Andy McCall ↶ 370
    if repo.is_revoked(&key)? {
75167672 Andy McCall 371
        println!(
372
            "{}",
373
            ui::kv(
374
                "status",
375
                ui::err(format!("{} REVOKED", ui::glyph("✖", "x")))
376
            )
377
        );
28fc23d7 Andy McCall ↶ 378
    }
75167672 Andy McCall 379
    println!(
380
        "{}",
381
        ui::kv(
382
            "secret",
383
            if repo.secret_key()?.is_some() {
384
                ui::ok(format!("{} available", ui::glyph("✔", "*")))
385
            } else {
386
                ui::err(format!(
387
                    "{} not found in the key directory",
388
                    ui::glyph("✖", "x")
389
                ))
390
            }
391
        )
392
    );
393
    println!(
394
        "{}",
395
        ui::kv(
396
            "signing",
397
            if repo.config.sign_changes {
398
                "every change (core.sign = change)"
399
            } else {
400
                "at push via seals (core.sign = seal)"
401
            }
402
        )
403
    );
fce44ae9 Andy McCall ↶ 404
    Ok(0)
405
}
406
2c9ddac3 Andy McCall ↶ 407
pub fn verify(change: Option<&str>, revset: Option<&str>, as_json: bool) -> Result<i32> {
fce44ae9 Andy McCall ↶ 408
    let repo = open()?;
2c9ddac3 Andy McCall ↶ 409
    let hashes: Vec<ChangeHash> = match (change, revset) {
410
        (_, Some(r)) => {
411
            let wc = repo.working_copy().ok().map(|(h, _)| h);
412
            repo.revset(r)?
413
                .hashes
414
                .into_iter()
415
                .filter(|h| Some(*h) != wc)
416
                .collect()
417
        }
418
        (Some(c), None) => vec![repo.resolve_change(c)?],
419
        (None, None) => repo
75167672 Andy McCall 420
            .log()?
421
            .into_iter()
422
            .filter(|e| !e.is_working_copy)
423
            .map(|e| e.hash)
424
            .collect(),
fce44ae9 Andy McCall ↶ 425
    };
426
    let mut bad = 0;
c6aa5bd9 Andy McCall ↶ 427
    if as_json {
428
        let mut items = Vec::new();
429
        for h in &hashes {
75167672 Andy McCall 430
            let Ok(c) = repo.store().get_change(h) else {
431
                continue;
432
            };
c6aa5bd9 Andy McCall ↶ 433
            let (status, key, name, revoked, reason) = match repo.authenticate_change(h)? {
434
                SignatureStatus::Unsigned => ("unsigned", None, None, false, None),
75167672 Andy McCall 435
                SignatureStatus::Valid {
436
                    key,
437
                    name,
438
                    sealed,
439
                    revoked,
440
                    ..
441
                } => (
442
                    if sealed { "sealed" } else { "signed" },
443
                    Some(key.to_hex()),
444
                    Some(name),
445
                    revoked,
446
                    None,
447
                ),
c6aa5bd9 Andy McCall ↶ 448
                SignatureStatus::Invalid(why) => {
449
                    bad += 1;
450
                    ("invalid", None, None, false, Some(why))
451
                }
452
            };
453
            items.push(json::obj([
454
                ("hash", json::s(&h.to_hex())),
455
                ("change_id", json::s(&c.change_id.to_hex())),
456
                ("status", json::s(status)),
457
                ("key", json::opt(key.as_deref(), json::s)),
458
                ("name", json::opt(name.as_deref(), json::s)),
459
                ("revoked", json::b(revoked)),
460
                ("reason", json::opt(reason.as_deref(), json::s)),
461
            ]));
462
        }
463
        println!("{}", json::arr(items));
464
        return Ok(if bad > 0 { 1 } else { 0 });
465
    }
fce44ae9 Andy McCall ↶ 466
    for h in hashes {
2863456a Andy McCall ↶ 467
        let Ok(c) = repo.store().get_change(&h) else {
75167672 Andy McCall 468
            println!(
469
                "{}  {}",
470
                ui::hash(format!("#{}", h.short(12))),
471
                ui::dim("not present (shallow)")
472
            );
2863456a Andy McCall ↶ 473
            continue;
474
        };
fce44ae9 Andy McCall ↶ 475
        let line = signature_line(&repo, &h)?;
135212de Andy McCall ↶ 476
        let styled = if line.starts_with("INVALID") {
fce44ae9 Andy McCall ↶ 477
            bad += 1;
94084087 Andy McCall ↶ 478
            ui::err(format!("{} {line}", ui::glyph("✖", "x")))
135212de Andy McCall ↶ 479
        } else if line == "unsigned" {
94084087 Andy McCall ↶ 480
            ui::dim(format!("{} {line}", ui::glyph("·", "-")))
135212de Andy McCall ↶ 481
        } else {
94084087 Andy McCall ↶ 482
            ui::ok(format!("{} {line}", ui::glyph("✔", "v")))
135212de Andy McCall ↶ 483
        };
75167672 Andy McCall 484
        println!(
485
            "{} {}  {styled}",
486
            ui::id(short_id(&c)),
487
            ui::hash(format!("#{}", h.short(12)))
488
        );
fce44ae9 Andy McCall ↶ 489
    }
490
    Ok(if bad > 0 { 1 } else { 0 })
491
}
492
8e9c64d1 Andy McCall ↶ 493
/// Whether a configuration key holds a secret, never to be printed.
494
fn secret_key(key: &str) -> bool {
495
    let k = key.to_ascii_lowercase();
496
    k.ends_with(".token") || k.contains("secret") || k.contains("password")
497
}
498
499
/// A secret as a confirmation may show it (oxygen#113): enough of its start
500
/// to tell which one it is, never the rest.
501
fn mask(value: &str) -> String {
502
    let shown: String = value.chars().take(10).collect();
503
    let rest = value.chars().count().saturating_sub(10);
504
    if rest == 0 {
505
        "…".to_string()
506
    } else {
507
        format!("{shown}…({rest} more)")
508
    }
509
}
510
614f55e1 Andy McCall ↶ 511
pub fn config(key: &str, value: &str, user: bool, unset: bool) -> Result<i32> {
512
    // An empty value removes the key, which is what --unset says plainly.
8e9c64d1 Andy McCall ↶ 513
    // `-` reads the value from standard input, so a token never sits in
514
    // the shell's history (oxygen#113).
515
    let read;
516
    let value = if unset {
517
        ""
518
    } else if value == "-" {
519
        let mut line = String::new();
520
        std::io::stdin().read_line(&mut line)?;
521
        read = line.trim().to_string();
522
        read.as_str()
523
    } else {
524
        value
525
    };
614f55e1 Andy McCall ↶ 526
    let said = if unset {
527
        format!("{} {key}", ui::done("Unset"))
8e9c64d1 Andy McCall ↶ 528
    } else if secret_key(key) {
529
        format!("{} {key} = {}", ui::done("Set"), mask(value))
614f55e1 Andy McCall ↶ 530
    } else {
531
        format!("{} {key} = {value}", ui::done("Set"))
532
    };
fce44ae9 Andy McCall ↶ 533
    if user {
534
        let path = repo::default_user_config();
535
        repo::Config::set_user(&path, key, value)?;
614f55e1 Andy McCall ↶ 536
        println!("{said} {}", ui::dim(format!("(in {})", path.display())));
fce44ae9 Andy McCall ↶ 537
    } else {
538
        let mut repo = open()?;
539
        repo.config.set(key, value)?;
540
        repo.save_config()?;
614f55e1 Andy McCall ↶ 541
        println!("{said} {}", ui::dim("(in this repository)"));
fce44ae9 Andy McCall ↶ 542
    }
543
    Ok(0)
544
}
83e0677b Andy McCall ↶ 545
a39754d0 Andy McCall ↶ 546
pub fn export(path: Option<PathBuf>, pem: bool) -> Result<i32> {
83e0677b Andy McCall ↶ 547
    let repo = open()?;
a39754d0 Andy McCall ↶ 548
    // The PEM is for checking what this home signs, so it is the signing
549
    // key's: a delegate's when `author.signer` names one.
550
    let key = if pem {
551
        repo.signing_key()
552
    } else {
553
        repo.config.author_key
554
    };
555
    let Some(key) = key else {
83e0677b Andy McCall ↶ 556
        anyhow::bail!("no identity configured; run `oxy identity create`");
557
    };
558
    let id = repo.identity(&key)?;
a39754d0 Andy McCall ↶ 559
    let ext = if pem { "pem" } else { "identity" };
560
    let path = path.unwrap_or_else(|| PathBuf::from(format!("{}.{ext}", key.short(16))));
561
    if pem {
562
        std::fs::write(&path, oxy_core::dsse::public_pem(&id))?;
563
    } else {
564
        std::fs::write(&path, id.encode())?;
565
    }
75167672 Andy McCall 566
    println!(
a39754d0 Andy McCall ↶ 567
        "{} {} {} <{}> ({}) to {}",
75167672 Andy McCall 568
        ui::done("Wrote"),
a39754d0 Andy McCall ↶ 569
        if pem { "the public key of" } else { "identity" },
75167672 Andy McCall 570
        ui::author(&id.name),
571
        id.email,
572
        key.short(12),
573
        ui::path(path.display())
574
    );
83e0677b Andy McCall ↶ 575
    Ok(0)
576
}
577
28fc23d7 Andy McCall ↶ 578
pub fn rotate() -> Result<i32> {
579
    let mut repo = open()?;
580
    let r = repo.rotate_identity()?;
5cde7882 Andy McCall ↶ 581
    report_rotation(&repo, &r, "Rotated")?;
28fc23d7 Andy McCall ↶ 582
    Ok(0)
583
}
584
585
pub fn renew(expires: u64) -> Result<i32> {
586
    let mut repo = open()?;
587
    let r = repo.renew_identity(expiry_from_days(expires))?;
5cde7882 Andy McCall ↶ 588
    report_rotation(&repo, &r, "Renewed")?;
28fc23d7 Andy McCall ↶ 589
    Ok(0)
590
}
591
592
pub fn revoke(key: Option<&str>, reason: &str) -> Result<i32> {
593
    let repo = open()?;
594
    let key = match key {
595
        Some(k) => resolve_key(&repo, k)?,
75167672 Andy McCall 596
        None => repo
597
            .config
598
            .author_key
599
            .ok_or_else(|| anyhow::anyhow!("no identity configured"))?,
28fc23d7 Andy McCall ↶ 600
    };
601
    let update = repo.revoke_identity(&key, reason)?;
75167672 Andy McCall 602
    println!(
603
        "{} key {}{}",
604
        ui::done("Revoked"),
605
        ui::hash(key.short(12)),
606
        if reason.is_empty() {
607
            String::new()
608
        } else {
609
            format!(": {reason}")
610
        }
611
    );
28fc23d7 Andy McCall ↶ 612
    match update {
613
        Some(u) => report_update(&u, None)?,
75167672 Andy McCall 614
        None => println!(
615
            "  {} {}",
616
            ui::note(),
617
            ui::dim("recorded locally; no policy named the key")
618
        ),
28fc23d7 Andy McCall ↶ 619
    }
7ba155f2 Andy McCall ↶ 620
    // A revoked delegate comes off the identity too, so the successor no
621
    // longer names it (spec §14.2).
622
    let own = repo.config.author_key;
623
    let delegated = own.is_some_and(|o| {
624
        repo.identity(&o)
625
            .is_ok_and(|id| id.delegate(&key).is_some())
626
    });
627
    if delegated {
628
        let mut repo = repo;
629
        let r = repo.delegate_remove(&key)?;
5cde7882 Andy McCall ↶ 630
        report_rotation(&repo, &r, "Undelegated")?;
7ba155f2 Andy McCall ↶ 631
        return Ok(0);
632
    }
28fc23d7 Andy McCall ↶ 633
    if repo.config.author_key == Some(key) {
75167672 Andy McCall 634
        println!(
635
            "  {} {}",
636
            ui::caution(),
637
            ui::warn("this was your configured key; run oxy identity create or rotate")
638
        );
28fc23d7 Andy McCall ↶ 639
    }
640
    Ok(0)
641
}
642
614f55e1 Andy McCall ↶ 643
pub fn delegate_add(identity_files: &[PathBuf], scope: &str, expires: Option<&str>) -> Result<i32> {
28fc23d7 Andy McCall ↶ 644
    let mut repo = open()?;
614f55e1 Andy McCall ↶ 645
    let ids = identity_files
646
        .iter()
647
        .map(|f| {
648
            Identity::decode(
649
                &std::fs::read(f).map_err(|e| anyhow::anyhow!("{}: {e}", f.display()))?,
650
            )
651
            .map_err(Into::into)
652
        })
653
        .collect::<Result<Vec<_>>>()?;
7ba155f2 Andy McCall ↶ 654
    let scope = Scope::parse(scope).map_err(|e| anyhow::anyhow!("{e}"))?;
655
    let expires = expires.map(expiry_from_duration).transpose()?;
614f55e1 Andy McCall ↶ 656
    let r = repo.delegate_add_many(ids, scope, expires)?;
5cde7882 Andy McCall ↶ 657
    report_rotation(&repo, &r, "Delegated")?;
614f55e1 Andy McCall ↶ 658
    println!(
659
        "  {} {}",
660
        ui::note(),
661
        ui::dim("an agent configured with your author.key needs the new key: oxy config --user author.key NEW in its home")
662
    );
7ba155f2 Andy McCall ↶ 663
    if !scope.has(Scope::REFS) {
664
        println!(
665
            "  {} {}",
666
            ui::note(),
667
            ui::dim("it may not move references: a push or tag it attests is refused")
668
        );
669
    }
28fc23d7 Andy McCall ↶ 670
    Ok(0)
671
}
672
673
pub fn delegate_remove(key: &str) -> Result<i32> {
674
    let mut repo = open()?;
675
    let k = resolve_key(&repo, key)?;
676
    let r = repo.delegate_remove(&k)?;
5cde7882 Andy McCall ↶ 677
    report_rotation(&repo, &r, "Undelegated")?;
28fc23d7 Andy McCall ↶ 678
    Ok(0)
679
}
680
681
pub fn delegate_list() -> Result<i32> {
682
    let repo = open()?;
683
    let Some(key) = repo.config.author_key else {
684
        println!("No identity configured; run `oxy identity create`.");
685
        return Ok(0);
686
    };
687
    let id = repo.identity(&key)?;
688
    if id.delegates.is_empty() {
689
        println!("{}", ui::dim("No delegates."));
690
    }
7ba155f2 Andy McCall ↶ 691
    let now = Hlc::now().wall_ms;
28fc23d7 Andy McCall ↶ 692
    for d in &id.delegates {
75167672 Andy McCall 693
        let who = repo
7ba155f2 Andy McCall ↶ 694
            .identity(&d.key)
75167672 Andy McCall 695
            .map(|i| format!("{} <{}>", i.name, i.email))
696
            .unwrap_or_else(|_| "(unknown identity)".into());
7ba155f2 Andy McCall ↶ 697
        let until = match d.expires {
698
            Some(e) if now > e => ui::err(format!("expired {}", ui::ago(e))),
699
            Some(e) => ui::dim(format!("until {}", when(e))),
700
            None => String::new(),
701
        };
75167672 Andy McCall 702
        println!(
7ba155f2 Andy McCall ↶ 703
            "  {} {}  {}  {} {}",
75167672 Andy McCall 704
            ui::ok(ui::glyph("🔑", "*")),
7ba155f2 Andy McCall ↶ 705
            ui::hash(d.key.short(12)),
706
            ui::author(who),
707
            ui::dim(d.scope.to_string()),
708
            until
75167672 Andy McCall 709
        );
28fc23d7 Andy McCall ↶ 710
    }
711
    Ok(0)
712
}
713
714
pub fn policy_root_add(identity_file: &Path, out: Option<PathBuf>) -> Result<i32> {
715
    let repo = open()?;
716
    let id = Identity::decode(&std::fs::read(identity_file)?)?;
717
    let key = id.hash();
718
    let u = repo.policy_root_add(id.clone())?;
75167672 Andy McCall 719
    println!(
720
        "{} root {} <{}> ({})",
721
        ui::done("Added"),
722
        ui::author(&id.name),
723
        id.email,
724
        key.short(12)
725
    );
28fc23d7 Andy McCall ↶ 726
    report_update(&u, out)?;
727
    Ok(0)
728
}
729
730
pub fn policy_root_remove(key: &str, out: Option<PathBuf>) -> Result<i32> {
731
    let repo = open()?;
732
    let k = resolve_key(&repo, key)?;
733
    let u = repo.policy_root_remove(&k)?;
734
    println!("{} root {}", ui::done("Removed"), ui::hash(k.short(12)));
735
    report_update(&u, out)?;
736
    Ok(0)
737
}
738
739
pub fn policy_threshold(n: u32, out: Option<PathBuf>) -> Result<i32> {
740
    let repo = open()?;
741
    let u = repo.policy_set_threshold(n)?;
742
    println!("{} root threshold to {n}", ui::done("Set"));
743
    report_update(&u, out)?;
744
    Ok(0)
745
}
746
747
pub fn policy_sign(proposal: &Path, out: Option<PathBuf>) -> Result<i32> {
748
    let repo = open()?;
749
    let sp = SignedPolicy::decode(&std::fs::read(proposal)?)?;
750
    let u = repo.policy_sign(sp)?;
751
    println!("{} policy serial {}", ui::done("Signed"), u.policy.serial);
752
    report_update(&u, Some(out.unwrap_or_else(|| proposal.to_path_buf())))?;
753
    Ok(0)
754
}
755
83e0677b Andy McCall ↶ 756
pub fn policy_init() -> Result<i32> {
757
    let repo = open()?;
758
    let sp = repo.policy_init()?;
75167672 Andy McCall 759
    println!(
760
        "{} policy with root key {}",
761
        ui::done("Created"),
762
        sp.policy.roots[0].short(12)
763
    );
28fc23d7 Andy McCall ↶ 764
    report_update(&sp, None)?;
83e0677b Andy McCall ↶ 765
    Ok(0)
766
}
767
c6aa5bd9 Andy McCall ↶ 768
pub fn policy_show(as_json: bool) -> Result<i32> {
83e0677b Andy McCall ↶ 769
    let repo = open()?;
770
    let Some((r, sp)) = repo.current_policy()? else {
c6aa5bd9 Andy McCall ↶ 771
        if as_json {
772
            println!("null");
773
        } else {
75167672 Andy McCall 774
            println!(
775
                "No policy: this repository is unverified. Run `oxy policy init` to create one."
776
            );
c6aa5bd9 Andy McCall ↶ 777
        }
83e0677b Andy McCall ↶ 778
        return Ok(0);
779
    };
780
    let p = &sp.policy;
c6aa5bd9 Andy McCall ↶ 781
    if as_json {
782
        let ident = |k: &oxy_core::hash::KeyId| {
75167672 Andy McCall 783
            let (name, email) = p
784
                .identity(k)
785
                .map(|i| (i.name.clone(), i.email.clone()))
786
                .unwrap_or_default();
787
            json::obj([
788
                ("key", json::s(&k.to_hex())),
789
                ("name", json::s(&name)),
790
                ("email", json::s(&email)),
791
            ])
c6aa5bd9 Andy McCall ↶ 792
        };
793
        println!(
794
            "{}",
795
            json::obj([
796
                ("hash", json::s(&r.to_hex())),
797
                ("serial", json::n(p.serial)),
798
                ("expires_ms", json::n(p.expires)),
75167672 Andy McCall 799
                (
800
                    "expired",
801
                    json::b(p.is_expired(oxy_core::objects::Hlc::now().wall_ms))
802
                ),
c6aa5bd9 Andy McCall ↶ 803
                ("root_threshold", json::n(p.root_threshold)),
804
                ("roots", json::arr(p.roots.iter().map(ident))),
805
                (
806
                    "rules",
807
                    json::arr(p.rules.iter().map(|rule| {
808
                        json::obj([
809
                            ("pattern", json::s(&rule.pattern)),
810
                            ("keys", json::arr(rule.keys.iter().map(ident))),
811
                            ("threshold", json::n(rule.threshold)),
812
                            ("require", json::s(rule.require.name())),
813
                            ("allow_conflicts", json::b(rule.allow_conflicts)),
3a6effc7 Andy McCall ↶ 814
                            (
815
                                "subjects",
816
                                json::arr(rule.subjects.iter().map(|g| {
817
                                    json::obj([
818
                                        ("subject", json::s(&g.pattern)),
819
                                        ("issuer", ident(&g.issuer)),
820
                                        ("scope", json::s(&g.scope.to_string())),
821
                                    ])
822
                                })),
823
                            ),
c6aa5bd9 Andy McCall ↶ 824
                        ])
825
                    })),
826
                ),
827
                ("revoked", json::arr(p.revoked.iter().map(ident))),
828
            ])
829
        );
830
        return Ok(0);
831
    }
75167672 Andy McCall 832
    let name = |k: &oxy_core::hash::KeyId| {
833
        p.identity(k)
834
            .map(|i| format!("{} <{}>", i.name, i.email))
835
            .unwrap_or_else(|| "(unknown identity)".into())
836
    };
94084087 Andy McCall ↶ 837
    let expired = p.is_expired(oxy_core::objects::Hlc::now().wall_ms);
75167672 Andy McCall 838
    println!(
839
        "{} {}{}serial {}{}expires {}{}",
840
        ui::section("policy"),
841
        ui::hash(format!("#{}", r.short(12))),
842
        ui::sep(),
843
        p.serial,
844
        ui::sep(),
845
        when(p.expires),
846
        if expired {
847
            ui::err(format!("  {} EXPIRED", ui::glyph("✖", "x")))
848
        } else {
849
            String::new()
850
        }
851
    );
852
    println!(
853
        "{}",
854
        ui::section(format!(
855
            "roots {}",
856
            ui::dim(format!("(threshold {})", p.root_threshold))
857
        ))
858
    );
83e0677b Andy McCall ↶ 859
    for k in &p.roots {
75167672 Andy McCall 860
        println!(
861
            "  {} {}  {}",
862
            ui::ok(ui::glyph("🔑", "*")),
863
            ui::hash(k.short(12)),
864
            ui::author(name(k))
865
        );
83e0677b Andy McCall ↶ 866
    }
94084087 Andy McCall ↶ 867
    println!("{}", ui::section("rules"));
83e0677b Andy McCall ↶ 868
    for rule in &p.rules {
75167672 Andy McCall 869
        println!(
870
            "  {} {:12} {}",
871
            ui::ok(ui::glyph("▸", ">")),
872
            ui::bookmark(&rule.pattern),
873
            ui::dim(format!(
874
                "threshold {}, require {}, conflicts {}",
875
                rule.threshold,
876
                rule.require.name(),
877
                if rule.allow_conflicts {
878
                    "allowed"
879
                } else {
880
                    "refused"
881
                }
882
            ))
883
        );
83e0677b Andy McCall ↶ 884
        for k in &rule.keys {
94084087 Andy McCall ↶ 885
            println!("      {}  {}", ui::hash(k.short(12)), ui::author(name(k)));
83e0677b Andy McCall ↶ 886
        }
3a6effc7 Andy McCall ↶ 887
        for g in &rule.subjects {
888
            println!(
889
                "      {}  {} {}",
890
                ui::author(&g.pattern),
891
                ui::dim(format!(
892
                    "certified by {} ({}),",
893
                    name(&g.issuer),
894
                    g.issuer.short(12)
895
                )),
896
                ui::dim(format!("scope {}", g.scope))
897
            );
898
        }
83e0677b Andy McCall ↶ 899
    }
900
    if !p.revoked.is_empty() {
94084087 Andy McCall ↶ 901
        println!("{}", ui::section("revoked"));
83e0677b Andy McCall ↶ 902
        for k in &p.revoked {
75167672 Andy McCall 903
            println!(
904
                "  {} {}  {}",
905
                ui::err(ui::glyph("✖", "x")),
906
                ui::hash(k.short(12)),
907
                ui::author(name(k))
908
            );
83e0677b Andy McCall ↶ 909
        }
910
    }
911
    Ok(0)
912
}
913
75167672 Andy McCall 914
pub fn policy_grant(
915
    identity_file: &Path,
916
    pattern: Option<&str>,
917
    out: Option<PathBuf>,
918
) -> Result<i32> {
83e0677b Andy McCall ↶ 919
    let repo = open()?;
920
    let id = Identity::decode(&std::fs::read(identity_file)?)?;
921
    let key = id.hash();
28fc23d7 Andy McCall ↶ 922
    let u = repo.policy_grant(id.clone(), pattern)?;
75167672 Andy McCall 923
    println!(
924
        "{} {} <{}> ({}) on {}",
925
        ui::done("Granted"),
926
        ui::author(&id.name),
927
        id.email,
928
        key.short(12),
929
        ui::bookmark(pattern.unwrap_or("*"))
3a6effc7 Andy McCall ↶ 930
    );
931
    report_update(&u, out)?;
932
    Ok(0)
933
}
934
935
pub fn policy_grant_subject(
936
    subject: &str,
937
    issuer_file: &Path,
938
    scope: &str,
939
    pattern: Option<&str>,
940
    out: Option<PathBuf>,
941
) -> Result<i32> {
942
    let repo = open()?;
943
    let issuer = Identity::decode(&std::fs::read(issuer_file)?)
944
        .map_err(|e| anyhow::anyhow!("{}: not an identity file: {e}", issuer_file.display()))?;
945
    let scope = oxy_core::identity::Scope::parse(scope).map_err(|e| anyhow::anyhow!(e))?;
946
    let key = issuer.hash();
947
    let u = repo.policy_grant_subject(subject, issuer.clone(), scope, pattern)?;
948
    println!(
949
        "{} {} certified by {} ({}) on {}, scope {}",
950
        ui::done("Granted"),
951
        ui::author(subject),
952
        issuer.name,
953
        key.short(12),
954
        ui::bookmark(pattern.unwrap_or("*")),
955
        scope
75167672 Andy McCall 956
    );
28fc23d7 Andy McCall ↶ 957
    report_update(&u, out)?;
83e0677b Andy McCall ↶ 958
    Ok(0)
959
}
960
28fc23d7 Andy McCall ↶ 961
pub fn policy_revoke(key: &str, out: Option<PathBuf>) -> Result<i32> {
83e0677b Andy McCall ↶ 962
    let repo = open()?;
28fc23d7 Andy McCall ↶ 963
    if repo.current_policy()?.is_none() {
83e0677b Andy McCall ↶ 964
        anyhow::bail!("this repository has no policy");
28fc23d7 Andy McCall ↶ 965
    }
966
    let full = resolve_key(&repo, key)?;
967
    let u = repo.policy_revoke(&full)?;
968
    println!("{} {}", ui::done("Revoked"), ui::hash(full.short(12)));
969
    report_update(&u, out)?;
83e0677b Andy McCall ↶ 970
    Ok(0)
971
}
05e71ed6 Andy McCall ↶ 972
973
/// `oxy seal [CHANGE..]`: seal the named changes, or every sealable one.
974
pub fn seal(specs: &[String], as_json: bool) -> Result<i32> {
975
    let repo = Repo::open(&std::env::current_dir()?)?;
976
    let hashes: Vec<_> = if specs.is_empty() {
977
        repo.sealable()?
978
    } else {
b0e64e81 Andy McCall ↶ 979
        specs
980
            .iter()
981
            .map(|s| repo.resolve_change(s))
982
            .collect::<std::result::Result<_, _>>()?
05e71ed6 Andy McCall ↶ 983
    };
984
    if hashes.is_empty() {
985
        if as_json {
986
            println!("{{\"sealed\":[]}}");
987
        } else {
b0e64e81 Andy McCall ↶ 988
            println!(
989
                "{} nothing to seal: every change you can authenticate already is",
990
                ui::done("Sealed")
991
            );
05e71ed6 Andy McCall ↶ 992
        }
993
        return Ok(0);
994
    }
995
    let mut refs = Vec::new();
996
    for chunk in hashes.chunks(oxy_core::attest::MAX_SEAL_HASHES) {
997
        refs.push(repo.seal(chunk)?);
998
    }
999
    if as_json {
b0e64e81 Andy McCall ↶ 1000
        let list: Vec<String> = hashes
1001
            .iter()
1002
            .map(|h| format!("\"{}\"", h.to_hex()))
1003
            .collect();
05e71ed6 Andy McCall ↶ 1004
        let seals: Vec<String> = refs.iter().map(|r| format!("\"{}\"", r.to_hex())).collect();
b0e64e81 Andy McCall ↶ 1005
        println!(
1006
            "{{\"sealed\":[{}],\"seals\":[{}]}}",
1007
            list.join(","),
1008
            seals.join(",")
1009
        );
05e71ed6 Andy McCall ↶ 1010
    } else {
b0e64e81 Andy McCall ↶ 1011
        println!(
1012
            "{} {} change{} in {} seal{}; they travel with your next push",
1013
            ui::done("Sealed"),
1014
            hashes.len(),
1015
            if hashes.len() == 1 { "" } else { "s" },
1016
            refs.len(),
1017
            if refs.len() == 1 { "" } else { "s" }
1018
        );
05e71ed6 Andy McCall ↶ 1019
        for h in &hashes {
b0e64e81 Andy McCall ↶ 1020
            let title = repo
1021
                .store()
1022
                .get_change(h)
1023
                .map(|c| c.message.lines().next().unwrap_or("").to_string())
1024
                .unwrap_or_default();
05e71ed6 Andy McCall ↶ 1025
            println!("  {} {}", ui::hash(h.short(12)), title);
1026
        }
1027
    }
1028
    Ok(0)
1029
}
7aa2dc93 Andy McCall ↶ 1030
1031
/// `oxy identity sign-challenge HEX`: the composite signature over a
1032
/// host's challenge, as hex of the CBOR signature (hub spec §5.3).
1033
pub fn sign_challenge(challenge_hex: &str, as_json: bool) -> Result<i32> {
1034
    let repo = open()?;
1035
    let Some((key, sk)) = repo.secret_key()? else {
1036
        anyhow::bail!("no secret key: run `oxy identity create` first");
1037
    };
119bbf50 Andy McCall ↶ 1038
    let challenge = hex_bytes(challenge_hex.trim())
1039
        .ok_or_else(|| anyhow::anyhow!("the challenge must be hex"))?;
1040
    let sig = sk
1041
        .sign_challenge(&key, &challenge)
1042
        .map_err(|e| anyhow::anyhow!("{e}"))?;
7aa2dc93 Andy McCall ↶ 1043
    let encoded = hex_string(&oxy_core::identity::Signature::encode_list(&[sig]));
1044
    if as_json {
119bbf50 Andy McCall ↶ 1045
        println!(
1046
            "{{\"key\":\"{}\",\"signature\":\"{}\"}}",
1047
            key.to_hex(),
1048
            encoded
1049
        );
7aa2dc93 Andy McCall ↶ 1050
    } else {
1051
        println!("{encoded}");
1052
    }
1053
    Ok(0)
1054
}
1055
1056
fn hex_bytes(s: &str) -> Option<Vec<u8>> {
1057
    if !s.len().is_multiple_of(2) {
1058
        return None;
1059
    }
119bbf50 Andy McCall ↶ 1060
    (0..s.len())
1061
        .step_by(2)
1062
        .map(|i| u8::from_str_radix(&s[i..i + 2], 16).ok())
1063
        .collect()
7aa2dc93 Andy McCall ↶ 1064
}
1065
1066
fn hex_string(b: &[u8]) -> String {
1067
    b.iter().map(|x| format!("{x:02x}")).collect()
8e9c64d1 Andy McCall ↶ 1068
}
1069
1070
#[cfg(test)]
1071
mod tests {
1072
    #[test]
1073
    fn secrets_are_never_printed_whole() {
1074
        assert!(
1075
            super::secret_key("remote.earth.token")
1076
                && super::secret_key("auth.secret")
1077
                && !super::secret_key("remote.earth.url")
1078
        );
1079
        assert_eq!(
1080
            super::mask("oxyhub_6b7dce13409b634d"),
1081
            "oxyhub_6b7…(13 more)"
1082
        );
1083
        assert_eq!(super::mask("short"), "…");
1084
    }
7aa2dc93 Andy McCall ↶ 1085
}