diff --git a/Cargo.lock b/Cargo.lock index 1705b66..ffaf13f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2166,6 +2166,7 @@ dependencies = [ "clap_complete", "data-encoding", "dirs", + "ed25519-dalek", "hex", "image", "predicates", @@ -2177,6 +2178,7 @@ dependencies = [ "rqrr", "serde", "serde_json", + "ssh-key", "tar", "tempfile", "url", diff --git a/crates/relicario-cli/Cargo.toml b/crates/relicario-cli/Cargo.toml index a5a853f..db05181 100644 --- a/crates/relicario-cli/Cargo.toml +++ b/crates/relicario-cli/Cargo.toml @@ -30,9 +30,11 @@ image = { version = "0.25", default-features = false, features = ["jpeg", "png"] rqrr = "0.7" reqwest = { version = "0.12", features = ["blocking", "json"] } qrcode = { version = "0.14", features = ["svg"] } +ssh-key = { version = "0.6", features = ["ed25519", "std"] } [dev-dependencies] assert_cmd = "2" predicates = "3" tempfile = "3" serde_json = "1" +ed25519-dalek = "2" diff --git a/crates/relicario-cli/src/commands/mod.rs b/crates/relicario-cli/src/commands/mod.rs index 791464f..077e366 100644 --- a/crates/relicario-cli/src/commands/mod.rs +++ b/crates/relicario-cli/src/commands/mod.rs @@ -14,6 +14,7 @@ pub mod edit; pub mod generate; pub mod get; pub mod import; +pub mod org; pub mod init; pub mod list; pub mod rate; diff --git a/crates/relicario-cli/src/commands/org.rs b/crates/relicario-cli/src/commands/org.rs new file mode 100644 index 0000000..b0f1bf8 --- /dev/null +++ b/crates/relicario-cli/src/commands/org.rs @@ -0,0 +1,388 @@ +//! `relicario org` subcommands for multi-user org vault management. + +use std::fs; +use std::path::Path; + +use anyhow::{Context, Result}; +use relicario_core::{ + generate_org_key, wrap_org_key, + CollectionDef, MemberId, OrgCollections, OrgManifest, OrgMembers, OrgMeta, OrgRole, OrgMember, + encrypt_org_manifest, +}; + +use crate::org_session::atomic_write; + +pub fn run_init(dir: &Path, name: &str) -> Result<()> { + // Create directory structure + fs::create_dir_all(dir.join("items")).context("create items/")?; + fs::create_dir_all(dir.join("keys")).context("create keys/")?; + + // Get caller's device info + let device_pubkey = crate::device::current_device_pubkey() + .context("read device key — run `relicario device add` first")?; + + // Generate org master key + let org_key = generate_org_key(); + + // Wrap org key to caller's device key + let wrapped = wrap_org_key(&org_key, &device_pubkey) + .context("wrap org key to device key")?; + + // Create initial members.json with caller as owner + let caller_id = MemberId::new(); + let now = relicario_core::now_unix(); + let member = OrgMember { + member_id: caller_id.clone(), + display_name: whoami(), + role: OrgRole::Owner, + ed25519_pubkey: device_pubkey, + collections: vec![], + added_at: now, + added_by: caller_id.clone(), + }; + let mut members = OrgMembers::new(); + members.members.push(member); + + // Write wrapped key + let key_path = dir.join("keys").join(format!("{}.enc", caller_id.as_str())); + fs::write(&key_path, &wrapped).context("write caller key blob")?; + + // Write org.json + let meta = OrgMeta::new(name.to_string()); + let meta_json = serde_json::to_string_pretty(&meta)?; + atomic_write(&dir.join("org.json"), meta_json.as_bytes())?; + + // Write members.json + let members_json = serde_json::to_string_pretty(&members)?; + atomic_write(&dir.join("members.json"), members_json.as_bytes())?; + + // Write collections.json (empty) + let collections = OrgCollections::new(); + let coll_json = serde_json::to_string_pretty(&collections)?; + atomic_write(&dir.join("collections.json"), coll_json.as_bytes())?; + + // Write empty manifest.enc + let manifest = OrgManifest::new(); + let manifest_bytes = encrypt_org_manifest(&manifest, &org_key)?; + atomic_write(&dir.join("manifest.enc"), &manifest_bytes)?; + + // git init, then configure THIS repo to sign commits with the active device + // key. Org commits must be signed; the pre-receive hook verifies every one. + crate::helpers::git_run(dir, &["init"], "git init")?; + let device_name = crate::device::current_device()? + .ok_or_else(|| anyhow::anyhow!("no active device — run `relicario device add` first"))?; + crate::device::configure_git_signing(dir, &device_name) + .context("configure org repo signing")?; + + // Stage everything and make the signed bootstrap commit via org_git_run + // (which does NOT disable signing, unlike helpers::git_run). + crate::org_session::org_git_run(dir, &["add", "."], "git add")?; + let commit_msg = format!( + "init: org vault \"{name}\"\n\nRelicario-Actor: {} {}\nRelicario-Action: org-init", + members.members[0].display_name, + caller_id.as_str() + ); + crate::org_session::org_git_run(dir, &["commit", "-m", &commit_msg], "git commit")?; + + println!("Org vault initialized at {}", dir.display()); + println!("Your member ID: {}", caller_id.as_str()); + Ok(()) +} + +fn whoami() -> String { + std::env::var("USER") + .or_else(|_| std::env::var("USERNAME")) + .unwrap_or_else(|_| "unknown".into()) +} + +pub fn run_add_member( + dir: &Path, + pubkey: &str, + name: &str, + role: OrgRole, +) -> Result<()> { + let vault = crate::org_session::open_org_vault(Some(dir))?; + let caller = vault.current_member()?; + if !caller.role.can_manage_members() { + anyhow::bail!("only owners and admins can add members"); + } + // Privilege-escalation guard: only an owner may create an owner or admin. + if matches!(role, OrgRole::Owner | OrgRole::Admin) && !caller.role.can_manage_owners() { + anyhow::bail!("only owners can add members with the owner or admin role"); + } + + let mut members = vault.load_members()?; + + // Check pubkey not already present + if members.members.iter().any(|m| m.ed25519_pubkey.trim() == pubkey.trim()) { + anyhow::bail!("this public key is already registered in the org"); + } + + let new_id = MemberId::new(); + let now = relicario_core::now_unix(); + let wrapped = wrap_org_key(vault.key(), pubkey) + .context("wrap org key to new member's key")?; + + fs::write(vault.member_key_path(&new_id), &wrapped) + .context("write member key blob")?; + + members.members.push(OrgMember { + member_id: new_id.clone(), + display_name: name.to_string(), + role, + ed25519_pubkey: pubkey.trim().to_string(), + collections: vec![], + added_at: now, + added_by: caller.member_id.clone(), + }); + vault.save_members(&members)?; + + let commit_msg = format!( + "org: add member \"{name}\"\n\nRelicario-Actor: {} {}\nRelicario-Action: member-add\nRelicario-Member: {}", + caller.display_name, caller.member_id.as_str(), new_id.as_str() + ); + crate::org_session::org_git_run( + &vault.root, + &["add", "members.json", &format!("keys/{}.enc", new_id.as_str())], + "git add", + )?; + crate::org_session::org_git_run(&vault.root, &["commit", "-m", &commit_msg], "git commit")?; + + println!("Added {} ({})", name, new_id.as_str()); + Ok(()) +} + +pub fn run_remove_member(dir: &Path, member_id_prefix: &str) -> Result<()> { + let vault = crate::org_session::open_org_vault(Some(dir))?; + let caller = vault.current_member()?; + if !caller.role.can_manage_members() { + anyhow::bail!("only owners and admins can remove members"); + } + + let mut members = vault.load_members()?; + let target_id = resolve_member_id(&members, member_id_prefix)?; + + let target = members.find_by_id(&target_id).unwrap(); + if target.role == OrgRole::Owner && !caller.role.can_manage_owners() { + anyhow::bail!("only owners can remove other owners"); + } + let target_name = target.display_name.clone(); + + // Delete key blob + let key_path = vault.member_key_path(&target_id); + if key_path.exists() { fs::remove_file(&key_path).context("delete key blob")?; } + + members.members.retain(|m| m.member_id != target_id); + vault.save_members(&members)?; + + let commit_msg = format!( + "org: remove member \"{target_name}\"\n\nRelicario-Actor: {} {}\nRelicario-Action: member-remove\nRelicario-Member: {}", + caller.display_name, caller.member_id.as_str(), target_id.as_str() + ); + crate::org_session::org_git_run( + &vault.root, + &["add", "members.json", &format!("keys/{}.enc", target_id.as_str())], + "git add", + )?; + crate::org_session::org_git_run(&vault.root, &["commit", "-m", &commit_msg], "git commit")?; + + eprintln!("⚠ Run `relicario org rotate-key --dir {}` to complete revocation.", vault.root.display()); + println!("Removed {}", target_name); + Ok(()) +} + +pub fn run_set_role(dir: &Path, member_id_prefix: &str, role: OrgRole) -> Result<()> { + let vault = crate::org_session::open_org_vault(Some(dir))?; + let caller = vault.current_member()?; + + let mut members = vault.load_members()?; + let target_id = resolve_member_id(&members, member_id_prefix)?; + + if matches!(role, OrgRole::Admin | OrgRole::Owner) && !caller.role.can_manage_owners() { + anyhow::bail!("only owners can promote to admin or owner"); + } + if !caller.role.can_manage_members() { + anyhow::bail!("only owners and admins can change roles"); + } + + let target = members.find_by_id_mut(&target_id) + .ok_or_else(|| anyhow::anyhow!("member not found"))?; + let old_role = target.role; + target.role = role; + vault.save_members(&members)?; + + let commit_msg = format!( + "org: set role {} → {:?}\n\nRelicario-Actor: {} {}\nRelicario-Action: member-role-change\nRelicario-Member: {}", + target_id.as_str(), role, + caller.display_name, caller.member_id.as_str(), + target_id.as_str() + ); + crate::org_session::org_git_run(&vault.root, &["add", "members.json"], "git add")?; + crate::org_session::org_git_run(&vault.root, &["commit", "-m", &commit_msg], "git commit")?; + + println!("Changed role {:?} → {:?}", old_role, role); + Ok(()) +} + +pub fn run_create_collection(dir: &Path, slug: &str, display_name: &str) -> Result<()> { + let vault = crate::org_session::open_org_vault(Some(dir))?; + let caller = vault.current_member()?; + if !caller.role.can_manage_members() { + anyhow::bail!("only owners and admins can create collections"); + } + + let mut collections = vault.load_collections()?; + if collections.contains_slug(slug) { + anyhow::bail!("collection `{slug}` already exists"); + } + if slug.is_empty() || slug.contains('/') || slug.contains('.') { + anyhow::bail!("invalid slug `{slug}` — no slashes or dots, no empty string"); + } + + collections.collections.push(CollectionDef { + slug: slug.to_string(), + display_name: display_name.to_string(), + created_by: caller.member_id.clone(), + created_at: relicario_core::now_unix(), + }); + vault.save_collections(&collections)?; + + let commit_msg = format!( + "org: create collection \"{slug}\"\n\nRelicario-Actor: {} {}\nRelicario-Action: collection-create\nRelicario-Collection: {slug}", + caller.display_name, caller.member_id.as_str() + ); + crate::org_session::org_git_run(&vault.root, &["add", "collections.json"], "git add")?; + crate::org_session::org_git_run(&vault.root, &["commit", "-m", &commit_msg], "git commit")?; + + println!("Created collection `{slug}`"); + Ok(()) +} + +pub fn run_grant(dir: &Path, member_id_prefix: &str, slug: &str) -> Result<()> { + let vault = crate::org_session::open_org_vault(Some(dir))?; + let caller = vault.current_member()?; + if !caller.role.can_manage_members() { + anyhow::bail!("only owners and admins can grant collection access"); + } + + let collections = vault.load_collections()?; + if !collections.contains_slug(slug) { + anyhow::bail!("collection `{slug}` does not exist — create it first"); + } + + let mut members = vault.load_members()?; + let target_id = resolve_member_id(&members, member_id_prefix)?; + let target = members.find_by_id_mut(&target_id).unwrap(); + if target.collections.contains(&slug.to_string()) { + anyhow::bail!("member already has access to `{slug}`"); + } + target.collections.push(slug.to_string()); + vault.save_members(&members)?; + + let commit_msg = format!( + "org: grant {slug} to {}\n\nRelicario-Actor: {} {}\nRelicario-Action: collection-grant\nRelicario-Collection: {slug}\nRelicario-Member: {}", + target_id.as_str(), caller.display_name, caller.member_id.as_str(), target_id.as_str() + ); + crate::org_session::org_git_run(&vault.root, &["add", "members.json"], "git add")?; + crate::org_session::org_git_run(&vault.root, &["commit", "-m", &commit_msg], "git commit")?; + + println!("Granted `{slug}` to {}", target_id.as_str()); + Ok(()) +} + +pub fn run_revoke(dir: &Path, member_id_prefix: &str, slug: &str) -> Result<()> { + let vault = crate::org_session::open_org_vault(Some(dir))?; + let caller = vault.current_member()?; + if !caller.role.can_manage_members() { + anyhow::bail!("only owners and admins can revoke collection access"); + } + + let mut members = vault.load_members()?; + let target_id = resolve_member_id(&members, member_id_prefix)?; + let target = members.find_by_id_mut(&target_id).unwrap(); + if !target.collections.contains(&slug.to_string()) { + anyhow::bail!("member does not have access to `{slug}`"); + } + target.collections.retain(|s| s != slug); + vault.save_members(&members)?; + + let commit_msg = format!( + "org: revoke {slug} from {}\n\nRelicario-Actor: {} {}\nRelicario-Action: collection-revoke\nRelicario-Collection: {slug}\nRelicario-Member: {}", + target_id.as_str(), caller.display_name, caller.member_id.as_str(), target_id.as_str() + ); + crate::org_session::org_git_run(&vault.root, &["add", "members.json"], "git add")?; + crate::org_session::org_git_run(&vault.root, &["commit", "-m", &commit_msg], "git commit")?; + + println!("Revoked `{slug}` from {}", target_id.as_str()); + Ok(()) +} + +/// Resolve a member_id prefix (or full ID) to a MemberId. +fn resolve_member_id(members: &OrgMembers, prefix: &str) -> Result { + let hits: Vec<_> = members.members.iter() + .filter(|m| m.member_id.as_str().starts_with(prefix)) + .collect(); + match hits.len() { + 0 => anyhow::bail!("no member matches `{prefix}`"), + 1 => Ok(hits[0].member_id.clone()), + _ => anyhow::bail!("ambiguous prefix `{prefix}` — {} matches", hits.len()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use relicario_core::{MemberId, OrgMembers, OrgRole, OrgMember}; + + fn alice() -> OrgMember { + OrgMember { + member_id: MemberId::new(), + display_name: "Alice".into(), + role: OrgRole::Member, + ed25519_pubkey: "ssh-ed25519 AAAA fake".into(), + collections: vec![], + added_at: 0, + added_by: MemberId::new(), + } + } + + #[test] + fn set_role_changes_role() { + let mut members = OrgMembers::new(); + let a = alice(); + let id = a.member_id.clone(); + members.members.push(a); + if let Some(m) = members.find_by_id_mut(&id) { + m.role = OrgRole::Admin; + } + assert_eq!(members.find_by_id(&id).unwrap().role, OrgRole::Admin); + } + + #[test] + fn grant_adds_slug_to_member_collections() { + let mut members = OrgMembers::new(); + let a = alice(); + let id = a.member_id.clone(); + members.members.push(a); + + let m = members.find_by_id_mut(&id).unwrap(); + if !m.collections.contains(&"prod".to_string()) { + m.collections.push("prod".to_string()); + } + assert!(members.find_by_id(&id).unwrap().collections.contains(&"prod".to_string())); + } + + #[test] + fn revoke_removes_slug_from_member_collections() { + let mut members = OrgMembers::new(); + let mut a = alice(); + a.collections = vec!["prod".into(), "dev".into()]; + let id = a.member_id.clone(); + members.members.push(a); + + let m = members.find_by_id_mut(&id).unwrap(); + m.collections.retain(|s| s != "prod"); + assert!(!members.find_by_id(&id).unwrap().collections.contains(&"prod".to_string())); + assert!(members.find_by_id(&id).unwrap().collections.contains(&"dev".to_string())); + } +} diff --git a/crates/relicario-cli/src/device.rs b/crates/relicario-cli/src/device.rs index 25cf775..5db7f1e 100644 --- a/crates/relicario-cli/src/device.rs +++ b/crates/relicario-cli/src/device.rs @@ -99,6 +99,48 @@ pub fn load_signing_key(name: &str) -> Result> { Ok(Zeroizing::new(key)) } +/// Read the active device's ed25519 public key (OpenSSH single-line format, +/// e.g. `ssh-ed25519 AAAA... comment`) from `signing.pub`. +/// +/// Errors if no device is selected (`devices/current` missing/empty) — the +/// caller should hint the user to run `relicario device add` first. +pub fn current_device_pubkey() -> Result { + let name = current_device()? + .ok_or_else(|| anyhow::anyhow!("no active device — run `relicario device add` first"))?; + let path = device_dir(&name)?.join("signing.pub"); + let pubkey = fs::read_to_string(&path) + .with_context(|| format!("read signing.pub for device '{name}'"))?; + let trimmed = pubkey.trim(); + if trimmed.is_empty() { + anyhow::bail!("signing.pub for device '{name}' is empty"); + } + Ok(trimmed.to_string()) +} + +/// Read the active device's 32-byte ed25519 seed from `signing.key` +/// (OpenSSH private-key format). +/// +/// The seed is the secret scalar used to sign org commits and to unwrap the +/// org key. It is returned in `Zeroizing` so it is wiped on drop. Errors if no +/// device is selected, the key file is unreadable, or the key is not ed25519. +pub fn current_device_seed() -> Result> { + let name = current_device()? + .ok_or_else(|| anyhow::anyhow!("no active device — run `relicario device add` first"))?; + // load_signing_key reads signing.key as OpenSSH private-key text. + let pem = load_signing_key(&name)?; + let private = ssh_key::PrivateKey::from_openssh(pem.as_str()) + .map_err(|e| anyhow::anyhow!("parse signing.key for device '{name}': {e}"))?; + let keypair = private + .key_data() + .ed25519() + .ok_or_else(|| anyhow::anyhow!("signing.key for device '{name}' is not ed25519"))?; + // Ed25519PrivateKey::as_ref() yields &[u8; 32] (verified: ssh-key 0.6.7 + // private/ed25519.rs:42). Copy into a Zeroizing array so the seed is wiped. + let mut seed = Zeroizing::new([0u8; 32]); + seed.copy_from_slice(keypair.private.as_ref()); + Ok(seed) +} + /// Load the deploy private key for a device. #[allow(dead_code)] pub fn load_deploy_key(name: &str) -> Result> { @@ -127,6 +169,53 @@ pub fn delete_device_keys(name: &str) -> Result<()> { Ok(()) } +#[cfg(test)] +mod seed_helper_tests { + use super::*; + use std::sync::Mutex; + + // dirs::config_dir() reads process-wide env; serialize these tests. + static ENV_LOCK: Mutex<()> = Mutex::new(()); + + #[test] + fn current_device_seed_and_pubkey_round_trip() { + let _guard = ENV_LOCK.lock().unwrap(); + let tmp = tempfile::tempdir().unwrap(); + let prev_xdg = std::env::var_os("XDG_CONFIG_HOME"); + std::env::set_var("XDG_CONFIG_HOME", tmp.path()); + + // Generate a real ed25519 device keypair (OpenSSH text) via core. + let (private_openssh, public_openssh) = + relicario_core::device::generate_keypair().unwrap(); + + // Lay out devices/test-dev/{signing.key,signing.pub} + devices/current. + let dir = device_dir("test-dev").unwrap(); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("signing.key"), private_openssh.as_str()).unwrap(); + std::fs::write(dir.join("signing.pub"), &public_openssh).unwrap(); + set_current_device("test-dev").unwrap(); + + // pubkey helper returns exactly the stored OpenSSH public line. + let got_pub = current_device_pubkey().unwrap(); + assert_eq!(got_pub.trim(), public_openssh.trim()); + + // seed helper returns the 32-byte ed25519 seed; re-derive the public + // key from it and confirm it matches. + let seed = current_device_seed().unwrap(); + let signing = ed25519_dalek::SigningKey::from_bytes(&seed); + let derived = signing.verifying_key(); + let parsed_pub = ssh_key::PublicKey::from_openssh(&public_openssh).unwrap(); + let parsed_bytes: &[u8] = parsed_pub.key_data().ed25519().unwrap().as_ref(); + assert_eq!(derived.as_bytes().as_slice(), parsed_bytes); + + // restore env + match prev_xdg { + Some(v) => std::env::set_var("XDG_CONFIG_HOME", v), + None => std::env::remove_var("XDG_CONFIG_HOME"), + } + } +} + /// Configure git in `vault_root` to: /// - sign commits with the device's signing key (SSH format) /// - push via SSH using the device's deploy key diff --git a/crates/relicario-cli/src/main.rs b/crates/relicario-cli/src/main.rs index 9ffd212..639c5be 100644 --- a/crates/relicario-cli/src/main.rs +++ b/crates/relicario-cli/src/main.rs @@ -9,6 +9,7 @@ mod helpers; mod parse; mod prompt; mod session; +mod org_session; use std::path::PathBuf; @@ -206,6 +207,15 @@ enum Commands { #[command(subcommand)] cmd: RecoveryQrCmd, }, + + /// Manage a multi-user org vault. + Org { + /// Path to the org vault directory (overrides RELICARIO_ORG_DIR). + #[arg(long, global = true)] + dir: Option, + #[command(subcommand)] + subcommand: OrgCommands, + }, } #[derive(Subcommand)] @@ -421,6 +431,16 @@ pub(crate) enum RecoveryQrCmd { Unwrap, } +#[derive(clap::Subcommand)] +pub(crate) enum OrgCommands { + /// Create a new org vault. + Init { + #[arg(long)] + name: String, + }, + // Admin + item subcommands are added by later tasks (B10-B14). +} + fn main() -> Result<()> { let cli = Cli::parse(); match cli.command { @@ -455,6 +475,16 @@ fn main() -> Result<()> { Commands::Rate { passphrase } => commands::rate::cmd_rate(passphrase), Commands::Device { action } => commands::device::cmd_device(action), Commands::RecoveryQr { cmd } => commands::recovery_qr::cmd_recovery_qr(cmd), + Commands::Org { dir, subcommand } => { + let dir_path = dir.as_deref(); + match subcommand { + OrgCommands::Init { name } => { + let d = crate::org_session::org_dir(dir_path)?; + commands::org::run_init(&d, &name)?; + Ok(()) + } + } + } } } diff --git a/crates/relicario-cli/src/org_session.rs b/crates/relicario-cli/src/org_session.rs new file mode 100644 index 0000000..6b3357d --- /dev/null +++ b/crates/relicario-cli/src/org_session.rs @@ -0,0 +1,320 @@ +//! Unlocked org vault session: holds the org master key for the duration of a +//! CLI invocation. + +use std::fs; +use std::path::{Path, PathBuf}; + +use anyhow::{bail, Context, Result}; +use zeroize::Zeroizing; + +use relicario_core::{ + decrypt_item, decrypt_org_manifest, encrypt_item, encrypt_org_manifest, + Item, ItemId, MemberId, OrgCollections, OrgManifest, OrgMember, OrgMembers, OrgMeta, +}; + +pub struct UnlockedOrgVault { + pub root: PathBuf, + pub org_key: Zeroizing<[u8; 32]>, +} + +impl UnlockedOrgVault { + pub fn root(&self) -> &Path { &self.root } + pub fn key(&self) -> &Zeroizing<[u8; 32]> { &self.org_key } + + pub fn manifest_path(&self) -> PathBuf { self.root.join("manifest.enc") } + + /// Collection-scoped item path: `items//.enc`. + /// The leading slug segment is what the pre-receive hook authorizes against + /// members.json — it never decrypts the blob. The slug must be non-empty and + /// already validated. + pub fn item_path(&self, collection_slug: &str, id: &ItemId) -> PathBuf { + self.root + .join("items") + .join(collection_slug) + .join(format!("{}.enc", id.as_str())) + } + + pub fn member_key_path(&self, id: &MemberId) -> PathBuf { + self.root.join("keys").join(format!("{}.enc", id.as_str())) + } + pub fn members_path(&self) -> PathBuf { self.root.join("members.json") } + pub fn collections_path(&self) -> PathBuf { self.root.join("collections.json") } + pub fn org_meta_path(&self) -> PathBuf { self.root.join("org.json") } + + pub fn load_meta(&self) -> Result { + let s = fs::read_to_string(self.org_meta_path()).context("read org.json")?; + Ok(serde_json::from_str(&s).context("parse org.json")?) + } + + pub fn load_members(&self) -> Result { + let s = fs::read_to_string(self.members_path()).context("read members.json")?; + Ok(serde_json::from_str(&s).context("parse members.json")?) + } + + pub fn save_members(&self, members: &OrgMembers) -> Result<()> { + let json = serde_json::to_string_pretty(members)?; + atomic_write(&self.members_path(), json.as_bytes()) + } + + pub fn load_collections(&self) -> Result { + let s = fs::read_to_string(self.collections_path()).context("read collections.json")?; + Ok(serde_json::from_str(&s).context("parse collections.json")?) + } + + pub fn save_collections(&self, collections: &OrgCollections) -> Result<()> { + let json = serde_json::to_string_pretty(collections)?; + atomic_write(&self.collections_path(), json.as_bytes()) + } + + pub fn load_manifest(&self) -> Result { + let bytes = fs::read(self.manifest_path()).context("read manifest.enc")?; + Ok(decrypt_org_manifest(&bytes, &self.org_key)?) + } + + pub fn save_manifest(&self, manifest: &OrgManifest) -> Result<()> { + let bytes = encrypt_org_manifest(manifest, &self.org_key)?; + atomic_write(&self.manifest_path(), &bytes) + } + + /// Encrypt + write an item under its collection directory, creating the + /// directory if needed. Returns the repo-relative path for git staging. + pub fn save_item(&self, collection_slug: &str, item: &Item) -> Result { + let path = self.item_path(collection_slug, &item.id); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .with_context(|| format!("create {}", parent.display()))?; + } + let bytes = encrypt_item(item, &self.org_key)?; + atomic_write(&path, &bytes)?; + Ok(format!("items/{}/{}.enc", collection_slug, item.id.as_str())) + } + + /// Read + decrypt an item from its collection directory. + pub fn load_item(&self, collection_slug: &str, id: &ItemId) -> Result { + let path = self.item_path(collection_slug, id); + let bytes = fs::read(&path) + .with_context(|| format!("read item {}", path.display()))?; + Ok(decrypt_item(&bytes, &self.org_key)?) + } + + /// Delete an item blob. Missing file is not an error (partial-write + /// recovery, same as the personal-vault purge path). + pub fn remove_item(&self, collection_slug: &str, id: &ItemId) -> Result<()> { + let path = self.item_path(collection_slug, id); + match fs::remove_file(&path) { + Ok(()) => Ok(()), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(e) => Err(anyhow::Error::from(e) + .context(format!("delete {}", path.display()))), + } + } + + /// Bail unless `member` has `slug` in their collection grants. The slug + /// existence check is done separately by the caller against collections.json. + pub fn ensure_grant(member: &OrgMember, slug: &str) -> Result<()> { + if member.collections.iter().any(|c| c == slug) { + Ok(()) + } else { + bail!( + "access denied: you do not have a grant for collection `{slug}` — ask an admin to run `relicario org grant`" + ) + } + } + + /// Load members.json and find the caller's member entry by matching the + /// current device's ed25519 fingerprint against each member's pubkey + /// fingerprint. Fingerprint comparison (not raw OpenSSH-string equality) + /// tolerates comment/whitespace differences in the serialized key. + pub fn current_member(&self) -> Result { + let device_fp = current_device_fingerprint()?; + let members = self.load_members()?; + members + .members + .into_iter() + .find(|m| { + relicario_core::fingerprint(&m.ed25519_pubkey) + .ok() + .as_deref() + == Some(device_fp.as_str()) + }) + .ok_or_else(|| { + anyhow::anyhow!( + "your device key is not registered in this org — ask an admin to run `org add-member`" + ) + }) + } +} + +/// Locate the org vault root from RELICARIO_ORG_DIR env var or --dir flag value. +pub fn org_dir(dir_flag: Option<&std::path::Path>) -> Result { + if let Some(d) = dir_flag { + return Ok(d.to_path_buf()); + } + if let Ok(v) = std::env::var("RELICARIO_ORG_DIR") { + return Ok(PathBuf::from(v)); + } + bail!("org vault location required: set RELICARIO_ORG_DIR or pass --dir ") +} + +/// Open an org vault: locate the root, read members.json to find the caller's +/// member entry (by ed25519 fingerprint), then unwrap their keys/.enc to +/// recover the org master key. +pub fn open_org_vault(dir_flag: Option<&std::path::Path>) -> Result { + let root = org_dir(dir_flag)?; + + let device_fp = current_device_fingerprint()?; + let members_json = fs::read_to_string(root.join("members.json")) + .context("read members.json — is this an org vault?")?; + let members: OrgMembers = serde_json::from_str(&members_json).context("parse members.json")?; + let member = members + .members + .iter() + .find(|m| { + relicario_core::fingerprint(&m.ed25519_pubkey) + .ok() + .as_deref() + == Some(device_fp.as_str()) + }) + .ok_or_else(|| anyhow::anyhow!("your device key is not in this org"))?; + + // Load this member's wrapped key blob. + let key_path = root + .join("keys") + .join(format!("{}.enc", member.member_id.as_str())); + let wrapped = + fs::read(&key_path).with_context(|| format!("read {}", key_path.display()))?; + + // Recover the device ed25519 seed and unwrap. + let seed = current_device_seed()?; + let org_key = relicario_core::unwrap_org_key(&wrapped, &seed)?; + + Ok(UnlockedOrgVault { root, org_key }) +} + +/// OpenSSH SHA-256 fingerprint of the active device's signing key. +fn current_device_fingerprint() -> Result { + let name = crate::device::current_device()? + .ok_or_else(|| anyhow::anyhow!("no active device — run `relicario device add` first"))?; + let pub_path = crate::device::device_dir(&name)?.join("signing.pub"); + let pubkey = fs::read_to_string(&pub_path) + .with_context(|| format!("read {}", pub_path.display()))?; + Ok(relicario_core::fingerprint(pubkey.trim())?) +} + +/// Recover the active device's ed25519 seed (the 32-byte private scalar source) +/// from its OpenSSH `signing.key`, for ECIES unwrap. +fn current_device_seed() -> Result> { + let name = crate::device::current_device()? + .ok_or_else(|| anyhow::anyhow!("no active device — run `relicario device add` first"))?; + let key_pem = crate::device::load_signing_key(&name)?; + let private = ssh_key::PrivateKey::from_openssh(key_pem.as_str()) + .map_err(|e| anyhow::anyhow!("parse device signing key: {e}"))?; + let ed = private + .key_data() + .ed25519() + .ok_or_else(|| anyhow::anyhow!("device signing key is not ed25519"))?; + // Ed25519PrivateKey derefs to its 32-byte seed. + let seed_bytes: &[u8] = ed.private.as_ref(); + if seed_bytes.len() != 32 { + anyhow::bail!("ed25519 seed has wrong length: {}", seed_bytes.len()); + } + let mut seed = Zeroizing::new([0u8; 32]); + seed.copy_from_slice(seed_bytes); + Ok(seed) +} + +pub(crate) fn atomic_write(path: &Path, data: &[u8]) -> Result<()> { + let mut tmp = path.as_os_str().to_owned(); + tmp.push(".tmp"); + let tmp = PathBuf::from(tmp); + fs::write(&tmp, data).with_context(|| format!("write {}", tmp.display()))?; + fs::rename(&tmp, path).with_context(|| format!("rename {}", tmp.display()))?; + Ok(()) +} + +/// Run `git ` in the org repo, capturing output and replaying it on +/// failure. Unlike `crate::helpers::git_run`, this does NOT inject +/// `commit.gpgsign=false` / `core.hooksPath=/dev/null`: org commits MUST be +/// signed (the pre-receive hook verifies every commit's signature), and the +/// repo's signing config is established by `configure_git_signing` during +/// `org init`. +pub(crate) fn org_git_run(root: &Path, args: &[&str], context: &str) -> Result<()> { + let output = std::process::Command::new("git") + .current_dir(root) + .args(args) + .output() + .with_context(|| format!("{context}: failed to spawn git"))?; + if !output.status.success() { + if !output.stdout.is_empty() { + eprint!("{}", String::from_utf8_lossy(&output.stdout)); + } + if !output.stderr.is_empty() { + eprint!("{}", String::from_utf8_lossy(&output.stderr)); + } + anyhow::bail!("{context}: git failed ({})", output.status); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + use std::fs; + + fn make_vault(key: Zeroizing<[u8; 32]>) -> (TempDir, UnlockedOrgVault) { + let dir = TempDir::new().unwrap(); + let root = dir.path().to_path_buf(); + fs::create_dir_all(root.join("items")).unwrap(); + fs::create_dir_all(root.join("keys")).unwrap(); + let vault = UnlockedOrgVault { root, org_key: key }; + (dir, vault) + } + + #[test] + fn unlocked_org_vault_paths() { + let key = Zeroizing::new([0u8; 32]); + let (dir, vault) = make_vault(key); + let root = dir.path().to_path_buf(); + assert_eq!(vault.manifest_path(), root.join("manifest.enc")); + assert_eq!( + vault.member_key_path(&MemberId("abc0def1abc0def1".into())), + root.join("keys/abc0def1abc0def1.enc") + ); + assert_eq!( + vault.item_path("prod", &relicario_core::ItemId("0123456789abcdef".into())), + root.join("items/prod/0123456789abcdef.enc") + ); + } + + #[test] + fn save_and_load_manifest() { + let key = Zeroizing::new([0xAAu8; 32]); + let (dir, vault) = make_vault(key); + let _ = dir; // keep alive + let mut m = OrgManifest::new(); + m.entries.push(relicario_core::OrgManifestEntry { + id: relicario_core::ItemId::new(), + r#type: relicario_core::ItemType::SecureNote, + title: "test".into(), + tags: vec![], + modified: 0, + trashed_at: None, + collection: "prod".into(), + }); + vault.save_manifest(&m).unwrap(); + let loaded = vault.load_manifest().unwrap(); + assert_eq!(loaded.entries.len(), 1); + } + + #[test] + fn save_and_load_members() { + let key = Zeroizing::new([0u8; 32]); + let (dir, vault) = make_vault(key); + let _ = dir; + let members = OrgMembers::new(); + vault.save_members(&members).unwrap(); + let loaded = vault.load_members().unwrap(); + assert_eq!(loaded.schema_version, 1); + } +} diff --git a/crates/relicario-cli/tests/org_init.rs b/crates/relicario-cli/tests/org_init.rs new file mode 100644 index 0000000..a2d708b --- /dev/null +++ b/crates/relicario-cli/tests/org_init.rs @@ -0,0 +1,24 @@ +use tempfile::TempDir; + +fn run(args: &[&str]) -> std::process::Output { + std::process::Command::new(env!("CARGO_BIN_EXE_relicario")) + .args(args) + .output() + .expect("run relicario") +} + +#[test] +#[ignore] // requires a device key on disk; run manually or via org_init_signing +fn org_init_creates_expected_files() { + let dir = TempDir::new().unwrap(); + let path = dir.path().to_str().unwrap(); + // `--dir` is a subcommand-scoped global on `org` (B14), so it must come + // AFTER `org init`, not before it (matches B10's OrgFixture). + let out = run(&["org", "init", "--dir", path, "--name", "Test Org"]); + assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr)); + assert!(dir.path().join("org.json").exists()); + assert!(dir.path().join("members.json").exists()); + assert!(dir.path().join("collections.json").exists()); + assert!(dir.path().join("manifest.enc").exists()); + assert!(dir.path().join(".git").exists()); +} diff --git a/crates/relicario-cli/tests/org_init_signing.rs b/crates/relicario-cli/tests/org_init_signing.rs new file mode 100644 index 0000000..01a6926 --- /dev/null +++ b/crates/relicario-cli/tests/org_init_signing.rs @@ -0,0 +1,145 @@ +use std::fs; +use std::path::Path; +use std::process::Command; +use tempfile::TempDir; + +fn relicario(config_home: &Path, args: &[&str]) -> std::process::Output { + Command::new(env!("CARGO_BIN_EXE_relicario")) + .env("XDG_CONFIG_HOME", config_home) + .env("HOME", config_home) // belt-and-suspenders for dirs on all platforms + .args(args) + .output() + .expect("run relicario") +} + +/// Like relicario() but also injects the git committer identity so that +/// `git commit` inside `org init` doesn't fail with "Please tell me who you are." +fn relicario_with_git_identity(config_home: &Path, args: &[&str]) -> std::process::Output { + Command::new(env!("CARGO_BIN_EXE_relicario")) + .env("XDG_CONFIG_HOME", config_home) + .env("HOME", config_home) + .env("GIT_AUTHOR_NAME", "Test Device") + .env("GIT_AUTHOR_EMAIL", "test@relicario.test") + .env("GIT_COMMITTER_NAME", "Test Device") + .env("GIT_COMMITTER_EMAIL", "test@relicario.test") + .args(args) + .output() + .expect("run relicario") +} + +fn git(repo: &Path, args: &[&str]) -> std::process::Output { + Command::new("git") + .current_dir(repo) + .args(args) + .output() + .expect("run git") +} + +/// Lay out device keys directly under `/relicario/devices//` +/// and set `devices/current` — mirrors the B2 seed_helper_tests approach. +/// Returns the OpenSSH public key string so the caller can build an allowed_signers +/// file for `git verify-commit`. +fn seed_device(config_home: &Path, name: &str) -> String { + let (priv_openssh, pub_openssh) = + relicario_core::device::generate_keypair().expect("generate_keypair"); + + let dev_dir = config_home + .join("relicario") + .join("devices") + .join(name); + fs::create_dir_all(&dev_dir).expect("create device dir"); + let signing_key_path = dev_dir.join("signing.key"); + fs::write(&signing_key_path, priv_openssh.as_str()) + .expect("write signing.key"); + // ssh requires 0600 on private key files or it refuses to use them. + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&signing_key_path, fs::Permissions::from_mode(0o600)) + .expect("chmod signing.key"); + } + fs::write(dev_dir.join("signing.pub"), &pub_openssh) + .expect("write signing.pub"); + // Also write stub deploy key files so configure_git_signing doesn't trip on + // a missing deploy.key path (the git config value just points to the file; + // the file itself is never read during org init). + fs::write(dev_dir.join("deploy.key"), "").expect("write stub deploy.key"); + fs::write(dev_dir.join("deploy.pub"), "").expect("write stub deploy.pub"); + + // Set this device as current. + let devices_dir = config_home.join("relicario").join("devices"); + fs::write(devices_dir.join("current"), format!("{name}\n")) + .expect("write current"); + + pub_openssh +} + +#[test] +fn org_init_produces_a_signed_initial_commit() { + let cfg = TempDir::new().unwrap(); + let org = TempDir::new().unwrap(); + + // Lay out the device key directly (no `device add` needed — it requires Gitea). + let pub_openssh = seed_device(cfg.path(), "test-dev"); + + // Initialize the org vault. `--dir` comes AFTER `org init` (B14 global). + // Inject git identity so the commit doesn't fail "Please tell me who you are." + let init = relicario_with_git_identity( + cfg.path(), + &["org", "init", "--dir", org.path().to_str().unwrap(), "--name", "Acme"], + ); + assert!( + init.status.success(), + "org init failed:\nstdout: {}\nstderr: {}", + String::from_utf8_lossy(&init.stdout), + String::from_utf8_lossy(&init.stderr) + ); + + // The org repo must be configured to sign. + let cfg_out = git(org.path(), &["config", "commit.gpgsign"]); + assert_eq!( + String::from_utf8_lossy(&cfg_out.stdout).trim(), + "true", + "org repo must have commit.gpgsign=true" + ); + + // The HEAD commit object must carry a signature header. + let head = git(org.path(), &["cat-file", "commit", "HEAD"]); + let body = String::from_utf8_lossy(&head.stdout); + assert!( + body.contains("gpgsig "), + "HEAD commit must be signed (no gpgsig header found):\n{body}" + ); + + // Configure an allowed_signers file so `git verify-commit` can validate the + // SSH signature. The principal must match the committer email injected above. + let allowed_signers_path = cfg.path().join("allowed_signers"); + let allowed_line = format!("test@relicario.test {}", pub_openssh.trim()); + fs::write(&allowed_signers_path, format!("{allowed_line}\n")) + .expect("write allowed_signers"); + git( + org.path(), + &[ + "config", + "gpg.ssh.allowedSignersFile", + allowed_signers_path.to_str().unwrap(), + ], + ); + + // Now verify-commit should succeed. + let verify = git(org.path(), &["verify-commit", "HEAD"]); + assert!( + verify.status.success(), + "git verify-commit HEAD failed:\nstdout: {}\nstderr: {}", + String::from_utf8_lossy(&verify.stdout), + String::from_utf8_lossy(&verify.stderr) + ); + + // The commit body must carry the org-init action trailer. + let log_out = git(org.path(), &["log", "-1", "--format=%B"]); + let commit_body = String::from_utf8_lossy(&log_out.stdout); + assert!( + commit_body.contains("Relicario-Action: org-init"), + "HEAD commit body must contain 'Relicario-Action: org-init' trailer:\n{commit_body}" + ); +}