feat(cli/org): rotate-key — re-encrypt every item blob + abort on concurrent rotation
This commit is contained in:
@@ -329,6 +329,118 @@ fn resolve_member_id(members: &OrgMembers, prefix: &str) -> Result<MemberId> {
|
||||
}
|
||||
}
|
||||
|
||||
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<String> = 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/<collection-slug>/<id>.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();
|
||||
|
||||
Reference in New Issue
Block a user