OXYGENHUB

Coverage / src/cli/identity.rs 28.5%

233 of 818 lines run, 17 of 153 functions · run #411 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.
460×
fn print_delegates(repo: &Repo, delegates: &[Delegate]) {
470×
    if delegates.is_empty() {
480×
        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
    }
700×
}
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
    );
1350×
    println!("{}", ui::kv("old key", ui::dim(r.old.to_hex())));
1360×
    println!("{}", ui::kv("new key", r.new.to_hex()));
1370×
    print_delegates(repo, &r.identity.delegates);
1380×
    if let Some(e) = r.identity.expires {
1390×
        println!("{}", ui::kv("expires", when(e)));
1400×
    }
1410×
    match &r.policy {
1420×
        Some(u) => report_update(u, None)?,
1430×
        None => println!(
144
            "  {} {}",
1450×
            ui::note(),
1460×
            ui::dim("no policy names the old key; export the new identity for policy holders")
147
        ),
148
    }
1490×
    Ok(())
1500×
}
151
1524×
pub fn create(
1534×
    name: Option<String>,
1544×
    email: Option<String>,
1554×
    local: bool,
1564×
    expires: Option<u64>,
1574×
) -> Result<i32> {
158
    // A first identity comes before any repository (oxygen#144): only
159
    // --local needs one; inside one, it is kept there too.
1604×
    let mut repo = match open() {
1612×
        Ok(r) => r,
1621×
        Err(e) if local => return Err(e),
163
        Err(_) => {
1641×
            let (key, id) = repo::create_user_identity(
1651×
                name.as_deref(),
1661×
                email.as_deref(),
1671×
                expires.and_then(expiry_from_days),
1680×
            )?;
1691×
            println!(
170
                "{} identity {} <{}>",
1711×
                ui::done("Created"),
1721×
                ui::author(&id.name),
173
                id.email
174
            );
1751×
            println!("{}", ui::kv("key", key));
1761×
            println!(
177
                "{}",
1781×
                ui::kv("secret", ui::path(repo::default_keys_dir().display()))
179
            );
1801×
            println!(
181
                "{}",
1821×
                ui::kv(
1831×
                    "scope",
1841×
                    format!(
185
                        "default author in {}",
1861×
                        ui::path(repo::default_user_config().display())
187
                    )
188
                )
189
            );
1901×
            println!("  {} {}", ui::note(), ui::dim("changes in every repository carry this author; run oxy config --user core.sign change to sign each commit"));
1911×
            return Ok(0);
192
        }
193
    };
1942×
    let name = name.unwrap_or_else(|| repo.config.author_name.clone());
1952×
    let email = email.unwrap_or_else(|| repo.config.author_email.clone());
1962×
    let scope = if local {
1970×
        IdentityScope::Repo
198
    } else {
1992×
        IdentityScope::User
200
    };
2012×
    let (key, _) =
2022×
        repo.create_identity_with(&name, &email, scope, expires.and_then(expiry_from_days))?;
2032×
    println!(
204
        "{} identity {} <{email}>",
2052×
        ui::done("Created"),
2062×
        ui::author(&name)
207
    );
2082×
    println!("{}", ui::kv("key", key));
2092×
    println!(
210
        "{}",
2112×
        ui::kv("secret", ui::path(repo::default_keys_dir().display()))
212
    );
2132×
    match scope {
2142×
        IdentityScope::User => println!(
215
            "{}",
2162×
            ui::kv(
2172×
                "scope",
2182×
                format!(
219
                    "default author in {}",
2202×
                    ui::path(repo.user_config_path().display())
221
                )
222
            )
223
        ),
224
        IdentityScope::Repo => {
2250×
            println!("{}", ui::kv("scope", "this repository's .oxy/config only"))
226
        }
227
    }
2282×
    if !repo.config.sign_changes {
2292×
        println!("  {} {}", ui::note(), ui::dim("changes will carry this author; run oxy config --user core.sign change to sign each commit"));
2302×
    }
2312×
    Ok(0)
2324×
}
233
2340×
pub fn show() -> Result<i32> {
2350×
    let repo = open()?;
2360×
    let Some(key) = repo.config.author_key else {
2370×
        println!("No identity configured; run `oxy identity create`.");
2380×
        return Ok(0);
239
    };
2400×
    let id = repo.identity(&key)?;
2410×
    println!(
242
        "{} {} <{}>",
2430×
        ui::section("identity"),
2440×
        ui::author(&id.name),
245
        id.email
246
    );
2470×
    println!("{}", ui::kv("key", key));
2480×
    println!(
249
        "{}",
2500×
        ui::kv(
2510×
            "created",
2520×
            format!(
253
                "{} {}",
2540×
                when(id.created),
2550×
                ui::dim(format!("({})", ui::ago(id.created)))
256
            )
257
        )
258
    );
2590×
    if let Some(e) = id.expires {
2600×
        let expired = id.is_expired_at(Hlc::now().wall_ms);
2610×
        println!(
262
            "{}",
2630×
            ui::kv(
2640×
                "expires",
2650×
                if expired {
2660×
                    ui::err(format!("{} EXPIRED", when(e)))
267
                } else {
2680×
                    when(e)
269
                }
270
            )
271
        );
2720×
    }
2730×
    if let Some(p) = id.previous {
2740×
        println!("{}", ui::kv("previous", ui::dim(p.short(12))));
2750×
    }
276
    // A repository that pins one key while the user file names another is
277
    // how checkouts drift apart after a rotation (oxygen#69).
2780×
    if let Some(pinned) = repo
2790×
        .config
2800×
        .repo_layer()
2810×
        .get("author.key")
2820×
        .and_then(|k| oxy_core::hash::KeyId::from_hex(k))
283
    {
2840×
        let user_key = repo::read_config_file(&repo::default_user_config())
2850×
            .ok()
2860×
            .and_then(|m| {
2870×
                m.get("author.key")
2880×
                    .and_then(|k| oxy_core::hash::KeyId::from_hex(k))
2890×
            });
2900×
        if let Some(u) = user_key.filter(|u| *u != pinned) {
2910×
            let related = |from: oxy_core::hash::KeyId, to: oxy_core::hash::KeyId| {
2920×
                let mut cur = Some(from);
2930×
                for _ in 0..64 {
2940×
                    match cur {
2950×
                        Some(k) if k == to => return true,
2960×
                        Some(k) => cur = repo.identity(&k).ok().and_then(|i| i.previous),
2970×
                        None => break,
298
                    }
299
                }
3000×
                false
3010×
            };
3020×
            let how = if related(pinned, u) {
3030×
                "an older key of this identity"
3040×
            } else if related(u, pinned) {
3050×
                "a newer key of this identity"
306
            } else {
3070×
                "another key"
308
            };
3090×
            println!(
310
                "  {} {}",
3110×
                ui::caution(),
3120×
                ui::dim(format!(
313
                    "this repository pins author.key {}; your user config names {how}, {}. Other checkouts follow the user config: `oxy config --unset author.key` here, or `oxy config --user author.key {}`",
3140×
                    pinned.short(12),
3150×
                    u.short(12),
3160×
                    pinned.to_hex()
317
                ))
318
            );
3190×
        }
3200×
    }
3210×
    print_delegates(&repo, &id.delegates);
3220×
    if repo.is_revoked(&key)? {
3230×
        println!(
3240×
            "{}",
3250×
            ui::kv(
3260×
                "status",
3270×
                ui::err(format!("{} REVOKED", ui::glyph("✖", "x")))
3280×
            )
3290×
        );
3300×
    }
3310×
    println!(
332
        "{}",
3330×
        ui::kv(
3340×
            "secret",
3350×
            if repo.secret_key()?.is_some() {
3360×
                ui::ok(format!("{} available", ui::glyph("✔", "*")))
337
            } else {
3380×
                ui::err(format!(
339
                    "{} not found in the key directory",
3400×
                    ui::glyph("✖", "x")
341
                ))
342
            }
343
        )
344
    );
3450×
    println!(
346
        "{}",
3470×
        ui::kv(
3480×
            "signing",
3490×
            if repo.config.sign_changes {
3500×
                "every change (core.sign = change)"
351
            } else {
3520×
                "at push via seals (core.sign = seal)"
353
            }
354
        )
355
    );
3560×
    Ok(0)
3570×
}
358
3591×
pub fn verify(change: Option<&str>, revset: Option<&str>, as_json: bool) -> Result<i32> {
3601×
    let repo = open()?;
3611×
    let hashes: Vec<ChangeHash> = match (change, revset) {
3620×
        (_, Some(r)) => {
3630×
            let wc = repo.working_copy().ok().map(|(h, _)| h);
3640×
            repo.revset(r)?
365
                .hashes
3660×
                .into_iter()
3670×
                .filter(|h| Some(*h) != wc)
3680×
                .collect()
369
        }
3700×
        (Some(c), None) => vec![repo.resolve_change(c)?],
3711×
        (None, None) => repo
3721×
            .log()?
3731×
            .into_iter()
3743×
            .filter(|e| !e.is_working_copy)
3751×
            .map(|e| e.hash)
3761×
            .collect(),
377
    };
3781×
    let mut bad = 0;
3791×
    if as_json {
3801×
        let mut items = Vec::new();
3812×
        for h in &hashes {
3822×
            let Ok(c) = repo.store().get_change(h) else {
3830×
                continue;
384
            };
3852×
            let (status, key, name, revoked, reason) = match repo.authenticate_change(h)? {
3860×
                SignatureStatus::Unsigned => ("unsigned", None, None, false, None),
387
                SignatureStatus::Valid {
3882×
                    key,
3892×
                    name,
3902×
                    sealed,
3912×
                    revoked,
392
                    ..
393
                } => (
3942×
                    if sealed { "sealed" } else { "signed" },
3952×
                    Some(key.to_hex()),
3962×
                    Some(name),
3972×
                    revoked,
3982×
                    None,
399
                ),
4000×
                SignatureStatus::Invalid(why) => {
4010×
                    bad += 1;
4020×
                    ("invalid", None, None, false, Some(why))
403
                }
404
            };
4052×
            items.push(json::obj([
4062×
                ("hash", json::s(&h.to_hex())),
4072×
                ("change_id", json::s(&c.change_id.to_hex())),
4082×
                ("status", json::s(status)),
4092×
                ("key", json::opt(key.as_deref(), json::s)),
4102×
                ("name", json::opt(name.as_deref(), json::s)),
4112×
                ("revoked", json::b(revoked)),
4122×
                ("reason", json::opt(reason.as_deref(), json::s)),
4132×
            ]));
414
        }
4151×
        println!("{}", json::arr(items));
4161×
        return Ok(if bad > 0 { 1 } else { 0 });
4170×
    }
4180×
    for h in hashes {
4190×
        let Ok(c) = repo.store().get_change(&h) else {
4200×
            println!(
421
                "{}  {}",
4220×
                ui::hash(format!("#{}", h.short(12))),
4230×
                ui::dim("not present (shallow)")
424
            );
4250×
            continue;
426
        };
4270×
        let line = signature_line(&repo, &h)?;
4280×
        let styled = if line.starts_with("INVALID") {
4290×
            bad += 1;
4300×
            ui::err(format!("{} {line}", ui::glyph("✖", "x")))
4310×
        } else if line == "unsigned" {
4320×
            ui::dim(format!("{} {line}", ui::glyph("·", "-")))
433
        } else {
4340×
            ui::ok(format!("{} {line}", ui::glyph("✔", "v")))
435
        };
4360×
        println!(
437
            "{} {}  {styled}",
4380×
            ui::id(short_id(&c)),
4390×
            ui::hash(format!("#{}", h.short(12)))
440
        );
441
    }
4420×
    Ok(if bad > 0 { 1 } else { 0 })
4431×
}
444
445
/// Whether a configuration key holds a secret, never to be printed.
4465×
fn secret_key(key: &str) -> bool {
4475×
    let k = key.to_ascii_lowercase();
4485×
    k.ends_with(".token") || k.contains("secret") || k.contains("password")
4495×
}
450
451
/// A secret as a confirmation may show it (oxygen#113): enough of its start
452
/// to tell which one it is, never the rest.
4532×
fn mask(value: &str) -> String {
4542×
    let shown: String = value.chars().take(10).collect();
4552×
    let rest = value.chars().count().saturating_sub(10);
4562×
    if rest == 0 {
4571×
        "…".to_string()
458
    } else {
4591×
        format!("{shown}…({rest} more)")
460
    }
4612×
}
462
4632×
pub fn config(key: &str, value: &str, user: bool, unset: bool) -> Result<i32> {
464
    // An empty value removes the key, which is what --unset says plainly.
465
    // `-` reads the value from standard input, so a token never sits in
466
    // the shell's history (oxygen#113).
467
    let read;
4682×
    let value = if unset {
4690×
        ""
4702×
    } else if value == "-" {
4710×
        let mut line = String::new();
4720×
        std::io::stdin().read_line(&mut line)?;
4730×
        read = line.trim().to_string();
4740×
        read.as_str()
475
    } else {
4762×
        value
477
    };
4782×
    let said = if unset {
4790×
        format!("{} {key}", ui::done("Unset"))
4802×
    } else if secret_key(key) {
4810×
        format!("{} {key} = {}", ui::done("Set"), mask(value))
482
    } else {
4832×
        format!("{} {key} = {value}", ui::done("Set"))
484
    };
4852×
    if user {
4861×
        let path = repo::default_user_config();
4871×
        repo::Config::set_user(&path, key, value)?;
4881×
        println!("{said} {}", ui::dim(format!("(in {})", path.display())));
489
    } else {
4901×
        let mut repo = open()?;
4911×
        repo.config.set(key, value)?;
4921×
        repo.save_config()?;
4931×
        println!("{said} {}", ui::dim("(in this repository)"));
494
    }
4952×
    Ok(0)
4962×
}
497
4981×
pub fn export(path: Option<PathBuf>, pem: bool) -> Result<i32> {
4991×
    let repo = open()?;
500
    // The PEM is for checking what this home signs, so it is the signing
501
    // key's: a delegate's when `author.signer` names one.
5021×
    let key = if pem {
5031×
        repo.signing_key()
504
    } else {
5050×
        repo.config.author_key
506
    };
5071×
    let Some(key) = key else {
5080×
        anyhow::bail!("no identity configured; run `oxy identity create`");
509
    };
5101×
    let id = repo.identity(&key)?;
5111×
    let ext = if pem { "pem" } else { "identity" };
5121×
    let path = path.unwrap_or_else(|| PathBuf::from(format!("{}.{ext}", key.short(16))));
5131×
    if pem {
5141×
        std::fs::write(&path, oxy_core::dsse::public_pem(&id))?;
515
    } else {
5160×
        std::fs::write(&path, id.encode())?;
517
    }
5181×
    println!(
519
        "{} {} {} <{}> ({}) to {}",
5201×
        ui::done("Wrote"),
5211×
        if pem { "the public key of" } else { "identity" },
5221×
        ui::author(&id.name),
523
        id.email,
5241×
        key.short(12),
5251×
        ui::path(path.display())
526
    );
5271×
    Ok(0)
5281×
}
529
5300×
pub fn rotate() -> Result<i32> {
5310×
    let mut repo = open()?;
5320×
    let r = repo.rotate_identity()?;
5330×
    report_rotation(&repo, &r, "Rotated")?;
5340×
    Ok(0)
5350×
}
536
5370×
pub fn renew(expires: u64) -> Result<i32> {
5380×
    let mut repo = open()?;
5390×
    let r = repo.renew_identity(expiry_from_days(expires))?;
5400×
    report_rotation(&repo, &r, "Renewed")?;
5410×
    Ok(0)
5420×
}
543
5440×
pub fn revoke(key: Option<&str>, reason: &str) -> Result<i32> {
5450×
    let repo = open()?;
5460×
    let key = match key {
5470×
        Some(k) => resolve_key(&repo, k)?,
5480×
        None => repo
5490×
            .config
5500×
            .author_key
5510×
            .ok_or_else(|| anyhow::anyhow!("no identity configured"))?,
552
    };
5530×
    let update = repo.revoke_identity(&key, reason)?;
5540×
    println!(
555
        "{} key {}{}",
5560×
        ui::done("Revoked"),
5570×
        ui::hash(key.short(12)),
5580×
        if reason.is_empty() {
5590×
            String::new()
560
        } else {
5610×
            format!(": {reason}")
562
        }
563
    );
5640×
    match update {
5650×
        Some(u) => report_update(&u, None)?,
5660×
        None => println!(
567
            "  {} {}",
5680×
            ui::note(),
5690×
            ui::dim("recorded locally; no policy named the key")
570
        ),
571
    }
572
    // A revoked delegate comes off the identity too, so the successor no
573
    // longer names it (spec §14.2).
5740×
    let own = repo.config.author_key;
5750×
    let delegated = own.is_some_and(|o| {
5760×
        repo.identity(&o)
5770×
            .is_ok_and(|id| id.delegate(&key).is_some())
5780×
    });
5790×
    if delegated {
5800×
        let mut repo = repo;
5810×
        let r = repo.delegate_remove(&key)?;
5820×
        report_rotation(&repo, &r, "Undelegated")?;
5830×
        return Ok(0);
5840×
    }
5850×
    if repo.config.author_key == Some(key) {
5860×
        println!(
5870×
            "  {} {}",
5880×
            ui::caution(),
5890×
            ui::warn("this was your configured key; run oxy identity create or rotate")
5900×
        );
5910×
    }
5920×
    Ok(0)
5930×
}
594
5950×
pub fn delegate_add(identity_files: &[PathBuf], scope: &str, expires: Option<&str>) -> Result<i32> {
5960×
    let mut repo = open()?;
5970×
    let ids = identity_files
5980×
        .iter()
5990×
        .map(|f| {
6000×
            Identity::decode(
6010×
                &std::fs::read(f).map_err(|e| anyhow::anyhow!("{}: {e}", f.display()))?,
602
            )
6030×
            .map_err(Into::into)
6040×
        })
6050×
        .collect::<Result<Vec<_>>>()?;
6060×
    let scope = Scope::parse(scope).map_err(|e| anyhow::anyhow!("{e}"))?;
6070×
    let expires = expires.map(expiry_from_duration).transpose()?;
6080×
    let r = repo.delegate_add_many(ids, scope, expires)?;
6090×
    report_rotation(&repo, &r, "Delegated")?;
6100×
    println!(
611
        "  {} {}",
6120×
        ui::note(),
6130×
        ui::dim("an agent configured with your author.key needs the new key: oxy config --user author.key NEW in its home")
614
    );
6150×
    if !scope.has(Scope::REFS) {
6160×
        println!(
6170×
            "  {} {}",
6180×
            ui::note(),
6190×
            ui::dim("it may not move references: a push or tag it attests is refused")
6200×
        );
6210×
    }
6220×
    Ok(0)
6230×
}
624
6250×
pub fn delegate_remove(key: &str) -> Result<i32> {
6260×
    let mut repo = open()?;
6270×
    let k = resolve_key(&repo, key)?;
6280×
    let r = repo.delegate_remove(&k)?;
6290×
    report_rotation(&repo, &r, "Undelegated")?;
6300×
    Ok(0)
6310×
}
632
6330×
pub fn delegate_list() -> Result<i32> {
6340×
    let repo = open()?;
6350×
    let Some(key) = repo.config.author_key else {
6360×
        println!("No identity configured; run `oxy identity create`.");
6370×
        return Ok(0);
638
    };
6390×
    let id = repo.identity(&key)?;
6400×
    if id.delegates.is_empty() {
6410×
        println!("{}", ui::dim("No delegates."));
6420×
    }
6430×
    let now = Hlc::now().wall_ms;
6440×
    for d in &id.delegates {
6450×
        let who = repo
6460×
            .identity(&d.key)
6470×
            .map(|i| format!("{} <{}>", i.name, i.email))
6480×
            .unwrap_or_else(|_| "(unknown identity)".into());
6490×
        let until = match d.expires {
6500×
            Some(e) if now > e => ui::err(format!("expired {}", ui::ago(e))),
6510×
            Some(e) => ui::dim(format!("until {}", when(e))),
6520×
            None => String::new(),
653
        };
6540×
        println!(
655
            "  {} {}  {}  {} {}",
6560×
            ui::ok(ui::glyph("🔑", "*")),
6570×
            ui::hash(d.key.short(12)),
6580×
            ui::author(who),
6590×
            ui::dim(d.scope.to_string()),
660
            until
661
        );
662
    }
6630×
    Ok(0)
6640×
}
665
6660×
pub fn policy_root_add(identity_file: &Path, out: Option<PathBuf>) -> Result<i32> {
6670×
    let repo = open()?;
6680×
    let id = Identity::decode(&std::fs::read(identity_file)?)?;
6690×
    let key = id.hash();
6700×
    let u = repo.policy_root_add(id.clone())?;
6710×
    println!(
672
        "{} root {} <{}> ({})",
6730×
        ui::done("Added"),
6740×
        ui::author(&id.name),
675
        id.email,
6760×
        key.short(12)
677
    );
6780×
    report_update(&u, out)?;
6790×
    Ok(0)
6800×
}
681
6820×
pub fn policy_root_remove(key: &str, out: Option<PathBuf>) -> Result<i32> {
6830×
    let repo = open()?;
6840×
    let k = resolve_key(&repo, key)?;
6850×
    let u = repo.policy_root_remove(&k)?;
6860×
    println!("{} root {}", ui::done("Removed"), ui::hash(k.short(12)));
6870×
    report_update(&u, out)?;
6880×
    Ok(0)
6890×
}
690
6910×
pub fn policy_threshold(n: u32, out: Option<PathBuf>) -> Result<i32> {
6920×
    let repo = open()?;
6930×
    let u = repo.policy_set_threshold(n)?;
6940×
    println!("{} root threshold to {n}", ui::done("Set"));
6950×
    report_update(&u, out)?;
6960×
    Ok(0)
6970×
}
698
6990×
pub fn policy_sign(proposal: &Path, out: Option<PathBuf>) -> Result<i32> {
7000×
    let repo = open()?;
7010×
    let sp = SignedPolicy::decode(&std::fs::read(proposal)?)?;
7020×
    let u = repo.policy_sign(sp)?;
7030×
    println!("{} policy serial {}", ui::done("Signed"), u.policy.serial);
7040×
    report_update(&u, Some(out.unwrap_or_else(|| proposal.to_path_buf())))?;
7050×
    Ok(0)
7060×
}
707
7081×
pub fn policy_init() -> Result<i32> {
7091×
    let repo = open()?;
7101×
    let sp = repo.policy_init()?;
7111×
    println!(
712
        "{} policy with root key {}",
7131×
        ui::done("Created"),
7141×
        sp.policy.roots[0].short(12)
715
    );
7161×
    report_update(&sp, None)?;
7171×
    Ok(0)
7181×
}
719
7203×
pub fn policy_show(as_json: bool) -> Result<i32> {
7213×
    let repo = open()?;
7223×
    let Some((r, sp)) = repo.current_policy()? else {
7231×
        if as_json {
7241×
            println!("null");
7251×
        } else {
7260×
            println!(
7270×
                "No policy: this repository is unverified. Run `oxy policy init` to create one."
7280×
            );
7290×
        }
7301×
        return Ok(0);
731
    };
7322×
    let p = &sp.policy;
7332×
    if as_json {
7345×
        let ident = |k: &oxy_core::hash::KeyId| {
7355×
            let (name, email) = p
7365×
                .identity(k)
7375×
                .map(|i| (i.name.clone(), i.email.clone()))
7385×
                .unwrap_or_default();
7395×
            json::obj([
7405×
                ("key", json::s(&k.to_hex())),
7415×
                ("name", json::s(&name)),
7425×
                ("email", json::s(&email)),
7435×
            ])
7445×
        };
7452×
        println!(
746
            "{}",
7472×
            json::obj([
7482×
                ("hash", json::s(&r.to_hex())),
7492×
                ("serial", json::n(p.serial)),
7502×
                ("expires_ms", json::n(p.expires)),
7512×
                (
7522×
                    "expired",
7532×
                    json::b(p.is_expired(oxy_core::objects::Hlc::now().wall_ms))
7542×
                ),
7552×
                ("root_threshold", json::n(p.root_threshold)),
7562×
                ("roots", json::arr(p.roots.iter().map(ident))),
757
                (
7582×
                    "rules",
7592×
                    json::arr(p.rules.iter().map(|rule| {
7602×
                        json::obj([
7612×
                            ("pattern", json::s(&rule.pattern)),
7622×
                            ("keys", json::arr(rule.keys.iter().map(ident))),
7632×
                            ("threshold", json::n(rule.threshold)),
7642×
                            ("require", json::s(rule.require.name())),
7652×
                            ("allow_conflicts", json::b(rule.allow_conflicts)),
766
                            (
7672×
                                "subjects",
7682×
                                json::arr(rule.subjects.iter().map(|g| {
7691×
                                    json::obj([
7701×
                                        ("subject", json::s(&g.pattern)),
7711×
                                        ("issuer", ident(&g.issuer)),
7721×
                                        ("scope", json::s(&g.scope.to_string())),
7731×
                                    ])
7741×
                                })),
775
                            ),
776
                        ])
7772×
                    })),
778
                ),
7792×
                ("revoked", json::arr(p.revoked.iter().map(ident))),
780
            ])
781
        );
7822×
        return Ok(0);
7830×
    }
7840×
    let name = |k: &oxy_core::hash::KeyId| {
7850×
        p.identity(k)
7860×
            .map(|i| format!("{} <{}>", i.name, i.email))
7870×
            .unwrap_or_else(|| "(unknown identity)".into())
7880×
    };
7890×
    let expired = p.is_expired(oxy_core::objects::Hlc::now().wall_ms);
7900×
    println!(
791
        "{} {}{}serial {}{}expires {}{}",
7920×
        ui::section("policy"),
7930×
        ui::hash(format!("#{}", r.short(12))),
7940×
        ui::sep(),
795
        p.serial,
7960×
        ui::sep(),
7970×
        when(p.expires),
7980×
        if expired {
7990×
            ui::err(format!("  {} EXPIRED", ui::glyph("✖", "x")))
800
        } else {
8010×
            String::new()
802
        }
803
    );
8040×
    println!(
805
        "{}",
8060×
        ui::section(format!(
807
            "roots {}",
8080×
            ui::dim(format!("(threshold {})", p.root_threshold))
809
        ))
810
    );
8110×
    for k in &p.roots {
8120×
        println!(
8130×
            "  {} {}  {}",
8140×
            ui::ok(ui::glyph("🔑", "*")),
8150×
            ui::hash(k.short(12)),
8160×
            ui::author(name(k))
8170×
        );
8180×
    }
8190×
    println!("{}", ui::section("rules"));
8200×
    for rule in &p.rules {
8210×
        println!(
822
            "  {} {:12} {}",
8230×
            ui::ok(ui::glyph("▸", ">")),
8240×
            ui::bookmark(&rule.pattern),
8250×
            ui::dim(format!(
826
                "threshold {}, require {}, conflicts {}",
827
                rule.threshold,
8280×
                rule.require.name(),
8290×
                if rule.allow_conflicts {
8300×
                    "allowed"
831
                } else {
8320×
                    "refused"
833
                }
834
            ))
835
        );
8360×
        for k in &rule.keys {
8370×
            println!("      {}  {}", ui::hash(k.short(12)), ui::author(name(k)));
8380×
        }
8390×
        for g in &rule.subjects {
8400×
            println!(
8410×
                "      {}  {} {}",
8420×
                ui::author(&g.pattern),
8430×
                ui::dim(format!(
8440×
                    "certified by {} ({}),",
8450×
                    name(&g.issuer),
8460×
                    g.issuer.short(12)
8470×
                )),
8480×
                ui::dim(format!("scope {}", g.scope))
8490×
            );
8500×
        }
851
    }
8520×
    if !p.revoked.is_empty() {
8530×
        println!("{}", ui::section("revoked"));
8540×
        for k in &p.revoked {
8550×
            println!(
8560×
                "  {} {}  {}",
8570×
                ui::err(ui::glyph("✖", "x")),
8580×
                ui::hash(k.short(12)),
8590×
                ui::author(name(k))
8600×
            );
8610×
        }
8620×
    }
8630×
    Ok(0)
8643×
}
865
8660×
pub fn policy_grant(
8670×
    identity_file: &Path,
8680×
    pattern: Option<&str>,
8690×
    out: Option<PathBuf>,
8700×
) -> Result<i32> {
8710×
    let repo = open()?;
8720×
    let id = Identity::decode(&std::fs::read(identity_file)?)?;
8730×
    let key = id.hash();
8740×
    let u = repo.policy_grant(id.clone(), pattern)?;
8750×
    println!(
876
        "{} {} <{}> ({}) on {}",
8770×
        ui::done("Granted"),
8780×
        ui::author(&id.name),
879
        id.email,
8800×
        key.short(12),
8810×
        ui::bookmark(pattern.unwrap_or("*"))
882
    );
8830×
    report_update(&u, out)?;
8840×
    Ok(0)
8850×
}
886
8871×
pub fn policy_grant_subject(
8881×
    subject: &str,
8891×
    issuer_file: &Path,
8901×
    scope: &str,
8911×
    pattern: Option<&str>,
8921×
    out: Option<PathBuf>,
8931×
) -> Result<i32> {
8941×
    let repo = open()?;
8951×
    let issuer = Identity::decode(&std::fs::read(issuer_file)?)
8961×
        .map_err(|e| anyhow::anyhow!("{}: not an identity file: {e}", issuer_file.display()))?;
8971×
    let scope = oxy_core::identity::Scope::parse(scope).map_err(|e| anyhow::anyhow!(e))?;
8981×
    let key = issuer.hash();
8991×
    let u = repo.policy_grant_subject(subject, issuer.clone(), scope, pattern)?;
9001×
    println!(
901
        "{} {} certified by {} ({}) on {}, scope {}",
9021×
        ui::done("Granted"),
9031×
        ui::author(subject),
904
        issuer.name,
9051×
        key.short(12),
9061×
        ui::bookmark(pattern.unwrap_or("*")),
907
        scope
908
    );
9091×
    report_update(&u, out)?;
9101×
    Ok(0)
9111×
}
912
9130×
pub fn policy_revoke(key: &str, out: Option<PathBuf>) -> Result<i32> {
9140×
    let repo = open()?;
9150×
    if repo.current_policy()?.is_none() {
9160×
        anyhow::bail!("this repository has no policy");
9170×
    }
9180×
    let full = resolve_key(&repo, key)?;
9190×
    let u = repo.policy_revoke(&full)?;
9200×
    println!("{} {}", ui::done("Revoked"), ui::hash(full.short(12)));
9210×
    report_update(&u, out)?;
9220×
    Ok(0)
9230×
}
924
925
/// `oxy seal [CHANGE..]`: seal the named changes, or every sealable one.
9260×
pub fn seal(specs: &[String], as_json: bool) -> Result<i32> {
9270×
    let repo = Repo::open(&std::env::current_dir()?)?;
9280×
    let hashes: Vec<_> = if specs.is_empty() {
9290×
        repo.sealable()?
930
    } else {
9310×
        specs
9320×
            .iter()
9330×
            .map(|s| repo.resolve_change(s))
9340×
            .collect::<std::result::Result<_, _>>()?
935
    };
9360×
    if hashes.is_empty() {
9370×
        if as_json {
9380×
            println!("{{\"sealed\":[]}}");
9390×
        } else {
9400×
            println!(
9410×
                "{} nothing to seal: every change you can authenticate already is",
9420×
                ui::done("Sealed")
9430×
            );
9440×
        }
9450×
        return Ok(0);
9460×
    }
9470×
    let mut refs = Vec::new();
9480×
    for chunk in hashes.chunks(oxy_core::attest::MAX_SEAL_HASHES) {
9490×
        refs.push(repo.seal(chunk)?);
950
    }
9510×
    if as_json {
9520×
        let list: Vec<String> = hashes
9530×
            .iter()
9540×
            .map(|h| format!("\"{}\"", h.to_hex()))
9550×
            .collect();
9560×
        let seals: Vec<String> = refs.iter().map(|r| format!("\"{}\"", r.to_hex())).collect();
9570×
        println!(
958
            "{{\"sealed\":[{}],\"seals\":[{}]}}",
9590×
            list.join(","),
9600×
            seals.join(",")
961
        );
962
    } else {
9630×
        println!(
964
            "{} {} change{} in {} seal{}; they travel with your next push",
9650×
            ui::done("Sealed"),
9660×
            hashes.len(),
9670×
            if hashes.len() == 1 { "" } else { "s" },
9680×
            refs.len(),
9690×
            if refs.len() == 1 { "" } else { "s" }
970
        );
9710×
        for h in &hashes {
9720×
            let title = repo
9730×
                .store()
9740×
                .get_change(h)
9750×
                .map(|c| c.message.lines().next().unwrap_or("").to_string())
9760×
                .unwrap_or_default();
9770×
            println!("  {} {}", ui::hash(h.short(12)), title);
978
        }
979
    }
9800×
    Ok(0)
9810×
}
982
983
/// `oxy identity sign-challenge HEX`: the composite signature over a
984
/// host's challenge, as hex of the CBOR signature (hub spec §5.3).
9850×
pub fn sign_challenge(challenge_hex: &str, as_json: bool) -> Result<i32> {
9860×
    let repo = open()?;
9870×
    let Some((key, sk)) = repo.secret_key()? else {
9880×
        anyhow::bail!("no secret key: run `oxy identity create` first");
989
    };
9900×
    let challenge = hex_bytes(challenge_hex.trim())
9910×
        .ok_or_else(|| anyhow::anyhow!("the challenge must be hex"))?;
9920×
    let sig = sk
9930×
        .sign_challenge(&key, &challenge)
9940×
        .map_err(|e| anyhow::anyhow!("{e}"))?;
9950×
    let encoded = hex_string(&oxy_core::identity::Signature::encode_list(&[sig]));
9960×
    if as_json {
9970×
        println!(
9980×
            "{{\"key\":\"{}\",\"signature\":\"{}\"}}",
9990×
            key.to_hex(),
10000×
            encoded
10010×
        );
10020×
    } else {
10030×
        println!("{encoded}");
10040×
    }
10050×
    Ok(0)
10060×
}
1007
10080×
fn hex_bytes(s: &str) -> Option<Vec<u8>> {
10090×
    if !s.len().is_multiple_of(2) {
10100×
        return None;
10110×
    }
10120×
    (0..s.len())
10130×
        .step_by(2)
10140×
        .map(|i| u8::from_str_radix(&s[i..i + 2], 16).ok())
10150×
        .collect()
10160×
}
1017
10180×
fn hex_string(b: &[u8]) -> String {
10190×
    b.iter().map(|x| format!("{x:02x}")).collect()
10200×
}
1021
1022
#[cfg(test)]
1023
mod tests {
1024
    #[test]
10251×
    fn secrets_are_never_printed_whole() {
10261×
        assert!(
10271×
            super::secret_key("remote.earth.token")
10281×
                && super::secret_key("auth.secret")
10291×
                && !super::secret_key("remote.earth.url")
1030
        );
10311×
        assert_eq!(
10321×
            super::mask("oxyhub_6b7dce13409b634d"),
1033
            "oxyhub_6b7…(13 more)"
1034
        );
10351×
        assert_eq!(super::mask("short"), "…");
10361×
    }
1037
}