merge(cli): dev-b B7 (rotate-key) + B8 (status/audit) — reviewed; rotate re-encrypts all blobs, owner-only, concurrent-rotation abort

This commit is contained in:
adlee-was-taken
2026-06-20 13:40:36 -04:00
3 changed files with 372 additions and 1 deletions

1
Cargo.lock generated
View File

@@ -2172,6 +2172,7 @@ dependencies = [
"predicates",
"qrcode",
"rand",
"regex",
"relicario-core",
"reqwest",
"rpassword",

View File

@@ -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"

View File

@@ -329,11 +329,373 @@ 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);
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()));
}
// 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(())
}
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<String>,
pub actor_id: Option<String>,
/// Actor id as CLAIMED by the commit trailer (advisory; for tamper-checking).
pub trailer_actor_id: Option<String>,
pub action: Option<String>,
pub collection: Option<String>,
pub item_id: Option<String>,
pub device_id: Option<String>,
/// 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: "<name> <member_id>" (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 <table|json>` (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<String> = 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<AuditEvent> = 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("<unverified>"),
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: <name> <member_id>".
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(),
@@ -346,6 +708,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();