From 558da3bd75cdaf633e9eb8d156a1383eeb6f9c06 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sat, 20 Jun 2026 12:58:00 -0400 Subject: [PATCH] =?UTF-8?q?feat(cli/org):=20rotate-key=20=E2=80=94=20re-en?= =?UTF-8?q?crypt=20every=20item=20blob=20+=20abort=20on=20concurrent=20rot?= =?UTF-8?q?ation?= 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();