//! Identity, verification, and configuration commands. use super::format::{short_id, signature_line, when}; use super::json; use super::open; use super::ui; use anyhow::Result; use oxy_core::hash::ChangeHash; use oxy_core::hash::KeyId; use oxy_core::identity::{Delegate, Identity, Scope}; use oxy_core::objects::Hlc; use oxy_core::protocol::SignedPolicy; use oxy_core::repo::{self, IdentityScope, PolicyUpdate, Repo, Rotation, SignatureStatus}; use std::path::{Path, PathBuf}; const DAY_MS: u64 = 24 * 60 * 60 * 1000; fn expiry_from_days(days: u64) -> Option { (days > 0).then(|| Hlc::now().wall_ms + days * DAY_MS) } /// `30m`, `8h`, `7d`, or a bare number of days, as a wall time from now. fn expiry_from_duration(s: &str) -> Result { let s = s.trim(); let (n, unit) = match s.char_indices().find(|(_, c)| !c.is_ascii_digit()) { Some((i, _)) => (&s[..i], &s[i..]), None => (s, "d"), }; let n: u64 = n.parse().map_err(|_| { anyhow::anyhow!("--expires takes 30m, 8h, 7d or a number of days, not {s:?}") })?; let ms = match unit { "m" => 60_000, "h" => 3_600_000, "d" => DAY_MS, _ => anyhow::bail!("--expires takes 30m, 8h, 7d or a number of days, not {s:?}"), }; if n == 0 { anyhow::bail!("--expires needs a length of time; leave it out for no expiry"); } Ok(Hlc::now().wall_ms + n * ms) } /// Print the delegates, one per line: who, their key, what they may do /// and until when, so an agent's name is seen, not only its key. fn print_delegates(repo: &Repo, delegates: &[Delegate]) { if delegates.is_empty() { return; } println!("{}", ui::kv("delegates", delegates.len().to_string())); let now = Hlc::now().wall_ms; for d in delegates { let who = repo .identity(&d.key) .map(|i| format!("{} <{}>", i.name, i.email)) .unwrap_or_else(|_| "(unknown identity)".into()); let until = match d.expires { Some(e) if now > e => ui::err(format!("expired {}", ui::ago(e))), Some(e) => ui::dim(format!("until {}", when(e))), None => String::new(), }; println!( " {} {} {} {}", ui::author(who), ui::hash(d.key.short(12)), ui::dim(d.scope.to_string()), until ); } } /// Resolve a KeyId prefix among the identities the repository knows. fn resolve_key(repo: &Repo, prefix: &str) -> Result { if let Some(k) = KeyId::from_hex(prefix) { return Ok(k); } let mut candidates: Vec = Vec::new(); if let Some((_, sp)) = repo.current_policy()? { candidates.extend(sp.policy.identities.iter().map(|i| i.hash())); candidates.extend(sp.policy.revoked.iter().copied()); } if let Some(own) = repo.config.author_key { candidates.push(own); if let Ok(id) = repo.identity(&own) { candidates.extend(id.delegates.iter().map(|d| d.key)); } } candidates.sort(); candidates.dedup(); let matches: Vec<_> = candidates .into_iter() .filter(|k| k.starts_with_hex(prefix)) .collect(); match matches.len() { 1 => Ok(matches[0]), 0 => anyhow::bail!("no known key starts with {prefix:?}"), _ => anyhow::bail!("{prefix:?} is ambiguous"), } } /// Report a policy update: applied, or written out as a proposal that /// other root holders sign with `oxy policy sign`. fn report_update(u: &PolicyUpdate, out: Option) -> Result<()> { if u.applied { println!( "{} policy serial {} {}", ui::done("Applied"), u.policy.serial, ui::dim("(push to publish it)") ); return Ok(()); } let path = out.unwrap_or_else(|| PathBuf::from(format!("policy-{}.proposal", u.policy.serial))); std::fs::write(&path, u.signed().encode())?; println!( "{} policy serial {} needs {} of {} root signatures; has {}. Wrote {} for another root to run {}", ui::caution(), u.policy.serial, u.policy.root_threshold, u.policy.roots.len(), u.sigs.len(), ui::path(path.display()), ui::dim(format!("oxy policy sign {}", path.display())) ); Ok(()) } fn report_rotation(repo: &Repo, r: &Rotation, verb: &str) -> Result<()> { println!( "{} identity {} <{}>", ui::done(verb), ui::author(&r.identity.name), r.identity.email ); println!("{}", ui::kv("old key", ui::dim(r.old.to_hex()))); println!("{}", ui::kv("new key", r.new.to_hex())); print_delegates(repo, &r.identity.delegates); if let Some(e) = r.identity.expires { println!("{}", ui::kv("expires", when(e))); } match &r.policy { Some(u) => report_update(u, None)?, None => println!( " {} {}", ui::note(), ui::dim("no policy names the old key; export the new identity for policy holders") ), } Ok(()) } pub fn create( name: Option, email: Option, local: bool, expires: Option, ) -> Result { let mut repo = open()?; let name = name.unwrap_or_else(|| repo.config.author_name.clone()); let email = email.unwrap_or_else(|| repo.config.author_email.clone()); let scope = if local { IdentityScope::Repo } else { IdentityScope::User }; let (key, _) = repo.create_identity_with(&name, &email, scope, expires.and_then(expiry_from_days))?; println!( "{} identity {} <{email}>", ui::done("Created"), ui::author(&name) ); println!("{}", ui::kv("key", key)); println!( "{}", ui::kv("secret", ui::path(repo::default_keys_dir().display())) ); match scope { IdentityScope::User => println!( "{}", ui::kv( "scope", format!( "default author in {}", ui::path(repo.user_config_path().display()) ) ) ), IdentityScope::Repo => { println!("{}", ui::kv("scope", "this repository's .oxy/config only")) } } if !repo.config.sign_changes { println!(" {} {}", ui::note(), ui::dim("changes will carry this author; run oxy config --user core.sign change to sign each commit")); } Ok(0) } pub fn show() -> Result { let repo = open()?; let Some(key) = repo.config.author_key else { println!("No identity configured; run `oxy identity create`."); return Ok(0); }; let id = repo.identity(&key)?; println!( "{} {} <{}>", ui::section("identity"), ui::author(&id.name), id.email ); println!("{}", ui::kv("key", key)); println!( "{}", ui::kv( "created", format!( "{} {}", when(id.created), ui::dim(format!("({})", ui::ago(id.created))) ) ) ); if let Some(e) = id.expires { let expired = id.is_expired_at(Hlc::now().wall_ms); println!( "{}", ui::kv( "expires", if expired { ui::err(format!("{} EXPIRED", when(e))) } else { when(e) } ) ); } if let Some(p) = id.previous { println!("{}", ui::kv("previous", ui::dim(p.short(12)))); } // A repository that pins one key while the user file names another is // how checkouts drift apart after a rotation (oxygen#69). if let Some(pinned) = repo .config .repo_layer() .get("author.key") .and_then(|k| oxy_core::hash::KeyId::from_hex(k)) { let user_key = repo::read_config_file(&repo::default_user_config()) .ok() .and_then(|m| { m.get("author.key") .and_then(|k| oxy_core::hash::KeyId::from_hex(k)) }); if let Some(u) = user_key.filter(|u| *u != pinned) { let related = |from: oxy_core::hash::KeyId, to: oxy_core::hash::KeyId| { let mut cur = Some(from); for _ in 0..64 { match cur { Some(k) if k == to => return true, Some(k) => cur = repo.identity(&k).ok().and_then(|i| i.previous), None => break, } } false }; let how = if related(pinned, u) { "an older key of this identity" } else if related(u, pinned) { "a newer key of this identity" } else { "another key" }; println!( " {} {}", ui::caution(), ui::dim(format!( "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 {}`", pinned.short(12), u.short(12), pinned.to_hex() )) ); } } print_delegates(&repo, &id.delegates); if repo.is_revoked(&key)? { println!( "{}", ui::kv( "status", ui::err(format!("{} REVOKED", ui::glyph("✖", "x"))) ) ); } println!( "{}", ui::kv( "secret", if repo.secret_key()?.is_some() { ui::ok(format!("{} available", ui::glyph("✔", "*"))) } else { ui::err(format!( "{} not found in the key directory", ui::glyph("✖", "x") )) } ) ); println!( "{}", ui::kv( "signing", if repo.config.sign_changes { "every change (core.sign = change)" } else { "at push via seals (core.sign = seal)" } ) ); Ok(0) } pub fn verify(change: Option<&str>, revset: Option<&str>, as_json: bool) -> Result { let repo = open()?; let hashes: Vec = match (change, revset) { (_, Some(r)) => { let wc = repo.working_copy().ok().map(|(h, _)| h); repo.revset(r)? .hashes .into_iter() .filter(|h| Some(*h) != wc) .collect() } (Some(c), None) => vec![repo.resolve_change(c)?], (None, None) => repo .log()? .into_iter() .filter(|e| !e.is_working_copy) .map(|e| e.hash) .collect(), }; let mut bad = 0; if as_json { let mut items = Vec::new(); for h in &hashes { let Ok(c) = repo.store().get_change(h) else { continue; }; let (status, key, name, revoked, reason) = match repo.authenticate_change(h)? { SignatureStatus::Unsigned => ("unsigned", None, None, false, None), SignatureStatus::Valid { key, name, sealed, revoked, .. } => ( if sealed { "sealed" } else { "signed" }, Some(key.to_hex()), Some(name), revoked, None, ), SignatureStatus::Invalid(why) => { bad += 1; ("invalid", None, None, false, Some(why)) } }; items.push(json::obj([ ("hash", json::s(&h.to_hex())), ("change_id", json::s(&c.change_id.to_hex())), ("status", json::s(status)), ("key", json::opt(key.as_deref(), json::s)), ("name", json::opt(name.as_deref(), json::s)), ("revoked", json::b(revoked)), ("reason", json::opt(reason.as_deref(), json::s)), ])); } println!("{}", json::arr(items)); return Ok(if bad > 0 { 1 } else { 0 }); } for h in hashes { let Ok(c) = repo.store().get_change(&h) else { println!( "{} {}", ui::hash(format!("#{}", h.short(12))), ui::dim("not present (shallow)") ); continue; }; let line = signature_line(&repo, &h)?; let styled = if line.starts_with("INVALID") { bad += 1; ui::err(format!("{} {line}", ui::glyph("✖", "x"))) } else if line == "unsigned" { ui::dim(format!("{} {line}", ui::glyph("·", "-"))) } else { ui::ok(format!("{} {line}", ui::glyph("✔", "v"))) }; println!( "{} {} {styled}", ui::id(short_id(&c)), ui::hash(format!("#{}", h.short(12))) ); } Ok(if bad > 0 { 1 } else { 0 }) } /// Whether a configuration key holds a secret, never to be printed. fn secret_key(key: &str) -> bool { let k = key.to_ascii_lowercase(); k.ends_with(".token") || k.contains("secret") || k.contains("password") } /// A secret as a confirmation may show it (oxygen#113): enough of its start /// to tell which one it is, never the rest. fn mask(value: &str) -> String { let shown: String = value.chars().take(10).collect(); let rest = value.chars().count().saturating_sub(10); if rest == 0 { "…".to_string() } else { format!("{shown}…({rest} more)") } } pub fn config(key: &str, value: &str, user: bool, unset: bool) -> Result { // An empty value removes the key, which is what --unset says plainly. // `-` reads the value from standard input, so a token never sits in // the shell's history (oxygen#113). let read; let value = if unset { "" } else if value == "-" { let mut line = String::new(); std::io::stdin().read_line(&mut line)?; read = line.trim().to_string(); read.as_str() } else { value }; let said = if unset { format!("{} {key}", ui::done("Unset")) } else if secret_key(key) { format!("{} {key} = {}", ui::done("Set"), mask(value)) } else { format!("{} {key} = {value}", ui::done("Set")) }; if user { let path = repo::default_user_config(); repo::Config::set_user(&path, key, value)?; println!("{said} {}", ui::dim(format!("(in {})", path.display()))); } else { let mut repo = open()?; repo.config.set(key, value)?; repo.save_config()?; println!("{said} {}", ui::dim("(in this repository)")); } Ok(0) } pub fn export(path: Option) -> Result { let repo = open()?; let Some(key) = repo.config.author_key else { anyhow::bail!("no identity configured; run `oxy identity create`"); }; let id = repo.identity(&key)?; let path = path.unwrap_or_else(|| PathBuf::from(format!("{}.identity", key.short(16)))); std::fs::write(&path, id.encode())?; println!( "{} identity {} <{}> ({}) to {}", ui::done("Wrote"), ui::author(&id.name), id.email, key.short(12), ui::path(path.display()) ); Ok(0) } pub fn rotate() -> Result { let mut repo = open()?; let r = repo.rotate_identity()?; report_rotation(&repo, &r, "Rotated")?; Ok(0) } pub fn renew(expires: u64) -> Result { let mut repo = open()?; let r = repo.renew_identity(expiry_from_days(expires))?; report_rotation(&repo, &r, "Renewed")?; Ok(0) } pub fn revoke(key: Option<&str>, reason: &str) -> Result { let repo = open()?; let key = match key { Some(k) => resolve_key(&repo, k)?, None => repo .config .author_key .ok_or_else(|| anyhow::anyhow!("no identity configured"))?, }; let update = repo.revoke_identity(&key, reason)?; println!( "{} key {}{}", ui::done("Revoked"), ui::hash(key.short(12)), if reason.is_empty() { String::new() } else { format!(": {reason}") } ); match update { Some(u) => report_update(&u, None)?, None => println!( " {} {}", ui::note(), ui::dim("recorded locally; no policy named the key") ), } // A revoked delegate comes off the identity too, so the successor no // longer names it (spec §14.2). let own = repo.config.author_key; let delegated = own.is_some_and(|o| { repo.identity(&o) .is_ok_and(|id| id.delegate(&key).is_some()) }); if delegated { let mut repo = repo; let r = repo.delegate_remove(&key)?; report_rotation(&repo, &r, "Undelegated")?; return Ok(0); } if repo.config.author_key == Some(key) { println!( " {} {}", ui::caution(), ui::warn("this was your configured key; run oxy identity create or rotate") ); } Ok(0) } pub fn delegate_add(identity_files: &[PathBuf], scope: &str, expires: Option<&str>) -> Result { let mut repo = open()?; let ids = identity_files .iter() .map(|f| { Identity::decode( &std::fs::read(f).map_err(|e| anyhow::anyhow!("{}: {e}", f.display()))?, ) .map_err(Into::into) }) .collect::>>()?; let scope = Scope::parse(scope).map_err(|e| anyhow::anyhow!("{e}"))?; let expires = expires.map(expiry_from_duration).transpose()?; let r = repo.delegate_add_many(ids, scope, expires)?; report_rotation(&repo, &r, "Delegated")?; println!( " {} {}", ui::note(), ui::dim("an agent configured with your author.key needs the new key: oxy config --user author.key NEW in its home") ); if !scope.has(Scope::REFS) { println!( " {} {}", ui::note(), ui::dim("it may not move references: a push or tag it attests is refused") ); } Ok(0) } pub fn delegate_remove(key: &str) -> Result { let mut repo = open()?; let k = resolve_key(&repo, key)?; let r = repo.delegate_remove(&k)?; report_rotation(&repo, &r, "Undelegated")?; Ok(0) } pub fn delegate_list() -> Result { let repo = open()?; let Some(key) = repo.config.author_key else { println!("No identity configured; run `oxy identity create`."); return Ok(0); }; let id = repo.identity(&key)?; if id.delegates.is_empty() { println!("{}", ui::dim("No delegates.")); } let now = Hlc::now().wall_ms; for d in &id.delegates { let who = repo .identity(&d.key) .map(|i| format!("{} <{}>", i.name, i.email)) .unwrap_or_else(|_| "(unknown identity)".into()); let until = match d.expires { Some(e) if now > e => ui::err(format!("expired {}", ui::ago(e))), Some(e) => ui::dim(format!("until {}", when(e))), None => String::new(), }; println!( " {} {} {} {} {}", ui::ok(ui::glyph("🔑", "*")), ui::hash(d.key.short(12)), ui::author(who), ui::dim(d.scope.to_string()), until ); } Ok(0) } pub fn policy_root_add(identity_file: &Path, out: Option) -> Result { let repo = open()?; let id = Identity::decode(&std::fs::read(identity_file)?)?; let key = id.hash(); let u = repo.policy_root_add(id.clone())?; println!( "{} root {} <{}> ({})", ui::done("Added"), ui::author(&id.name), id.email, key.short(12) ); report_update(&u, out)?; Ok(0) } pub fn policy_root_remove(key: &str, out: Option) -> Result { let repo = open()?; let k = resolve_key(&repo, key)?; let u = repo.policy_root_remove(&k)?; println!("{} root {}", ui::done("Removed"), ui::hash(k.short(12))); report_update(&u, out)?; Ok(0) } pub fn policy_threshold(n: u32, out: Option) -> Result { let repo = open()?; let u = repo.policy_set_threshold(n)?; println!("{} root threshold to {n}", ui::done("Set")); report_update(&u, out)?; Ok(0) } pub fn policy_sign(proposal: &Path, out: Option) -> Result { let repo = open()?; let sp = SignedPolicy::decode(&std::fs::read(proposal)?)?; let u = repo.policy_sign(sp)?; println!("{} policy serial {}", ui::done("Signed"), u.policy.serial); report_update(&u, Some(out.unwrap_or_else(|| proposal.to_path_buf())))?; Ok(0) } pub fn policy_init() -> Result { let repo = open()?; let sp = repo.policy_init()?; println!( "{} policy with root key {}", ui::done("Created"), sp.policy.roots[0].short(12) ); report_update(&sp, None)?; Ok(0) } pub fn policy_show(as_json: bool) -> Result { let repo = open()?; let Some((r, sp)) = repo.current_policy()? else { if as_json { println!("null"); } else { println!( "No policy: this repository is unverified. Run `oxy policy init` to create one." ); } return Ok(0); }; let p = &sp.policy; if as_json { let ident = |k: &oxy_core::hash::KeyId| { let (name, email) = p .identity(k) .map(|i| (i.name.clone(), i.email.clone())) .unwrap_or_default(); json::obj([ ("key", json::s(&k.to_hex())), ("name", json::s(&name)), ("email", json::s(&email)), ]) }; println!( "{}", json::obj([ ("hash", json::s(&r.to_hex())), ("serial", json::n(p.serial)), ("expires_ms", json::n(p.expires)), ( "expired", json::b(p.is_expired(oxy_core::objects::Hlc::now().wall_ms)) ), ("root_threshold", json::n(p.root_threshold)), ("roots", json::arr(p.roots.iter().map(ident))), ( "rules", json::arr(p.rules.iter().map(|rule| { json::obj([ ("pattern", json::s(&rule.pattern)), ("keys", json::arr(rule.keys.iter().map(ident))), ("threshold", json::n(rule.threshold)), ("require", json::s(rule.require.name())), ("allow_conflicts", json::b(rule.allow_conflicts)), ]) })), ), ("revoked", json::arr(p.revoked.iter().map(ident))), ]) ); return Ok(0); } let name = |k: &oxy_core::hash::KeyId| { p.identity(k) .map(|i| format!("{} <{}>", i.name, i.email)) .unwrap_or_else(|| "(unknown identity)".into()) }; let expired = p.is_expired(oxy_core::objects::Hlc::now().wall_ms); println!( "{} {}{}serial {}{}expires {}{}", ui::section("policy"), ui::hash(format!("#{}", r.short(12))), ui::sep(), p.serial, ui::sep(), when(p.expires), if expired { ui::err(format!(" {} EXPIRED", ui::glyph("✖", "x"))) } else { String::new() } ); println!( "{}", ui::section(format!( "roots {}", ui::dim(format!("(threshold {})", p.root_threshold)) )) ); for k in &p.roots { println!( " {} {} {}", ui::ok(ui::glyph("🔑", "*")), ui::hash(k.short(12)), ui::author(name(k)) ); } println!("{}", ui::section("rules")); for rule in &p.rules { println!( " {} {:12} {}", ui::ok(ui::glyph("▸", ">")), ui::bookmark(&rule.pattern), ui::dim(format!( "threshold {}, require {}, conflicts {}", rule.threshold, rule.require.name(), if rule.allow_conflicts { "allowed" } else { "refused" } )) ); for k in &rule.keys { println!(" {} {}", ui::hash(k.short(12)), ui::author(name(k))); } } if !p.revoked.is_empty() { println!("{}", ui::section("revoked")); for k in &p.revoked { println!( " {} {} {}", ui::err(ui::glyph("✖", "x")), ui::hash(k.short(12)), ui::author(name(k)) ); } } Ok(0) } pub fn policy_grant( identity_file: &Path, pattern: Option<&str>, out: Option, ) -> Result { let repo = open()?; let id = Identity::decode(&std::fs::read(identity_file)?)?; let key = id.hash(); let u = repo.policy_grant(id.clone(), pattern)?; println!( "{} {} <{}> ({}) on {}", ui::done("Granted"), ui::author(&id.name), id.email, key.short(12), ui::bookmark(pattern.unwrap_or("*")) ); report_update(&u, out)?; Ok(0) } pub fn policy_revoke(key: &str, out: Option) -> Result { let repo = open()?; if repo.current_policy()?.is_none() { anyhow::bail!("this repository has no policy"); } let full = resolve_key(&repo, key)?; let u = repo.policy_revoke(&full)?; println!("{} {}", ui::done("Revoked"), ui::hash(full.short(12))); report_update(&u, out)?; Ok(0) } /// `oxy seal [CHANGE..]`: seal the named changes, or every sealable one. pub fn seal(specs: &[String], as_json: bool) -> Result { let repo = Repo::open(&std::env::current_dir()?)?; let hashes: Vec<_> = if specs.is_empty() { repo.sealable()? } else { specs .iter() .map(|s| repo.resolve_change(s)) .collect::>()? }; if hashes.is_empty() { if as_json { println!("{{\"sealed\":[]}}"); } else { println!( "{} nothing to seal: every change you can authenticate already is", ui::done("Sealed") ); } return Ok(0); } let mut refs = Vec::new(); for chunk in hashes.chunks(oxy_core::attest::MAX_SEAL_HASHES) { refs.push(repo.seal(chunk)?); } if as_json { let list: Vec = hashes .iter() .map(|h| format!("\"{}\"", h.to_hex())) .collect(); let seals: Vec = refs.iter().map(|r| format!("\"{}\"", r.to_hex())).collect(); println!( "{{\"sealed\":[{}],\"seals\":[{}]}}", list.join(","), seals.join(",") ); } else { println!( "{} {} change{} in {} seal{}; they travel with your next push", ui::done("Sealed"), hashes.len(), if hashes.len() == 1 { "" } else { "s" }, refs.len(), if refs.len() == 1 { "" } else { "s" } ); for h in &hashes { let title = repo .store() .get_change(h) .map(|c| c.message.lines().next().unwrap_or("").to_string()) .unwrap_or_default(); println!(" {} {}", ui::hash(h.short(12)), title); } } Ok(0) } /// `oxy identity sign-challenge HEX`: the composite signature over a /// host's challenge, as hex of the CBOR signature (hub spec §5.3). pub fn sign_challenge(challenge_hex: &str, as_json: bool) -> Result { let repo = open()?; let Some((key, sk)) = repo.secret_key()? else { anyhow::bail!("no secret key: run `oxy identity create` first"); }; let challenge = hex_bytes(challenge_hex.trim()) .ok_or_else(|| anyhow::anyhow!("the challenge must be hex"))?; let sig = sk .sign_challenge(&key, &challenge) .map_err(|e| anyhow::anyhow!("{e}"))?; let encoded = hex_string(&oxy_core::identity::Signature::encode_list(&[sig])); if as_json { println!( "{{\"key\":\"{}\",\"signature\":\"{}\"}}", key.to_hex(), encoded ); } else { println!("{encoded}"); } Ok(0) } fn hex_bytes(s: &str) -> Option> { if !s.len().is_multiple_of(2) { return None; } (0..s.len()) .step_by(2) .map(|i| u8::from_str_radix(&s[i..i + 2], 16).ok()) .collect() } fn hex_string(b: &[u8]) -> String { b.iter().map(|x| format!("{x:02x}")).collect() } #[cfg(test)] mod tests { #[test] fn secrets_are_never_printed_whole() { assert!( super::secret_key("remote.earth.token") && super::secret_key("auth.secret") && !super::secret_key("remote.earth.url") ); assert_eq!( super::mask("oxyhub_6b7dce13409b634d"), "oxyhub_6b7…(13 more)" ); assert_eq!(super::mask("short"), "…"); } }