//! 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::Identity; 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) } /// 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().copied()); } } 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(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())); if !r.identity.delegates.is_empty() { println!( "{}", ui::kv( "delegates", r.identity .delegates .iter() .map(|d| d.short(12)) .collect::>() .join(" ") ) ); } 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)))); } if !id.delegates.is_empty() { println!( "{}", ui::kv( "delegates", id.delegates .iter() .map(|d| d.short(12)) .collect::>() .join(" ") ) ); } 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>, as_json: bool) -> Result { let repo = open()?; let hashes: Vec = match change { Some(c) => vec![repo.resolve_change(c)?], 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 }) } pub fn config(key: &str, value: &str, user: bool) -> Result { if user { let path = repo::default_user_config(); repo::Config::set_user(&path, key, value)?; println!( "{} {key} = {value} {}", ui::done("Set"), ui::dim(format!("(in {})", path.display())) ); } else { let mut repo = open()?; repo.config.set(key, value)?; repo.save_config()?; println!( "{} {key} = {value} {}", ui::done("Set"), 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(&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(&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") ), } 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_file: &Path) -> Result { let mut repo = open()?; let id = Identity::decode(&std::fs::read(identity_file)?)?; let r = repo.delegate_add(id)?; report_rotation(&r, "Delegated")?; 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(&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.")); } for d in &id.delegates { let who = repo .identity(d) .map(|i| format!("{} <{}>", i.name, i.email)) .unwrap_or_else(|_| "(unknown identity)".into()); println!( " {} {} {}", ui::ok(ui::glyph("🔑", "*")), ui::hash(d.short(12)), ui::author(who) ); } 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) }