diff --git a/Cargo.lock b/Cargo.lock index ffaf13f..5b9a869 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2172,6 +2172,7 @@ dependencies = [ "predicates", "qrcode", "rand", + "regex", "relicario-core", "reqwest", "rpassword", diff --git a/crates/relicario-cli/Cargo.toml b/crates/relicario-cli/Cargo.toml index db05181..928004c 100644 --- a/crates/relicario-cli/Cargo.toml +++ b/crates/relicario-cli/Cargo.toml @@ -31,10 +31,11 @@ rqrr = "0.7" reqwest = { version = "0.12", features = ["blocking", "json"] } qrcode = { version = "0.14", features = ["svg"] } ssh-key = { version = "0.6", features = ["ed25519", "std"] } +regex = "1" +tempfile = "3" [dev-dependencies] assert_cmd = "2" predicates = "3" -tempfile = "3" serde_json = "1" ed25519-dalek = "2" diff --git a/crates/relicario-cli/src/commands/org.rs b/crates/relicario-cli/src/commands/org.rs index 9076eeb..e9ee641 100644 --- a/crates/relicario-cli/src/commands/org.rs +++ b/crates/relicario-cli/src/commands/org.rs @@ -441,11 +441,261 @@ pub fn run_rotate_key(dir: &Path) -> Result<()> { Ok(()) } +pub fn run_status(dir: &Path) -> Result<()> { + let root = crate::org_session::org_dir(Some(dir))?; + + let meta: relicario_core::OrgMeta = { + let s = fs::read_to_string(root.join("org.json")).context("read org.json")?; + serde_json::from_str(&s)? + }; + let members: OrgMembers = { + let s = fs::read_to_string(root.join("members.json")).context("read members.json")?; + serde_json::from_str(&s)? + }; + let collections: OrgCollections = { + let s = fs::read_to_string(root.join("collections.json")).context("read collections.json")?; + serde_json::from_str(&s)? + }; + + println!("Org: {} ({})", meta.display_name, meta.org_id.as_str()); + println!(); + println!("Members ({}):", members.members.len()); + for m in &members.members { + let colls = if m.collections.is_empty() { + "(no collections)".to_string() + } else { + m.collections.join(", ") + }; + println!(" {:?} {} {} [{}]", m.role, m.member_id.as_str(), m.display_name, colls); + } + println!(); + println!("Collections ({}):", collections.collections.len()); + for c in &collections.collections { + println!(" {} — {}", c.slug, c.display_name); + } + Ok(()) +} + +#[derive(Debug, serde::Serialize)] +pub struct AuditEvent { + pub commit: String, + pub timestamp: String, + /// Actor as resolved from the VERIFIED signing key (authoritative). + pub actor_name: Option, + pub actor_id: Option, + /// Actor id as CLAIMED by the commit trailer (advisory; for tamper-checking). + pub trailer_actor_id: Option, + pub action: Option, + pub collection: Option, + pub item_id: Option, + pub device_id: Option, + /// True when the trailer's claimed actor disagrees with the verified signer, + /// or when no current member matches the signing key. + pub tampered: bool, +} + +fn parse_trailer_block(commit: &str, timestamp: &str, trailers: &str) -> AuditEvent { + let mut ev = AuditEvent { + commit: commit.to_string(), + timestamp: timestamp.to_string(), + actor_name: None, + actor_id: None, + trailer_actor_id: None, + action: None, + collection: None, + item_id: None, + device_id: None, + tampered: false, + }; + for line in trailers.lines() { + let line = line.trim(); + if let Some(rest) = line.strip_prefix("Relicario-Actor:") { + // Contract format: " " (member_id is the last token). + let rest = rest.trim(); + if let Some((_name, id)) = rest.rsplit_once(' ') { + ev.trailer_actor_id = Some(id.trim().to_string()); + } else if !rest.is_empty() { + ev.trailer_actor_id = Some(rest.to_string()); + } + } else if let Some(v) = line.strip_prefix("Relicario-Action:") { + ev.action = Some(v.trim().to_string()); + } else if let Some(v) = line.strip_prefix("Relicario-Collection:") { + ev.collection = Some(v.trim().to_string()); + } else if let Some(v) = line.strip_prefix("Relicario-Item:") { + ev.item_id = Some(v.trim().to_string()); + } else if let Some(v) = line.strip_prefix("Relicario-Device:") { + ev.device_id = Some(v.trim().to_string()); + } + } + ev +} + +/// Resolve a commit's SSH signature fingerprint to a current member, mirroring +/// the pre-receive hook: build an allowed_signers from members.json, inject it +/// via GIT_CONFIG_*, run `git verify-commit --raw`, parse the SHA256: key from +/// stderr. Returns None if the commit is unsigned or the signer is not a member. +fn resolve_signer<'m>( + root: &Path, + commit: &str, + members: &'m relicario_core::OrgMembers, +) -> Option<&'m relicario_core::OrgMember> { + use std::io::Write; + let mut tmp = tempfile::NamedTempFile::new().ok()?; + for m in &members.members { + let _ = writeln!(tmp, "relicario {}", m.ed25519_pubkey.trim()); + } + let allowed_path = tmp.path(); + + let output = std::process::Command::new("git") + .current_dir(root) + .args(["verify-commit", "--raw", commit]) + .env("GIT_CONFIG_COUNT", "1") + .env("GIT_CONFIG_KEY_0", "gpg.ssh.allowedSignersFile") + .env("GIT_CONFIG_VALUE_0", allowed_path.as_os_str()) + .output() + .ok()?; + let stderr = String::from_utf8_lossy(&output.stderr); + + let re = regex::Regex::new(r"key (SHA256:[A-Za-z0-9+/]+)").ok()?; + let fp = re.captures(&stderr)?.get(1)?.as_str().to_string(); + + members.members.iter().find(|m| { + relicario_core::fingerprint(&m.ed25519_pubkey).ok().as_deref() == Some(fp.as_str()) + }) +} + +pub fn run_audit( + dir: &Path, + since: Option<&str>, + member_filter: Option<&str>, + collection_filter: Option<&str>, + action_filter: Option<&str>, + format: &str, +) -> Result<()> { + // Spec surface is `--format ` (default table). Accept only those. + let json = match format { + "json" => true, + "table" => false, + other => anyhow::bail!("unknown --format `{other}` — use table or json"), + }; + let root = crate::org_session::org_dir(Some(dir))?; + + // members.json — needed to resolve each commit's verified signer to a member. + let members: relicario_core::OrgMembers = { + let s = fs::read_to_string(root.join("members.json")).context("read members.json")?; + serde_json::from_str(&s).context("parse members.json")? + }; + + // git log framed with a record separator (%x1e, U+001E) PER COMMIT and a + // field separator (%x1f, U+001F) between fields, so multi-line trailer + // values cannot misalign record boundaries. Committer date (%cI), not + // author date: it is what revocation/audit is anchored to. + let fmt = "%x1e%H%x1f%cI%x1f%(trailers:only=true,unfold=true)"; + let mut args: Vec = vec!["log".into(), format!("--format={fmt}")]; + if let Some(s) = since { + args.push(format!("--since={s}")); + } + + let output = std::process::Command::new("git") + .current_dir(&root) + .args(&args) + .output() + .context("git log")?; + let log = String::from_utf8_lossy(&output.stdout); + + let mut events: Vec = Vec::new(); + for record in log.split('\u{1e}') { + let record = record.trim_start_matches('\n'); + if record.trim().is_empty() { + continue; + } + let mut fields = record.splitn(3, '\u{1f}'); + let commit = fields.next().unwrap_or("").trim(); + let ts = fields.next().unwrap_or("").trim(); + let trailers = fields.next().unwrap_or(""); + if commit.is_empty() { + continue; + } + + let mut ev = parse_trailer_block(commit, ts, trailers); + if ev.action.is_none() { + continue; // not an org commit + } + + // Resolve the VERIFIED signer and attribute it as the authoritative actor. + match resolve_signer(&root, commit, &members) { + Some(m) => { + ev.actor_name = Some(m.display_name.clone()); + ev.actor_id = Some(m.member_id.as_str().to_string()); + // Tampered if the trailer claims a different actor than the signer. + if let Some(claimed) = ev.trailer_actor_id.as_deref() { + if claimed != m.member_id.as_str() { + ev.tampered = true; + } + } + } + None => { + // No current member matched the signature -> cannot trust the + // trailer's claimed actor. + ev.tampered = true; + } + } + + if let Some(mid) = member_filter { + // Filter on the VERIFIED actor id, not the spoofable trailer. + if ev.actor_id.as_deref() != Some(mid) { + continue; + } + } + if let Some(col) = collection_filter { + if ev.collection.as_deref() != Some(col) { + continue; + } + } + if let Some(act) = action_filter { + if ev.action.as_deref() != Some(act) { + continue; + } + } + events.push(ev); + } + + if json { + println!("{}", serde_json::to_string_pretty(&events)?); + } else { + println!("{:<44} {:<26} {:<20} {:<18} {}", "COMMIT", "TIMESTAMP", "ACTION", "ACTOR", "FLAG"); + for ev in &events { + println!("{:<44} {:<26} {:<20} {:<18} {}", + ev.commit, + ev.timestamp, + ev.action.as_deref().unwrap_or("-"), + ev.actor_name.as_deref().unwrap_or(""), + if ev.tampered { "TAMPERED" } else { "" }, + ); + } + } + Ok(()) +} + #[cfg(test)] mod tests { use super::*; use relicario_core::{MemberId, OrgMembers, OrgRole, OrgMember}; + #[test] + fn parse_trailers_extracts_relicario_fields() { + // Contract trailer shape: "Relicario-Actor: ". + let raw = "Relicario-Actor: alice a1b2c3d4e5f6a1b2\nRelicario-Action: item-create\nRelicario-Collection: prod\n"; + let event = parse_trailer_block("abc123", "2026-06-06T12:00:00+00:00", raw); + assert_eq!(event.action.as_deref(), Some("item-create")); + assert_eq!(event.collection.as_deref(), Some("prod")); + // The verified actor_id is resolved later from the signature, not the trailer; + // the trailer only populates trailer_actor_id here. + assert_eq!(event.trailer_actor_id.as_deref(), Some("a1b2c3d4e5f6a1b2")); + assert_eq!(event.actor_id, None); + assert!(!event.tampered); + } + fn alice() -> OrgMember { OrgMember { member_id: MemberId::new(),