From 558da3bd75cdaf633e9eb8d156a1383eeb6f9c06 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sat, 20 Jun 2026 12:58:00 -0400 Subject: [PATCH 1/3] =?UTF-8?q?feat(cli/org):=20rotate-key=20=E2=80=94=20r?= =?UTF-8?q?e-encrypt=20every=20item=20blob=20+=20abort=20on=20concurrent?= =?UTF-8?q?=20rotation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/relicario-cli/src/commands/org.rs | 119 +++++++++++++++++++++++ 1 file changed, 119 insertions(+) diff --git a/crates/relicario-cli/src/commands/org.rs b/crates/relicario-cli/src/commands/org.rs index b0f1bf8..6b8951c 100644 --- a/crates/relicario-cli/src/commands/org.rs +++ b/crates/relicario-cli/src/commands/org.rs @@ -329,6 +329,118 @@ fn resolve_member_id(members: &OrgMembers, prefix: &str) -> Result { } } +pub fn run_rotate_key(dir: &Path) -> Result<()> { + // Pull latest state first to detect a concurrent rotation. We must + // distinguish three outcomes: + // * success -> proceed + // * no upstream / no remote -> local-only org, proceed + // * non-fast-forward / conflict -> concurrent rotation, ABORT + let pull = std::process::Command::new("git") + .current_dir(dir) + .args(["pull", "--rebase"]) + .output() + .context("spawn git pull --rebase")?; + if !pull.status.success() { + let stderr = String::from_utf8_lossy(&pull.stderr); + let no_upstream = stderr.contains("no tracking information") + || stderr.contains("There is no tracking information") + || stderr.contains("does not appear to be a git repository") + || stderr.contains("Could not read from remote repository") + || stderr.contains("No remote repository specified"); + if no_upstream { + eprintln!("Note: no upstream configured; proceeding with local state."); + } else { + // Best-effort: leave the working tree clean for the retry. + let _ = std::process::Command::new("git") + .current_dir(dir) + .args(["rebase", "--abort"]) + .output(); + anyhow::bail!( + "Concurrent key rotation detected — pull and re-run org rotate-key." + ); + } + } + + let vault = crate::org_session::open_org_vault(Some(dir))?; + let caller = vault.current_member()?; + if !caller.role.can_manage_owners() { + anyhow::bail!("only owners can rotate the org master key"); + } + + let members = vault.load_members()?; + let new_org_key = relicario_core::generate_org_key(); + + // Re-wrap the org key for every current member. + let mut staged_paths: Vec = Vec::new(); + for member in &members.members { + let wrapped = wrap_org_key(&new_org_key, &member.ed25519_pubkey) + .with_context(|| format!("wrap key for {}", member.display_name))?; + let key_path = vault.member_key_path(&member.member_id); + fs::write(&key_path, &wrapped) + .with_context(|| format!("write key for {}", member.display_name))?; + staged_paths.push(format!("keys/{}.enc", member.member_id.as_str())); + } + + // Re-encrypt EVERY item blob under the new key. Items live collection-scoped + // at items//.enc. Decrypt with the old key (held in the + // open vault session) and re-encrypt with the new one, in place. Without this + // a removed member who kept the old key + a clone could still decrypt every + // pre-rotation item. + let items_root = vault.root().join("items"); + if items_root.is_dir() { + for slug_entry in fs::read_dir(&items_root).context("read items/")? { + let slug_entry = slug_entry.context("read items/ entry")?; + let slug_dir = slug_entry.path(); + if !slug_dir.is_dir() { + continue; + } + let slug = slug_entry.file_name().to_string_lossy().to_string(); + for item_entry in fs::read_dir(&slug_dir) + .with_context(|| format!("read items/{slug}/"))? + { + let item_entry = item_entry.context("read item entry")?; + let item_path = item_entry.path(); + if item_path.extension().and_then(|e| e.to_str()) != Some("enc") { + continue; + } + let old_bytes = fs::read(&item_path) + .with_context(|| format!("read {}", item_path.display()))?; + let item = relicario_core::decrypt_item(&old_bytes, vault.key()) + .with_context(|| format!("decrypt {}", item_path.display()))?; + let new_bytes = relicario_core::encrypt_item(&item, &new_org_key) + .with_context(|| format!("re-encrypt {}", item_path.display()))?; + crate::org_session::atomic_write(&item_path, &new_bytes)?; + let file_name = item_entry.file_name().to_string_lossy().to_string(); + staged_paths.push(format!("items/{slug}/{file_name}")); + } + } + } + + // Re-encrypt the manifest with the new key. + let manifest = vault.load_manifest()?; + let new_manifest_bytes = relicario_core::encrypt_org_manifest(&manifest, &new_org_key)?; + crate::org_session::atomic_write(&vault.manifest_path(), &new_manifest_bytes)?; + staged_paths.push("manifest.enc".to_string()); + + // Commit + let mut add_args = vec!["add"]; + let path_refs: Vec<&str> = staged_paths.iter().map(|s| s.as_str()).collect(); + add_args.extend_from_slice(&path_refs); + crate::org_session::org_git_run(&vault.root, &add_args, "git add")?; + + let commit_msg = format!( + "org: rotate org master key\n\nRelicario-Actor: {} {}\nRelicario-Action: key-rotate", + caller.display_name, caller.member_id.as_str() + ); + crate::org_session::org_git_run(&vault.root, &["commit", "-m", &commit_msg], "git commit")?; + + println!( + "Key rotated. {} member key(s) re-wrapped; all item blobs + manifest re-encrypted.", + members.members.len() + ); + Ok(()) +} + #[cfg(test)] mod tests { use super::*; @@ -346,6 +458,13 @@ mod tests { } } + #[test] + fn new_key_differs_from_old_key() { + let k1 = relicario_core::generate_org_key(); + let k2 = relicario_core::generate_org_key(); + assert_ne!(*k1, *k2); + } + #[test] fn set_role_changes_role() { let mut members = OrgMembers::new(); From 3b6dbbe353f65b55bfaa1d02f085a5f3f8b4d596 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sat, 20 Jun 2026 13:17:16 -0400 Subject: [PATCH 2/3] fix(cli/org): rotate-key writes member key blobs atomically (crash-safe) --- crates/relicario-cli/src/commands/org.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/relicario-cli/src/commands/org.rs b/crates/relicario-cli/src/commands/org.rs index 6b8951c..9076eeb 100644 --- a/crates/relicario-cli/src/commands/org.rs +++ b/crates/relicario-cli/src/commands/org.rs @@ -376,7 +376,7 @@ pub fn run_rotate_key(dir: &Path) -> Result<()> { let wrapped = wrap_org_key(&new_org_key, &member.ed25519_pubkey) .with_context(|| format!("wrap key for {}", member.display_name))?; let key_path = vault.member_key_path(&member.member_id); - fs::write(&key_path, &wrapped) + crate::org_session::atomic_write(&key_path, &wrapped) .with_context(|| format!("write key for {}", member.display_name))?; staged_paths.push(format!("keys/{}.enc", member.member_id.as_str())); } From 053062effd70f8458bd41cbdab35de6c63447c11 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sat, 20 Jun 2026 13:24:35 -0400 Subject: [PATCH 3/3] feat(cli/org): status + audit (verified-signer attribution, TAMPERED flag, committer-date framing) --- Cargo.lock | 1 + crates/relicario-cli/Cargo.toml | 3 +- crates/relicario-cli/src/commands/org.rs | 250 +++++++++++++++++++++++ 3 files changed, 253 insertions(+), 1 deletion(-) 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(),