//! Identity, verification, and configuration commands. use super::format::{short_id, signature_line, when}; use super::open; use super::ui; use anyhow::Result; use oxy_core::hash::ChangeHash; use oxy_core::identity::Identity; use oxy_core::hash::KeyId; use oxy_core::objects::Hlc; use oxy_core::protocol::SignedPolicy; use oxy_core::repo::{self, IdentityScope, PolicyUpdate, Repo, Rotation}; 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>) -> 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; 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() -> Result { let repo = open()?; let Some((r, sp)) = repo.current_policy()? else { println!("No policy: this repository is unverified. Run `oxy policy init` to create one."); return Ok(0); }; let p = &sp.policy; 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) }