diff --git a/crates/relicario-cli/src/main.rs b/crates/relicario-cli/src/main.rs index 9ffd212..4d2eb7a 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; 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); + } +}