//! 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, AttachmentId, EncryptedAttachment, Item, ItemId, MemberId, OrgCollections, OrgManifest, OrgMember, OrgMembers, OrgMeta, }; /// Default per-attachment cap for org vaults. Org vaults have no settings.enc, /// so this mirrors the personal-vault default /// `AttachmentCaps::per_attachment_max_bytes` at /// crates/relicario-core/src/settings.rs:116. // Attachment API — consumed by `org add document`, Document edit, and purge // landing in Tasks C2/C3; `load_attachment` additionally backs a future // org document read/extract. Allow dead_code until those consumers land. #[allow(dead_code)] pub const DEFAULT_ORG_ATTACHMENT_MAX_BYTES: u64 = 10 * 1024 * 1024; 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") } // OrgMeta accessors — part of the UnlockedOrgVault path/loader API surface // (parallel to members_path/collections_path + load_members), retained for // completeness. No command consumes org.json yet; surfacing the org // name/id in `org status` is a tracked follow-up, so allow until then. #[allow(dead_code)] pub fn org_meta_path(&self) -> PathBuf { self.root.join("org.json") } #[allow(dead_code)] 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()))), } } #[allow(dead_code)] pub fn attachment_path(&self, collection_slug: &str, item_id: &ItemId, att_id: &AttachmentId) -> PathBuf { self.root.join("attachments").join(collection_slug) .join(item_id.as_str()).join(format!("{}.enc", att_id.as_str())) } /// Encrypt-already-done blob: persist it and return the repo-relative path for git staging. #[allow(dead_code)] pub fn save_attachment(&self, collection_slug: &str, item_id: &ItemId, enc: &EncryptedAttachment) -> Result { let path = self.attachment_path(collection_slug, item_id, &enc.id); if let Some(parent) = path.parent() { fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?; } atomic_write(&path, &enc.bytes)?; Ok(format!("attachments/{}/{}/{}.enc", collection_slug, item_id.as_str(), enc.id.as_str())) } #[allow(dead_code)] pub fn load_attachment(&self, collection_slug: &str, item_id: &ItemId, att_id: &AttachmentId) -> Result>> { let path = self.attachment_path(collection_slug, item_id, att_id); let bytes = fs::read(&path).with_context(|| format!("read attachment {}", path.display()))?; Ok(relicario_core::decrypt_attachment(&bytes, &self.org_key)?) } /// Remove an item's whole attachment directory. Missing dir is NOT an error /// (mirrors `remove_item`'s NotFound-tolerant behavior, for partial-write recovery). #[allow(dead_code)] pub fn remove_item_attachments(&self, collection_slug: &str, item_id: &ItemId) -> Result<()> { let dir = self.root.join("attachments").join(collection_slug).join(item_id.as_str()); match fs::remove_dir_all(&dir) { Ok(()) => Ok(()), Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), Err(e) => Err(anyhow::Error::from(e).context(format!("remove {}", dir.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 = crate::device::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) 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 attachment_round_trip_collection_scoped() { use relicario_core::encrypt_attachment; let key = Zeroizing::new([7u8; 32]); let (dir, vault) = make_vault(key); let _ = dir; // keep tempdir alive let item_id = ItemId::new(); let enc = encrypt_attachment(b"hello world", &vault.org_key, DEFAULT_ORG_ATTACHMENT_MAX_BYTES).unwrap(); let rel = vault.save_attachment("eng", &item_id, &enc).unwrap(); assert_eq!(rel, format!("attachments/eng/{}/{}.enc", item_id.as_str(), enc.id.as_str())); let got = vault.load_attachment("eng", &item_id, &enc.id).unwrap(); assert_eq!(got.as_slice(), b"hello world"); vault.remove_item_attachments("eng", &item_id).unwrap(); assert!(vault.load_attachment("eng", &item_id, &enc.id).is_err()); } #[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); } }