OXYGENHUB

Coverage / src/cli/identity.rs 34.0%

288 of 848 lines run, 19 of 155 functions · run #425 on main · run missed

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
180×
fn expiry_from_days(days: u64) -> Option<u64> {
190×
    (days > 0).then(|| Hlc::now().wall_ms + days * DAY_MS)
200×
}
21
22
/// `30m`, `8h`, `7d`, or a bare number of days, as a wall time from now.
230×
fn expiry_from_duration(s: &str) -> Result<u64> {
240×
    let s = s.trim();
250×
    let (n, unit) = match s.char_indices().find(|(_, c)| !c.is_ascii_digit()) {
260×
        Some((i, _)) => (&s[..i], &s[i..]),
270×
        None => (s, "d"),
28
    };
290×
    let n: u64 = n.parse().map_err(|_| {
300×
        anyhow::anyhow!("--expires takes 30m, 8h, 7d or a number of days, not {s:?}")
310×
    })?;
320×
    let ms = match unit {
330×
        "m" => 60_000,
340×
        "h" => 3_600_000,
350×
        "d" => DAY_MS,
360×
        _ => anyhow::bail!("--expires takes 30m, 8h, 7d or a number of days, not {s:?}"),
37
    };
380×
    if n == 0 {
390×
        anyhow::bail!("--expires needs a length of time; leave it out for no expiry");
400×
    }
410×
    Ok(Hlc::now().wall_ms + n * ms)
420×
}
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.
461×
fn print_delegates(repo: &Repo, delegates: &[Delegate]) {
471×
    if delegates.is_empty() {
481×
        return;
490×
    }
500×
    println!("{}", ui::kv("delegates", delegates.len().to_string()));
510×
    let now = Hlc::now().wall_ms;
520×
    for d in delegates {
530×
        let who = repo
540×
            .identity(&d.key)
550×
            .map(|i| format!("{} <{}>", i.name, i.email))
560×
            .unwrap_or_else(|_| "(unknown identity)".into());
570×
        let until = match d.expires {
580×
            Some(e) if now > e => ui::err(format!("expired {}", ui::ago(e))),
590×
            Some(e) => ui::dim(format!("until {}", when(e))),
600×
            None => String::new(),
61
        };
620×
        println!(
63
            "    {}  {}  {} {}",
640×
            ui::author(who),
650×
            ui::hash(d.key.short(12)),
660×
            ui::dim(d.scope.to_string()),
67
            until
68
        );
69
    }
701×
}
71
72
/// Resolve a KeyId prefix among the identities the repository knows.
730×
fn resolve_key(repo: &Repo, prefix: &str) -> Result<KeyId> {
740×
    if let Some(k) = KeyId::from_hex(prefix) {
750×
        return Ok(k);
760×
    }
770×
    let mut candidates: Vec<KeyId> = Vec::new();
780×
    if let Some((_, sp)) = repo.current_policy()? {
790×
        candidates.extend(sp.policy.identities.iter().map(|i| i.hash()));
800×
        candidates.extend(sp.policy.revoked.iter().copied());
810×
    }
820×
    if let Some(own) = repo.config.author_key {
830×
        candidates.push(own);
840×
        if let Ok(id) = repo.identity(&own) {
850×
            candidates.extend(id.delegates.iter().map(|d| d.key));
860×
        }
870×
    }
880×
    candidates.sort();
890×
    candidates.dedup();
900×
    let matches: Vec<_> = candidates
910×
        .into_iter()
920×
        .filter(|k| k.starts_with_hex(prefix))
930×
        .collect();
940×
    match matches.len() {
950×
        1 => Ok(matches[0]),
960×
        0 => anyhow::bail!("no known key starts with {prefix:?}"),
970×
        _ => anyhow::bail!("{prefix:?} is ambiguous"),
98
    }
990×
}
100
101
/// Report a policy update: applied, or written out as a proposal that
102
/// other root holders sign with `oxy policy sign`.
1032×
fn report_update(u: &PolicyUpdate, out: Option<PathBuf>) -> Result<()> {
1042×
    if u.applied {
1052×
        println!(
106
            "{} policy serial {} {}",
1072×
            ui::done("Applied"),
108
            u.policy.serial,
1092×
            ui::dim("(push to publish it)")
110
        );
1112×
        return Ok(());
1120×
    }
1130×
    let path = out.unwrap_or_else(|| PathBuf::from(format!("policy-{}.proposal", u.policy.serial)));
1140×
    std::fs::write(&path, u.signed().encode())?;
1150×
    println!(
116
        "{} policy serial {} needs {} of {} root signatures; has {}. Wrote {} for another root to run {}",
1170×
        ui::caution(),
118
        u.policy.serial,
119
        u.policy.root_threshold,
1200×
        u.policy.roots.len(),
1210×
        u.sigs.len(),
1220×
        ui::path(path.display()),
1230×
        ui::dim(format!("oxy policy sign {}", path.display()))
124
    );
1250×
    Ok(())
1262×
}
127
1280×
fn report_rotation(repo: &Repo, r: &Rotation, verb: &str) -> Result<()> {
1290×
    println!(
130
        "{} identity {} <{}>",
1310×
        ui::done(verb),
1320×
        ui::author(&r.identity.name),
133
        r.identity.email
134
    );
135
    // Which key it started from, and where that came from (oxygen#163).
1360×
    let from = if repo.config.is_repo_set("author.key") {
1370×
        "this repository's author.key"
138
    } else {
1390×
        "the user config's author.key"
140
    };
1410×
    println!(
142
        "{}",
1430×
        ui::kv(
1440×
            "old key",
1450×
            format!(
146
                "{} {}",
1470×
                ui::dim(r.old.to_hex()),
1480×
                ui::dim(format!("(from {from})"))
149
            )
150
        )
151
    );
1520×
    println!("{}", ui::kv("new key", r.new.to_hex()));
1530×
    print_delegates(repo, &r.identity.delegates);
1540×
    if let Some(e) = r.identity.expires {
1550×
        println!("{}", ui::kv("expires", when(e)));
1560×
    }
1570×
    match &r.policy {
1580×
        Some(u) => report_update(u, None)?,
1590×
        None => println!(
160
            "  {} {}",
1610×
            ui::note(),
1620×
            ui::dim("no policy names the old key; export the new identity for policy holders")
163
        ),
164
    }
1650×
    Ok(())
1660×
}
167
1686×
pub fn create(
1696×
    name: Option<String>,
1706×
    email: Option<String>,
1716×
    local: bool,
1726×
    expires: Option<u64>,
1736×
) -> Result<i32> {
174
    // A first identity comes before any repository (oxygen#144): only
175
    // --local needs one; inside one, it is kept there too.
1766×
    let mut repo = match open() {
1774×
        Ok(r) => r,
1781×
        Err(e) if local => return Err(e),
179
        Err(_) => {
1801×
            let (key, id) = repo::create_user_identity(
1811×
                name.as_deref(),
1821×
                email.as_deref(),
1831×
                expires.and_then(expiry_from_days),
1840×
            )?;
1851×
            println!(
186
                "{} identity {} <{}>",
1871×
                ui::done("Created"),
1881×
                ui::author(&id.name),
189
                id.email
190
            );
1911×
            println!("{}", ui::kv("key", key));
1921×
            println!(
193
                "{}",
1941×
                ui::kv("secret", ui::path(repo::default_keys_dir().display()))
195
            );
1961×
            println!(
197
                "{}",
1981×
                ui::kv(
1991×
                    "scope",
2001×
                    format!(
201
                        "default author in {}",
2021×
                        ui::path(repo::default_user_config().display())
203
                    )
204
                )
205
            );
2061×
            println!("  {} {}", ui::note(), ui::dim("changes in every repository carry this author; run oxy config --user core.sign change to sign each commit"));
2071×
            return Ok(0);
208
        }
209
    };
2104×
    let name = name.unwrap_or_else(|| repo.config.author_name.clone());
2114×
    let email = email.unwrap_or_else(|| repo.config.author_email.clone());
2124×
    let scope = if local {
2130×
        IdentityScope::Repo
214
    } else {
2154×
        IdentityScope::User
216
    };
2174×
    let (key, _) =
2184×
        repo.create_identity_with(&name, &email, scope, expires.and_then(expiry_from_days))?;
2194×
    println!(
220
        "{} identity {} <{email}>",
2214×
        ui::done("Created"),
2224×
        ui::author(&name)
223
    );
2244×
    println!("{}", ui::kv("key", key));
2254×
    println!(
226
        "{}",
2274×
        ui::kv("secret", ui::path(repo::default_keys_dir().display()))
228
    );
2294×
    match scope {
2304×
        IdentityScope::User => println!(
231
            "{}",
2324×
            ui::kv(
2334×
                "scope",
2344×
                format!(
235
                    "default author in {}",
2364×
                    ui::path(repo.user_config_path().display())
237
                )
238
            )
239
        ),
240
        IdentityScope::Repo => {
2410×
            println!("{}", ui::kv("scope", "this repository's .oxy/config only"))
242
        }
243
    }
2444×
    if !repo.config.sign_changes {
2454×
        println!("  {} {}", ui::note(), ui::dim("changes will carry this author; run oxy config --user core.sign change to sign each commit"));
2464×
    }
2474×
    Ok(0)
2486×
}
249
2502×
pub fn show() -> Result<i32> {
2512×
    let repo = open()?;
2522×
    let Some(key) = repo.config.author_key else {
2531×
        println!("No identity configured; run `oxy identity create`.");
2541×
        return Ok(0);
255
    };
2561×
    let id = repo.identity(&key)?;
2571×
    println!(
258
        "{} {} <{}>",
2591×
        ui::section("identity"),
2601×
        ui::author(&id.name),
261
        id.email
262
    );
2631×
    println!("{}", ui::kv("key", key));
2641×
    println!(
265
        "{}",
2661×
        ui::kv(
2671×
            "created",
2681×
            format!(
269
                "{} {}",
2701×
                when(id.created),
2711×
                ui::dim(format!("({})", ui::ago(id.created)))
272
            )
273
        )
274
    );
2751×
    if let Some(e) = id.expires {
2760×
        let expired = id.is_expired_at(Hlc::now().wall_ms);
2770×
        println!(
278
            "{}",
2790×
            ui::kv(
2800×
                "expires",
2810×
                if expired {
2820×
                    ui::err(format!("{} EXPIRED", when(e)))
283
                } else {
2840×
                    when(e)
285
                }
286
            )
287
        );
2881×
    }
2891×
    if let Some(p) = id.previous {
2900×
        println!("{}", ui::kv("previous", ui::dim(p.short(12))));
2911×
    }
292
    // A repository that pins one key while the user file names another is
293
    // how checkouts drift apart after a rotation (oxygen#69).
2941×
    if let Some(pinned) = repo
2951×
        .config
2961×
        .repo_layer()
2971×
        .get("author.key")
2981×
        .and_then(|k| oxy_core::hash::KeyId::from_hex(k))
299
    {
3000×
        let user_key = repo::read_config_file(&repo::default_user_config())
3010×
            .ok()
3020×
            .and_then(|m| {
3030×
                m.get("author.key")
3040×
                    .and_then(|k| oxy_core::hash::KeyId::from_hex(k))
3050×
            });
3060×
        if let Some(u) = user_key.filter(|u| *u != pinned) {
3070×
            let related = |from: oxy_core::hash::KeyId, to: oxy_core::hash::KeyId| {
3080×
                let mut cur = Some(from);
3090×
                for _ in 0..64 {
3100×
                    match cur {
3110×
                        Some(k) if k == to => return true,
3120×
                        Some(k) => cur = repo.identity(&k).ok().and_then(|i| i.previous),
3130×
                        None => break,
314
                    }
315
                }
3160×
                false
3170×
            };
3180×
            let how = if related(pinned, u) {
3190×
                "an older key of this identity"
3200×
            } else if related(u, pinned) {
3210×
                "a newer key of this identity"
322
            } else {
3230×
                "another key"
324
            };
3250×
            println!(
326
                "  {} {}",
3270×
                ui::caution(),
3280×
                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 {}`",
3300×
                    pinned.short(12),
3310×
                    u.short(12),
3320×
                    pinned.to_hex()
333
                ))
334
            );
3350×
        }
3361×
    }
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).
3391×
    let next = repo.successors(&key)?;
3401×
    if let Some(n) = next.first() {
3410×
        println!(
342
            "  {} {}",
3430×
            ui::caution(),
3440×
            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",
3460×
                n.short(12),
3470×
                repo.newest(n)?.short(12)
348
            ))
349
        );
3501×
    }
3511×
    let mut cur = Some(key);
3521×
    for _ in 0..256 {
3532×
        let Some(k) = cur else { break };
3541×
        let after = repo.successors(&k)?;
3551×
        if after.len() > 1 {
3560×
            println!(
357
                "  {} {}",
3580×
                ui::caution(),
3590×
                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",
3610×
                    k.short(12),
3620×
                    after.iter().map(|a| a.short(12)).collect::<Vec<_>>().join(" and ")
363
                ))
364
            );
3650×
            break;
3661×
        }
3671×
        cur = repo.identity(&k).ok().and_then(|i| i.previous);
368
    }
3691×
    print_delegates(&repo, &id.delegates);
3701×
    if repo.is_revoked(&key)? {
3710×
        println!(
3720×
            "{}",
3730×
            ui::kv(
3740×
                "status",
3750×
                ui::err(format!("{} REVOKED", ui::glyph("✖", "x")))
3760×
            )
3770×
        );
3781×
    }
3791×
    println!(
380
        "{}",
3811×
        ui::kv(
3821×
            "secret",
3831×
            if repo.secret_key()?.is_some() {
3841×
                ui::ok(format!("{} available", ui::glyph("✔", "*")))
385
            } else {
3860×
                ui::err(format!(
387
                    "{} not found in the key directory",
3880×
                    ui::glyph("✖", "x")
389
                ))
390
            }
391
        )
392
    );
3931×
    println!(
394
        "{}",
3951×
        ui::kv(
3961×
            "signing",
3971×
            if repo.config.sign_changes {
3981×
                "every change (core.sign = change)"
399
            } else {
4000×
                "at push via seals (core.sign = seal)"
401
            }
402
        )
403
    );
4041×
    Ok(0)
4052×
}
406
4071×
pub fn verify(change: Option<&str>, revset: Option<&str>, as_json: bool) -> Result<i32> {
4081×
    let repo = open()?;
4091×
    let hashes: Vec<ChangeHash> = match (change, revset) {
4100×
        (_, Some(r)) => {
4110×
            let wc = repo.working_copy().ok().map(|(h, _)| h);
4120×
            repo.revset(r)?
413
                .hashes
4140×
                .into_iter()
4150×
                .filter(|h| Some(*h) != wc)
4160×
                .collect()
417
        }
4180×
        (Some(c), None) => vec![repo.resolve_change(c)?],
4191×
        (None, None) => repo
4201×
            .log()?
4211×
            .into_iter()
4223×
            .filter(|e| !e.is_working_copy)
4231×
            .map(|e| e.hash)
4241×
            .collect(),
425
    };
4261×
    let mut bad = 0;
4271×
    if as_json {
4281×
        let mut items = Vec::new();
4292×
        for h in &hashes {
4302×
            let Ok(c) = repo.store().get_change(h) else {
4310×
                continue;
432
            };
4332×
            let (status, key, name, revoked, reason) = match repo.authenticate_change(h)? {
4340×
                SignatureStatus::Unsigned => ("unsigned", None, None, false, None),
435
                SignatureStatus::Valid {
4362×
                    key,
4372×
                    name,
4382×
                    sealed,
4392×
                    revoked,
440
                    ..
441
                } => (
4422×
                    if sealed { "sealed" } else { "signed" },
4432×
                    Some(key.to_hex()),
4442×
                    Some(name),
4452×
                    revoked,
4462×
                    None,
447
                ),
4480×
                SignatureStatus::Invalid(why) => {
4490×
                    bad += 1;
4500×
                    ("invalid", None, None, false, Some(why))
451
                }
452
            };
4532×
            items.push(json::obj([
4542×
                ("hash", json::s(&h.to_hex())),
4552×
                ("change_id", json::s(&c.change_id.to_hex())),
4562×
                ("status", json::s(status)),
4572×
                ("key", json::opt(key.as_deref(), json::s)),
4582×
                ("name", json::opt(name.as_deref(), json::s)),
4592×
                ("revoked", json::b(revoked)),
4602×
                ("reason", json::opt(reason.as_deref(), json::s)),
4612×
            ]));
462
        }
4631×
        println!("{}", json::arr(items));
4641×
        return Ok(if bad > 0 { 1 } else { 0 });
4650×
    }
4660×
    for h in hashes {
4670×
        let Ok(c) = repo.store().get_change(&h) else {
4680×
            println!(
469
                "{}  {}",
4700×
                ui::hash(format!("#{}", h.short(12))),
4710×
                ui::dim("not present (shallow)")
472
            );
4730×
            continue;
474
        };
4750×
        let line = signature_line(&repo, &h)?;
4760×
        let styled = if line.starts_with("INVALID") {
4770×
            bad += 1;
4780×
            ui::err(format!("{} {line}", ui::glyph("✖", "x")))
4790×
        } else if line == "unsigned" {
4800×
            ui::dim(format!("{} {line}", ui::glyph("·", "-")))
481
        } else {
4820×
            ui::ok(format!("{} {line}", ui::glyph("✔", "v")))
483
        };
4840×
        println!(
485
            "{} {}  {styled}",
4860×
            ui::id(short_id(&c)),
4870×
            ui::hash(format!("#{}", h.short(12)))
488
        );
489
    }
4900×
    Ok(if bad > 0 { 1 } else { 0 })
4911×
}
492
493
/// Whether a configuration key holds a secret, never to be printed.
4946×
fn secret_key(key: &str) -> bool {
4956×
    let k = key.to_ascii_lowercase();
4966×
    k.ends_with(".token") || k.contains("secret") || k.contains("password")
4976×
}
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.
5012×
fn mask(value: &str) -> String {
5022×
    let shown: String = value.chars().take(10).collect();
5032×
    let rest = value.chars().count().saturating_sub(10);
5042×
    if rest == 0 {
5051×
        "…".to_string()
506
    } else {
5071×
        format!("{shown}…({rest} more)")
508
    }
5092×
}
510
5113×
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.
513
    // `-` reads the value from standard input, so a token never sits in
514
    // the shell's history (oxygen#113).
515
    let read;
5163×
    let value = if unset {
5170×
        ""
5183×
    } else if value == "-" {
5190×
        let mut line = String::new();
5200×
        std::io::stdin().read_line(&mut line)?;
5210×
        read = line.trim().to_string();
5220×
        read.as_str()
523
    } else {
5243×
        value
525
    };
5263×
    let said = if unset {
5270×
        format!("{} {key}", ui::done("Unset"))
5283×
    } else if secret_key(key) {
5290×
        format!("{} {key} = {}", ui::done("Set"), mask(value))
530
    } else {
5313×
        format!("{} {key} = {value}", ui::done("Set"))
532
    };
5333×
    if user {
5342×
        let path = repo::default_user_config();
5352×
        repo::Config::set_user(&path, key, value)?;
5362×
        println!("{said} {}", ui::dim(format!("(in {})", path.display())));
537
    } else {
5381×
        let mut repo = open()?;
5391×
        repo.config.set(key, value)?;
5401×
        repo.save_config()?;
5411×
        println!("{said} {}", ui::dim("(in this repository)"));
542
    }
5433×
    Ok(0)
5443×
}
545
5461×
pub fn export(path: Option<PathBuf>, pem: bool) -> Result<i32> {
5471×
    let repo = open()?;
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.
5501×
    let key = if pem {
5511×
        repo.signing_key()
552
    } else {
5530×
        repo.config.author_key
554
    };
5551×
    let Some(key) = key else {
5560×
        anyhow::bail!("no identity configured; run `oxy identity create`");
557
    };
5581×
    let id = repo.identity(&key)?;
5591×
    let ext = if pem { "pem" } else { "identity" };
5601×
    let path = path.unwrap_or_else(|| PathBuf::from(format!("{}.{ext}", key.short(16))));
5611×
    if pem {
5621×
        std::fs::write(&path, oxy_core::dsse::public_pem(&id))?;
563
    } else {
5640×
        std::fs::write(&path, id.encode())?;
565
    }
5661×
    println!(
567
        "{} {} {} <{}> ({}) to {}",
5681×
        ui::done("Wrote"),
5691×
        if pem { "the public key of" } else { "identity" },
5701×
        ui::author(&id.name),
571
        id.email,
5721×
        key.short(12),
5731×
        ui::path(path.display())
574
    );
5751×
    Ok(0)
5761×
}
577
5780×
pub fn rotate() -> Result<i32> {
5790×
    let mut repo = open()?;
5800×
    let r = repo.rotate_identity()?;
5810×
    report_rotation(&repo, &r, "Rotated")?;
5820×
    Ok(0)
5830×
}
584
5850×
pub fn renew(expires: u64) -> Result<i32> {
5860×
    let mut repo = open()?;
5870×
    let r = repo.renew_identity(expiry_from_days(expires))?;
5880×
    report_rotation(&repo, &r, "Renewed")?;
5890×
    Ok(0)
5900×
}
591
5920×
pub fn revoke(key: Option<&str>, reason: &str) -> Result<i32> {
5930×
    let repo = open()?;
5940×
    let key = match key {
5950×
        Some(k) => resolve_key(&repo, k)?,
5960×
        None => repo
5970×
            .config
5980×
            .author_key
5990×
            .ok_or_else(|| anyhow::anyhow!("no identity configured"))?,
600
    };
6010×
    let update = repo.revoke_identity(&key, reason)?;
6020×
    println!(
603
        "{} key {}{}",
6040×
        ui::done("Revoked"),
6050×
        ui::hash(key.short(12)),
6060×
        if reason.is_empty() {
6070×
            String::new()
608
        } else {
6090×
            format!(": {reason}")
610
        }
611
    );
6120×
    match update {
6130×
        Some(u) => report_update(&u, None)?,
6140×
        None => println!(
615
            "  {} {}",
6160×
            ui::note(),
6170×
            ui::dim("recorded locally; no policy named the key")
618
        ),
619
    }
620
    // A revoked delegate comes off the identity too, so the successor no
621
    // longer names it (spec §14.2).
6220×
    let own = repo.config.author_key;
6230×
    let delegated = own.is_some_and(|o| {
6240×
        repo.identity(&o)
6250×
            .is_ok_and(|id| id.delegate(&key).is_some())
6260×
    });
6270×
    if delegated {
6280×
        let mut repo = repo;
6290×
        let r = repo.delegate_remove(&key)?;
6300×
        report_rotation(&repo, &r, "Undelegated")?;
6310×
        return Ok(0);
6320×
    }
6330×
    if repo.config.author_key == Some(key) {
6340×
        println!(
6350×
            "  {} {}",
6360×
            ui::caution(),
6370×
            ui::warn("this was your configured key; run oxy identity create or rotate")
6380×
        );
6390×
    }
6400×
    Ok(0)
6410×
}
642
6430×
pub fn delegate_add(identity_files: &[PathBuf], scope: &str, expires: Option<&str>) -> Result<i32> {
6440×
    let mut repo = open()?;
6450×
    let ids = identity_files
6460×
        .iter()
6470×
        .map(|f| {
6480×
            Identity::decode(
6490×
                &std::fs::read(f).map_err(|e| anyhow::anyhow!("{}: {e}", f.display()))?,
650
            )
6510×
            .map_err(Into::into)
6520×
        })
6530×
        .collect::<Result<Vec<_>>>()?;
6540×
    let scope = Scope::parse(scope).map_err(|e| anyhow::anyhow!("{e}"))?;
6550×
    let expires = expires.map(expiry_from_duration).transpose()?;
6560×
    let r = repo.delegate_add_many(ids, scope, expires)?;
6570×
    report_rotation(&repo, &r, "Delegated")?;
6580×
    println!(
659
        "  {} {}",
6600×
        ui::note(),
6610×
        ui::dim("an agent configured with your author.key needs the new key: oxy config --user author.key NEW in its home")
662
    );
6630×
    if !scope.has(Scope::REFS) {
6640×
        println!(
6650×
            "  {} {}",
6660×
            ui::note(),
6670×
            ui::dim("it may not move references: a push or tag it attests is refused")
6680×
        );
6690×
    }
6700×
    Ok(0)
6710×
}
672
6730×
pub fn delegate_remove(key: &str) -> Result<i32> {
6740×
    let mut repo = open()?;
6750×
    let k = resolve_key(&repo, key)?;
6760×
    let r = repo.delegate_remove(&k)?;
6770×
    report_rotation(&repo, &r, "Undelegated")?;
6780×
    Ok(0)
6790×
}
680
6810×
pub fn delegate_list() -> Result<i32> {
6820×
    let repo = open()?;
6830×
    let Some(key) = repo.config.author_key else {
6840×
        println!("No identity configured; run `oxy identity create`.");
6850×
        return Ok(0);
686
    };
6870×
    let id = repo.identity(&key)?;
6880×
    if id.delegates.is_empty() {
6890×
        println!("{}", ui::dim("No delegates."));
6900×
    }
6910×
    let now = Hlc::now().wall_ms;
6920×
    for d in &id.delegates {
6930×
        let who = repo
6940×
            .identity(&d.key)
6950×
            .map(|i| format!("{} <{}>", i.name, i.email))
6960×
            .unwrap_or_else(|_| "(unknown identity)".into());
6970×
        let until = match d.expires {
6980×
            Some(e) if now > e => ui::err(format!("expired {}", ui::ago(e))),
6990×
            Some(e) => ui::dim(format!("until {}", when(e))),
7000×
            None => String::new(),
701
        };
7020×
        println!(
703
            "  {} {}  {}  {} {}",
7040×
            ui::ok(ui::glyph("🔑", "*")),
7050×
            ui::hash(d.key.short(12)),
7060×
            ui::author(who),
7070×
            ui::dim(d.scope.to_string()),
708
            until
709
        );
710
    }
7110×
    Ok(0)
7120×
}
713
7140×
pub fn policy_root_add(identity_file: &Path, out: Option<PathBuf>) -> Result<i32> {
7150×
    let repo = open()?;
7160×
    let id = Identity::decode(&std::fs::read(identity_file)?)?;
7170×
    let key = id.hash();
7180×
    let u = repo.policy_root_add(id.clone())?;
7190×
    println!(
720
        "{} root {} <{}> ({})",
7210×
        ui::done("Added"),
7220×
        ui::author(&id.name),
723
        id.email,
7240×
        key.short(12)
725
    );
7260×
    report_update(&u, out)?;
7270×
    Ok(0)
7280×
}
729
7300×
pub fn policy_root_remove(key: &str, out: Option<PathBuf>) -> Result<i32> {
7310×
    let repo = open()?;
7320×
    let k = resolve_key(&repo, key)?;
7330×
    let u = repo.policy_root_remove(&k)?;
7340×
    println!("{} root {}", ui::done("Removed"), ui::hash(k.short(12)));
7350×
    report_update(&u, out)?;
7360×
    Ok(0)
7370×
}
738
7390×
pub fn policy_threshold(n: u32, out: Option<PathBuf>) -> Result<i32> {
7400×
    let repo = open()?;
7410×
    let u = repo.policy_set_threshold(n)?;
7420×
    println!("{} root threshold to {n}", ui::done("Set"));
7430×
    report_update(&u, out)?;
7440×
    Ok(0)
7450×
}
746
7470×
pub fn policy_sign(proposal: &Path, out: Option<PathBuf>) -> Result<i32> {
7480×
    let repo = open()?;
7490×
    let sp = SignedPolicy::decode(&std::fs::read(proposal)?)?;
7500×
    let u = repo.policy_sign(sp)?;
7510×
    println!("{} policy serial {}", ui::done("Signed"), u.policy.serial);
7520×
    report_update(&u, Some(out.unwrap_or_else(|| proposal.to_path_buf())))?;
7530×
    Ok(0)
7540×
}
755
7561×
pub fn policy_init() -> Result<i32> {
7571×
    let repo = open()?;
7581×
    let sp = repo.policy_init()?;
7591×
    println!(
760
        "{} policy with root key {}",
7611×
        ui::done("Created"),
7621×
        sp.policy.roots[0].short(12)
763
    );
7641×
    report_update(&sp, None)?;
7651×
    Ok(0)
7661×
}
767
7683×
pub fn policy_show(as_json: bool) -> Result<i32> {
7693×
    let repo = open()?;
7703×
    let Some((r, sp)) = repo.current_policy()? else {
7711×
        if as_json {
7721×
            println!("null");
7731×
        } else {
7740×
            println!(
7750×
                "No policy: this repository is unverified. Run `oxy policy init` to create one."
7760×
            );
7770×
        }
7781×
        return Ok(0);
779
    };
7802×
    let p = &sp.policy;
7812×
    if as_json {
7825×
        let ident = |k: &oxy_core::hash::KeyId| {
7835×
            let (name, email) = p
7845×
                .identity(k)
7855×
                .map(|i| (i.name.clone(), i.email.clone()))
7865×
                .unwrap_or_default();
7875×
            json::obj([
7885×
                ("key", json::s(&k.to_hex())),
7895×
                ("name", json::s(&name)),
7905×
                ("email", json::s(&email)),
7915×
            ])
7925×
        };
7932×
        println!(
794
            "{}",
7952×
            json::obj([
7962×
                ("hash", json::s(&r.to_hex())),
7972×
                ("serial", json::n(p.serial)),
7982×
                ("expires_ms", json::n(p.expires)),
7992×
                (
8002×
                    "expired",
8012×
                    json::b(p.is_expired(oxy_core::objects::Hlc::now().wall_ms))
8022×
                ),
8032×
                ("root_threshold", json::n(p.root_threshold)),
8042×
                ("roots", json::arr(p.roots.iter().map(ident))),
805
                (
8062×
                    "rules",
8072×
                    json::arr(p.rules.iter().map(|rule| {
8082×
                        json::obj([
8092×
                            ("pattern", json::s(&rule.pattern)),
8102×
                            ("keys", json::arr(rule.keys.iter().map(ident))),
8112×
                            ("threshold", json::n(rule.threshold)),
8122×
                            ("require", json::s(rule.require.name())),
8132×
                            ("allow_conflicts", json::b(rule.allow_conflicts)),
814
                            (
8152×
                                "subjects",
8162×
                                json::arr(rule.subjects.iter().map(|g| {
8171×
                                    json::obj([
8181×
                                        ("subject", json::s(&g.pattern)),
8191×
                                        ("issuer", ident(&g.issuer)),
8201×
                                        ("scope", json::s(&g.scope.to_string())),
8211×
                                    ])
8221×
                                })),
823
                            ),
824
                        ])
8252×
                    })),
826
                ),
8272×
                ("revoked", json::arr(p.revoked.iter().map(ident))),
828
            ])
829
        );
8302×
        return Ok(0);
8310×
    }
8320×
    let name = |k: &oxy_core::hash::KeyId| {
8330×
        p.identity(k)
8340×
            .map(|i| format!("{} <{}>", i.name, i.email))
8350×
            .unwrap_or_else(|| "(unknown identity)".into())
8360×
    };
8370×
    let expired = p.is_expired(oxy_core::objects::Hlc::now().wall_ms);
8380×
    println!(
839
        "{} {}{}serial {}{}expires {}{}",
8400×
        ui::section("policy"),
8410×
        ui::hash(format!("#{}", r.short(12))),
8420×
        ui::sep(),
843
        p.serial,
8440×
        ui::sep(),
8450×
        when(p.expires),
8460×
        if expired {
8470×
            ui::err(format!("  {} EXPIRED", ui::glyph("✖", "x")))
848
        } else {
8490×
            String::new()
850
        }
851
    );
8520×
    println!(
853
        "{}",
8540×
        ui::section(format!(
855
            "roots {}",
8560×
            ui::dim(format!("(threshold {})", p.root_threshold))
857
        ))
858
    );
8590×
    for k in &p.roots {
8600×
        println!(
8610×
            "  {} {}  {}",
8620×
            ui::ok(ui::glyph("🔑", "*")),
8630×
            ui::hash(k.short(12)),
8640×
            ui::author(name(k))
8650×
        );
8660×
    }
8670×
    println!("{}", ui::section("rules"));
8680×
    for rule in &p.rules {
8690×
        println!(
870
            "  {} {:12} {}",
8710×
            ui::ok(ui::glyph("▸", ">")),
8720×
            ui::bookmark(&rule.pattern),
8730×
            ui::dim(format!(
874
                "threshold {}, require {}, conflicts {}",
875
                rule.threshold,
8760×
                rule.require.name(),
8770×
                if rule.allow_conflicts {
8780×
                    "allowed"
879
                } else {
8800×
                    "refused"
881
                }
882
            ))
883
        );
8840×
        for k in &rule.keys {
8850×
            println!("      {}  {}", ui::hash(k.short(12)), ui::author(name(k)));
8860×
        }
8870×
        for g in &rule.subjects {
8880×
            println!(
8890×
                "      {}  {} {}",
8900×
                ui::author(&g.pattern),
8910×
                ui::dim(format!(
8920×
                    "certified by {} ({}),",
8930×
                    name(&g.issuer),
8940×
                    g.issuer.short(12)
8950×
                )),
8960×
                ui::dim(format!("scope {}", g.scope))
8970×
            );
8980×
        }
899
    }
9000×
    if !p.revoked.is_empty() {
9010×
        println!("{}", ui::section("revoked"));
9020×
        for k in &p.revoked {
9030×
            println!(
9040×
                "  {} {}  {}",
9050×
                ui::err(ui::glyph("✖", "x")),
9060×
                ui::hash(k.short(12)),
9070×
                ui::author(name(k))
9080×
            );
9090×
        }
9100×
    }
9110×
    Ok(0)
9123×
}
913
9140×
pub fn policy_grant(
9150×
    identity_file: &Path,
9160×
    pattern: Option<&str>,
9170×
    out: Option<PathBuf>,
9180×
) -> Result<i32> {
9190×
    let repo = open()?;
9200×
    let id = Identity::decode(&std::fs::read(identity_file)?)?;
9210×
    let key = id.hash();
9220×
    let u = repo.policy_grant(id.clone(), pattern)?;
9230×
    println!(
924
        "{} {} <{}> ({}) on {}",
9250×
        ui::done("Granted"),
9260×
        ui::author(&id.name),
927
        id.email,
9280×
        key.short(12),
9290×
        ui::bookmark(pattern.unwrap_or("*"))
930
    );
9310×
    report_update(&u, out)?;
9320×
    Ok(0)
9330×
}
934
9351×
pub fn policy_grant_subject(
9361×
    subject: &str,
9371×
    issuer_file: &Path,
9381×
    scope: &str,
9391×
    pattern: Option<&str>,
9401×
    out: Option<PathBuf>,
9411×
) -> Result<i32> {
9421×
    let repo = open()?;
9431×
    let issuer = Identity::decode(&std::fs::read(issuer_file)?)
9441×
        .map_err(|e| anyhow::anyhow!("{}: not an identity file: {e}", issuer_file.display()))?;
9451×
    let scope = oxy_core::identity::Scope::parse(scope).map_err(|e| anyhow::anyhow!(e))?;
9461×
    let key = issuer.hash();
9471×
    let u = repo.policy_grant_subject(subject, issuer.clone(), scope, pattern)?;
9481×
    println!(
949
        "{} {} certified by {} ({}) on {}, scope {}",
9501×
        ui::done("Granted"),
9511×
        ui::author(subject),
952
        issuer.name,
9531×
        key.short(12),
9541×
        ui::bookmark(pattern.unwrap_or("*")),
955
        scope
956
    );
9571×
    report_update(&u, out)?;
9581×
    Ok(0)
9591×
}
960
9610×
pub fn policy_revoke(key: &str, out: Option<PathBuf>) -> Result<i32> {
9620×
    let repo = open()?;
9630×
    if repo.current_policy()?.is_none() {
9640×
        anyhow::bail!("this repository has no policy");
9650×
    }
9660×
    let full = resolve_key(&repo, key)?;
9670×
    let u = repo.policy_revoke(&full)?;
9680×
    println!("{} {}", ui::done("Revoked"), ui::hash(full.short(12)));
9690×
    report_update(&u, out)?;
9700×
    Ok(0)
9710×
}
972
973
/// `oxy seal [CHANGE..]`: seal the named changes, or every sealable one.
9740×
pub fn seal(specs: &[String], as_json: bool) -> Result<i32> {
9750×
    let repo = Repo::open(&std::env::current_dir()?)?;
9760×
    let hashes: Vec<_> = if specs.is_empty() {
9770×
        repo.sealable()?
978
    } else {
9790×
        specs
9800×
            .iter()
9810×
            .map(|s| repo.resolve_change(s))
9820×
            .collect::<std::result::Result<_, _>>()?
983
    };
9840×
    if hashes.is_empty() {
9850×
        if as_json {
9860×
            println!("{{\"sealed\":[]}}");
9870×
        } else {
9880×
            println!(
9890×
                "{} nothing to seal: every change you can authenticate already is",
9900×
                ui::done("Sealed")
9910×
            );
9920×
        }
9930×
        return Ok(0);
9940×
    }
9950×
    let mut refs = Vec::new();
9960×
    for chunk in hashes.chunks(oxy_core::attest::MAX_SEAL_HASHES) {
9970×
        refs.push(repo.seal(chunk)?);
998
    }
9990×
    if as_json {
10000×
        let list: Vec<String> = hashes
10010×
            .iter()
10020×
            .map(|h| format!("\"{}\"", h.to_hex()))
10030×
            .collect();
10040×
        let seals: Vec<String> = refs.iter().map(|r| format!("\"{}\"", r.to_hex())).collect();
10050×
        println!(
1006
            "{{\"sealed\":[{}],\"seals\":[{}]}}",
10070×
            list.join(","),
10080×
            seals.join(",")
1009
        );
1010
    } else {
10110×
        println!(
1012
            "{} {} change{} in {} seal{}; they travel with your next push",
10130×
            ui::done("Sealed"),
10140×
            hashes.len(),
10150×
            if hashes.len() == 1 { "" } else { "s" },
10160×
            refs.len(),
10170×
            if refs.len() == 1 { "" } else { "s" }
1018
        );
10190×
        for h in &hashes {
10200×
            let title = repo
10210×
                .store()
10220×
                .get_change(h)
10230×
                .map(|c| c.message.lines().next().unwrap_or("").to_string())
10240×
                .unwrap_or_default();
10250×
            println!("  {} {}", ui::hash(h.short(12)), title);
1026
        }
1027
    }
10280×
    Ok(0)
10290×
}
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).
10330×
pub fn sign_challenge(challenge_hex: &str, as_json: bool) -> Result<i32> {
10340×
    let repo = open()?;
10350×
    let Some((key, sk)) = repo.secret_key()? else {
10360×
        anyhow::bail!("no secret key: run `oxy identity create` first");
1037
    };
10380×
    let challenge = hex_bytes(challenge_hex.trim())
10390×
        .ok_or_else(|| anyhow::anyhow!("the challenge must be hex"))?;
10400×
    let sig = sk
10410×
        .sign_challenge(&key, &challenge)
10420×
        .map_err(|e| anyhow::anyhow!("{e}"))?;
10430×
    let encoded = hex_string(&oxy_core::identity::Signature::encode_list(&[sig]));
10440×
    if as_json {
10450×
        println!(
10460×
            "{{\"key\":\"{}\",\"signature\":\"{}\"}}",
10470×
            key.to_hex(),
10480×
            encoded
10490×
        );
10500×
    } else {
10510×
        println!("{encoded}");
10520×
    }
10530×
    Ok(0)
10540×
}
1055
10560×
fn hex_bytes(s: &str) -> Option<Vec<u8>> {
10570×
    if !s.len().is_multiple_of(2) {
10580×
        return None;
10590×
    }
10600×
    (0..s.len())
10610×
        .step_by(2)
10620×
        .map(|i| u8::from_str_radix(&s[i..i + 2], 16).ok())
10630×
        .collect()
10640×
}
1065
10660×
fn hex_string(b: &[u8]) -> String {
10670×
    b.iter().map(|x| format!("{x:02x}")).collect()
10680×
}
1069
1070
#[cfg(test)]
1071
mod tests {
1072
    #[test]
10731×
    fn secrets_are_never_printed_whole() {
10741×
        assert!(
10751×
            super::secret_key("remote.earth.token")
10761×
                && super::secret_key("auth.secret")
10771×
                && !super::secret_key("remote.earth.url")
1078
        );
10791×
        assert_eq!(
10801×
            super::mask("oxyhub_6b7dce13409b634d"),
1081
            "oxyhub_6b7…(13 more)"
1082
        );
10831×
        assert_eq!(super::mask("short"), "…");
10841×
    }
1085
}