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 b0f1bf8..799b14b 100644 --- a/crates/relicario-cli/src/commands/org.rs +++ b/crates/relicario-cli/src/commands/org.rs @@ -329,6 +329,285 @@ fn resolve_member_id(members: &OrgMembers, prefix: &str) -> Result { } } +// ═══════════ Status / Audit (B8) ═══════════ + +/// `org status`: print the org's members + collections with no decryption. Reads +/// the three plaintext metadata files (org.json, members.json, collections.json) +/// directly — the manifest stays encrypted and is never touched. +pub fn run_org_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).context("parse org.json")? + }; + let members: OrgMembers = { + let s = fs::read_to_string(root.join("members.json")).context("read members.json")?; + serde_json::from_str(&s).context("parse members.json")? + }; + let collections: OrgCollections = { + let s = fs::read_to_string(root.join("collections.json")) + .context("read collections.json")?; + serde_json::from_str(&s).context("parse collections.json")? + }; + + 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(()) +} + +/// One audited org-vault commit, attributed to a VERIFIED git signer. +#[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, +} + +/// Parse a commit's `Relicario-*` trailer block into an `AuditEvent`. The actor +/// id captured here is the trailer's CLAIM (`trailer_actor_id`) — the +/// authoritative `actor_id` is resolved later from the verified signature. +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()) + }) +} + +/// `org audit`: parse `git log`, resolve each commit's VERIFIED signer to a +/// member and report THAT as the actor (trailers are advisory), flag +/// trailer/signer mismatch as `TAMPERED`, and frame records with `%x1e`/`%x1f` +/// (so multi-line trailer values cannot misalign records) using the committer +/// date (`%cI`). +pub fn run_org_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 events = parse_audit_log(&root, &log, &members, member_filter, collection_filter, action_filter); + + 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(()) +} + +/// Frame a raw `git log` body (records split on `%x1e`, fields on `%x1f`) into +/// attributed `AuditEvent`s. Each commit's VERIFIED signer is resolved via +/// `resolve_signer` and reported as the authoritative actor; trailer/signer +/// disagreement (or no matching member) sets the `tampered` flag. Filters apply +/// to the VERIFIED actor id, not the spoofable trailer. Split out from +/// `run_org_audit` so it can be unit-tested over a real signed repo. +fn parse_audit_log( + root: &Path, + log: &str, + members: &relicario_core::OrgMembers, + member_filter: Option<&str>, + collection_filter: Option<&str>, + action_filter: Option<&str>, +) -> Vec { + 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); + } + events +} + #[cfg(test)] mod tests { use super::*; @@ -385,4 +664,201 @@ mod tests { assert!(!members.find_by_id(&id).unwrap().collections.contains(&"prod".to_string())); assert!(members.find_by_id(&id).unwrap().collections.contains(&"dev".to_string())); } + + // ───── Status / Audit (B8) ───── + + #[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); + } + + #[test] + fn parse_trailers_captures_item_and_device() { + let raw = "Relicario-Actor: bob feedfacefeedface\nRelicario-Action: item-update\nRelicario-Item: 0123456789abcdef\nRelicario-Device: laptop\n"; + let ev = parse_trailer_block("def456", "2026-06-06T13:00:00+00:00", raw); + assert_eq!(ev.action.as_deref(), Some("item-update")); + assert_eq!(ev.item_id.as_deref(), Some("0123456789abcdef")); + assert_eq!(ev.device_id.as_deref(), Some("laptop")); + assert_eq!(ev.trailer_actor_id.as_deref(), Some("feedfacefeedface")); + } + + #[test] + fn parse_trailers_single_token_actor_falls_back_to_whole_value() { + // No space => the whole value is treated as the member id. + let raw = "Relicario-Actor: lonelytoken00000\nRelicario-Action: org-init\n"; + let ev = parse_trailer_block("c0ffee", "2026-06-06T14:00:00+00:00", raw); + assert_eq!(ev.trailer_actor_id.as_deref(), Some("lonelytoken00000")); + assert_eq!(ev.action.as_deref(), Some("org-init")); + } + + #[test] + fn parse_trailers_non_org_commit_has_no_action() { + // A commit with no Relicario-* trailers parses to an event with no action, + // which run_org_audit skips. + let ev = parse_trailer_block("beef", "2026-06-06T15:00:00+00:00", ""); + assert!(ev.action.is_none()); + } +} + +#[cfg(test)] +mod audit_log_tests { + //! Record-framing + filter tests for `parse_audit_log` against a synthetic + //! `git log` body (no real repo / signatures needed: members.json is empty so + //! `resolve_signer` always returns None and every org commit is flagged + //! TAMPERED — which is exactly the "signer is not a current member" path). + use super::*; + use relicario_core::OrgMembers; + + /// Build one framed record: leading %x1e, then commit %x1f ts %x1f trailers. + fn record(commit: &str, ts: &str, trailers: &str) -> String { + format!("\u{1e}{commit}\u{1f}{ts}\u{1f}{trailers}") + } + + #[test] + fn parse_audit_log_frames_records_and_flags_unverified() { + let members = OrgMembers::new(); // no members => no signer can resolve + let log = format!( + "{}{}", + record( + "1111111111111111111111111111111111111111", + "2026-06-06T12:00:00+00:00", + "Relicario-Actor: alice a1b2c3d4e5f6a1b2\nRelicario-Action: item-create\nRelicario-Collection: prod\n", + ), + record( + "2222222222222222222222222222222222222222", + "2026-06-06T13:00:00+00:00", + "Relicario-Actor: bob feedfacefeedface\nRelicario-Action: item-update\nRelicario-Collection: dev\n", + ), + ); + // root path is unused once resolve_signer short-circuits on empty members, + // but verify-commit will run; point it at a tempdir to be safe. + let tmp = tempfile::tempdir().unwrap(); + let events = parse_audit_log(tmp.path(), &log, &members, None, None, None); + assert_eq!(events.len(), 2); + // Leading %x1e produced an empty leading split element that was filtered. + assert_eq!(events[0].commit, "1111111111111111111111111111111111111111"); + assert_eq!(events[0].action.as_deref(), Some("item-create")); + assert_eq!(events[0].collection.as_deref(), Some("prod")); + // No member matched the (absent) signature => TAMPERED, no verified actor. + assert!(events[0].tampered); + assert_eq!(events[0].actor_name, None); + assert_eq!(events[0].actor_id, None); + // Trailer claim is preserved for forensic comparison. + assert_eq!(events[0].trailer_actor_id.as_deref(), Some("a1b2c3d4e5f6a1b2")); + } + + #[test] + fn parse_audit_log_skips_non_org_commits() { + let members = OrgMembers::new(); + let log = format!( + "{}{}", + // A non-org commit: no Relicario-Action trailer. + record("3333", "2026-06-06T10:00:00+00:00", "Some-Other: trailer\n"), + record( + "4444", + "2026-06-06T11:00:00+00:00", + "Relicario-Action: org-init\nRelicario-Actor: alice a1b2c3d4e5f6a1b2\n", + ), + ); + let tmp = tempfile::tempdir().unwrap(); + let events = parse_audit_log(tmp.path(), &log, &members, None, None, None); + assert_eq!(events.len(), 1); + assert_eq!(events[0].commit, "4444"); + assert_eq!(events[0].action.as_deref(), Some("org-init")); + } + + #[test] + fn parse_audit_log_multiline_trailer_value_does_not_misalign() { + // A multi-line trailer value must not break record framing: only %x1e + // ends a record, not a newline inside the trailer block. + let members = OrgMembers::new(); + let log = format!( + "{}{}", + record( + "5555", + "2026-06-06T09:00:00+00:00", + "Relicario-Action: item-create\nRelicario-Actor: carol cafecafecafecafe\nRelicario-Collection: prod\n", + ), + record( + "6666", + "2026-06-06T09:30:00+00:00", + "Relicario-Action: item-delete\nRelicario-Actor: dave deaddeaddeaddead\nRelicario-Collection: dev\n", + ), + ); + let tmp = tempfile::tempdir().unwrap(); + let events = parse_audit_log(tmp.path(), &log, &members, None, None, None); + assert_eq!(events.len(), 2); + assert_eq!(events[0].commit, "5555"); + assert_eq!(events[1].commit, "6666"); + assert_eq!(events[1].action.as_deref(), Some("item-delete")); + } + + #[test] + fn parse_audit_log_collection_and_action_filters_apply() { + let members = OrgMembers::new(); + let log = format!( + "{}{}{}", + record( + "7777", + "2026-06-06T08:00:00+00:00", + "Relicario-Action: item-create\nRelicario-Collection: prod\nRelicario-Actor: a aaaa000000000000\n", + ), + record( + "8888", + "2026-06-06T08:10:00+00:00", + "Relicario-Action: item-update\nRelicario-Collection: prod\nRelicario-Actor: a aaaa000000000000\n", + ), + record( + "9999", + "2026-06-06T08:20:00+00:00", + "Relicario-Action: item-create\nRelicario-Collection: dev\nRelicario-Actor: a aaaa000000000000\n", + ), + ); + let tmp = tempfile::tempdir().unwrap(); + + // Collection filter: only prod commits survive. + let prod = parse_audit_log(tmp.path(), &log, &members, None, Some("prod"), None); + assert_eq!(prod.len(), 2); + assert!(prod.iter().all(|e| e.collection.as_deref() == Some("prod"))); + + // Action filter: only item-create commits survive. + let creates = parse_audit_log(tmp.path(), &log, &members, None, None, Some("item-create")); + assert_eq!(creates.len(), 2); + assert!(creates.iter().all(|e| e.action.as_deref() == Some("item-create"))); + + // Combined: item-create AND prod => just commit 7777. + let combined = + parse_audit_log(tmp.path(), &log, &members, None, Some("prod"), Some("item-create")); + assert_eq!(combined.len(), 1); + assert_eq!(combined[0].commit, "7777"); + } + + #[test] + fn parse_audit_log_member_filter_uses_verified_actor_not_trailer() { + // With no resolvable signer, actor_id is None, so a member filter naming + // the TRAILER's claimed id must NOT match — the filter is on the verified + // actor, which is the whole point of TAMPERED attribution. + let members = OrgMembers::new(); + let log = record( + "aaaa", + "2026-06-06T07:00:00+00:00", + "Relicario-Action: item-create\nRelicario-Actor: mallory deadbeefdeadbeef\n", + ); + let tmp = tempfile::tempdir().unwrap(); + let filtered = + parse_audit_log(tmp.path(), &log, &members, Some("deadbeefdeadbeef"), None, None); + assert!( + filtered.is_empty(), + "member filter must match the verified actor id, never the spoofable trailer" + ); + } }