//! Org vault types, crypto, and schema for multi-user self-hosted deployments. use rand::{rngs::OsRng, RngCore}; use serde::{Deserialize, Serialize}; use zeroize::Zeroizing; use crate::error::{RelicarioError, Result}; use crate::ids::ItemId; use crate::item_types::ItemType; // ── IDs ────────────────────────────────────────────────────────────────────── #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(transparent)] pub struct OrgId(pub String); #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(transparent)] pub struct MemberId(pub String); impl OrgId { pub fn new() -> Self { let mut bytes = [0u8; 8]; OsRng.fill_bytes(&mut bytes); Self(hex::encode(bytes)) } pub fn as_str(&self) -> &str { &self.0 } } impl Default for OrgId { fn default() -> Self { Self::new() } } impl MemberId { pub fn new() -> Self { let mut bytes = [0u8; 8]; OsRng.fill_bytes(&mut bytes); Self(hex::encode(bytes)) } pub fn as_str(&self) -> &str { &self.0 } pub fn is_valid(&self) -> bool { self.0.len() == 16 && self.0.chars().all(|c| c.is_ascii_hexdigit()) } } impl Default for MemberId { fn default() -> Self { Self::new() } } // ── Roles ──────────────────────────────────────────────────────────────────── #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum OrgRole { Owner, Admin, Member, } impl OrgRole { pub fn can_manage_members(&self) -> bool { matches!(self, OrgRole::Owner | OrgRole::Admin) } pub fn can_manage_owners(&self) -> bool { matches!(self, OrgRole::Owner) } } // ── Members ────────────────────────────────────────────────────────────────── #[derive(Debug, Clone, Serialize, Deserialize)] pub struct OrgMember { pub member_id: MemberId, pub display_name: String, pub role: OrgRole, /// SSH public key string (openssh format: "ssh-ed25519 AAAA...") pub ed25519_pubkey: String, /// Collection slugs this member can access. #[serde(default)] pub collections: Vec, pub added_at: i64, pub added_by: MemberId, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct OrgMembers { pub schema_version: u32, pub members: Vec, } impl OrgMembers { pub fn new() -> Self { Self { schema_version: 1, members: Vec::new() } } pub fn find_by_id(&self, id: &MemberId) -> Option<&OrgMember> { self.members.iter().find(|m| &m.member_id == id) } pub fn find_by_id_mut(&mut self, id: &MemberId) -> Option<&mut OrgMember> { self.members.iter_mut().find(|m| &m.member_id == id) } pub fn validate(&self) -> Result<()> { for m in &self.members { if !m.member_id.is_valid() { return Err(RelicarioError::Format( format!("invalid member_id: {}", m.member_id.0) )); } } Ok(()) } } impl Default for OrgMembers { fn default() -> Self { Self::new() } } // ── Collections ─────────────────────────────────────────────────────────────── #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CollectionDef { pub slug: String, pub display_name: String, pub created_by: MemberId, pub created_at: i64, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct OrgCollections { pub schema_version: u32, pub collections: Vec, } impl OrgCollections { pub fn new() -> Self { Self { schema_version: 1, collections: Vec::new() } } pub fn contains_slug(&self, slug: &str) -> bool { self.collections.iter().any(|c| c.slug == slug) } pub fn validate(&self) -> Result<()> { for c in &self.collections { if c.slug.is_empty() || c.slug.contains('/') || c.slug.contains('.') { return Err(RelicarioError::Format( format!("invalid collection slug: {:?}", c.slug) )); } } Ok(()) } } impl Default for OrgCollections { fn default() -> Self { Self::new() } } // ── Org meta ───────────────────────────────────────────────────────────────── #[derive(Debug, Clone, Serialize, Deserialize)] pub struct OrgMeta { pub schema_version: u32, pub org_id: OrgId, pub display_name: String, pub created_at: i64, } impl OrgMeta { pub fn new(display_name: String) -> Self { Self { schema_version: 1, org_id: OrgId::new(), display_name, created_at: crate::time::now_unix(), } } } // ── Org manifest ───────────────────────────────────────────────────────────── #[derive(Debug, Clone, Serialize, Deserialize)] pub struct OrgManifestEntry { pub id: ItemId, pub r#type: ItemType, pub title: String, #[serde(default)] pub tags: Vec, pub modified: i64, #[serde(skip_serializing_if = "Option::is_none")] pub trashed_at: Option, /// Collection this item belongs to. pub collection: String, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct OrgManifest { pub schema_version: u32, pub entries: Vec, } impl OrgManifest { pub fn new() -> Self { Self { schema_version: 1, entries: Vec::new() } } /// Return only entries whose collection is in `member.collections`. pub fn filter_for_member(&self, member: &OrgMember) -> Self { let granted: std::collections::HashSet<&str> = member.collections.iter().map(|s| s.as_str()).collect(); Self { schema_version: self.schema_version, entries: self.entries.iter() .filter(|e| granted.contains(e.collection.as_str())) .cloned() .collect(), } } } impl Default for OrgManifest { fn default() -> Self { Self::new() } } // ── Key wrap / unwrap (ECIES: X25519 + XChaCha20-Poly1305) ─────────────────── /// Generate a random 256-bit org master key. pub fn generate_org_key() -> Zeroizing<[u8; 32]> { let mut key = Zeroizing::new([0u8; 32]); OsRng.fill_bytes(key.as_mut()); key } /// Derive an X25519 static secret from an ed25519 seed (standard RFC 7748 path). fn ed25519_seed_to_x25519_secret(seed: &[u8; 32]) -> x25519_dalek::StaticSecret { use sha2::{Digest, Sha512}; let h = Sha512::digest(seed.as_ref()); let mut scalar = [0u8; 32]; scalar.copy_from_slice(&h[..32]); // RFC 7748 clamping scalar[0] &= 248; scalar[31] &= 127; scalar[31] |= 64; x25519_dalek::StaticSecret::from(scalar) } /// Parse an OpenSSH ed25519 public key string and return its X25519 form. fn openssh_ed25519_to_x25519_pk(openssh: &str) -> Result { use ssh_key::PublicKey; let pk = PublicKey::from_openssh(openssh.trim()) .map_err(|e| RelicarioError::Format(format!("bad SSH pubkey: {e}")))?; let ed_bytes = pk.key_data().ed25519() .ok_or_else(|| RelicarioError::Format("expected ed25519 key".into()))? .0; let verifying = ed25519_dalek::VerifyingKey::from_bytes(&ed_bytes) .map_err(|e| RelicarioError::Format(format!("bad ed25519 pubkey: {e}")))?; Ok(x25519_dalek::PublicKey::from(verifying.to_montgomery().to_bytes())) } /// Wrap `org_key` for a recipient identified by their OpenSSH ed25519 public key. /// /// Output layout: `ephemeral_x25519_pk(32) || version(1) || nonce(24) || ciphertext+tag` pub fn wrap_org_key(org_key: &Zeroizing<[u8; 32]>, recipient_openssh_pubkey: &str) -> Result> { use sha2::{Digest, Sha256}; use x25519_dalek::EphemeralSecret; let recipient_pk = openssh_ed25519_to_x25519_pk(recipient_openssh_pubkey)?; let ephemeral_sk = EphemeralSecret::random_from_rng(OsRng); let ephemeral_pk = x25519_dalek::PublicKey::from(&ephemeral_sk); let shared = ephemeral_sk.diffie_hellman(&recipient_pk); // Domain-separated KDF. All intermediates carrying the DH secret are held in // Zeroizing so they are wiped on drop (H6). let mut kdf_input: Zeroizing> = Zeroizing::new(Vec::with_capacity(32 + 32 + 32)); kdf_input.extend_from_slice(shared.as_bytes()); kdf_input.extend_from_slice(ephemeral_pk.as_bytes()); kdf_input.extend_from_slice(recipient_pk.as_bytes()); // Copy the digest straight into a Zeroizing array. The GenericArray returned // by Sha256::digest is not Zeroize (generic-array's impl is feature-gated and // not enabled here), so we move the bytes into an owned [u8; 32] whose own // Zeroize impl wipes them on drop. let mut wrap_key: Zeroizing<[u8; 32]> = Zeroizing::new([0u8; 32]); wrap_key.copy_from_slice(&Sha256::digest(kdf_input.as_slice())); let encrypted = crate::crypto::encrypt(&wrap_key, org_key.as_ref())?; let mut out = Vec::with_capacity(32 + encrypted.len()); out.extend_from_slice(ephemeral_pk.as_bytes()); out.extend_from_slice(&encrypted); Ok(out) } /// Unwrap a key blob produced by `wrap_org_key` using the recipient's ed25519 seed. pub fn unwrap_org_key(wrapped: &[u8], ed25519_seed: &Zeroizing<[u8; 32]>) -> Result> { use sha2::{Digest, Sha256}; // Minimum: 32 (ephemeral_pk) + 41 (version+nonce+tag for 32-byte plaintext) if wrapped.len() < 32 + 41 { return Err(RelicarioError::Format("wrapped key blob too short".into())); } let mut eph_bytes = [0u8; 32]; eph_bytes.copy_from_slice(&wrapped[..32]); let ephemeral_pk = x25519_dalek::PublicKey::from(eph_bytes); let encrypted = &wrapped[32..]; let recipient_sk = ed25519_seed_to_x25519_secret(ed25519_seed); let recipient_pk = x25519_dalek::PublicKey::from(&recipient_sk); let shared = recipient_sk.diffie_hellman(&ephemeral_pk); let mut kdf_input: Zeroizing> = Zeroizing::new(Vec::with_capacity(32 + 32 + 32)); kdf_input.extend_from_slice(shared.as_bytes()); kdf_input.extend_from_slice(ephemeral_pk.as_bytes()); kdf_input.extend_from_slice(recipient_pk.as_bytes()); let mut wrap_key: Zeroizing<[u8; 32]> = Zeroizing::new([0u8; 32]); wrap_key.copy_from_slice(&Sha256::digest(kdf_input.as_slice())); let plaintext = Zeroizing::new(crate::crypto::decrypt(&wrap_key, encrypted)?); if plaintext.len() != 32 { return Err(RelicarioError::Format( format!("unwrapped key has wrong length: {}", plaintext.len()) )); } let mut key = Zeroizing::new([0u8; 32]); key.copy_from_slice(&plaintext); Ok(key) } // ── Tests ───────────────────────────────────────────────────────────────────── #[cfg(test)] mod tests { use super::*; #[test] fn member_id_is_16_hex_chars() { let id = MemberId::new(); assert_eq!(id.0.len(), 16); assert!(id.0.chars().all(|c| c.is_ascii_hexdigit())); } #[test] fn member_ids_are_unique() { let mut seen = std::collections::HashSet::new(); for _ in 0..1_000 { assert!(seen.insert(MemberId::new().0)); } } #[test] fn org_id_is_16_hex_chars() { let id = OrgId::new(); assert_eq!(id.0.len(), 16); assert!(id.0.chars().all(|c| c.is_ascii_hexdigit())); } #[test] fn org_role_can_manage_members() { assert!(OrgRole::Owner.can_manage_members()); assert!(OrgRole::Admin.can_manage_members()); assert!(!OrgRole::Member.can_manage_members()); } #[test] fn collection_slug_validation_rejects_slash() { let mut c = OrgCollections::new(); c.collections.push(CollectionDef { slug: "bad/slug".into(), display_name: "Bad".into(), created_by: MemberId::new(), created_at: 0, }); assert!(c.validate().is_err()); } #[test] fn filter_for_member_restricts_collections() { let mut manifest = OrgManifest::new(); manifest.entries.push(OrgManifestEntry { id: ItemId::new(), r#type: crate::item_types::ItemType::SecureNote, title: "A".into(), tags: vec![], modified: 0, trashed_at: None, collection: "prod".into(), }); manifest.entries.push(OrgManifestEntry { id: ItemId::new(), r#type: crate::item_types::ItemType::SecureNote, title: "B".into(), tags: vec![], modified: 0, trashed_at: None, collection: "dev".into(), }); let member = OrgMember { member_id: MemberId::new(), display_name: "Alice".into(), role: OrgRole::Member, ed25519_pubkey: String::new(), collections: vec!["prod".into()], added_at: 0, added_by: MemberId::new(), }; let filtered = manifest.filter_for_member(&member); assert_eq!(filtered.entries.len(), 1); assert_eq!(filtered.entries[0].collection, "prod"); } #[test] fn generate_org_key_is_32_bytes() { let key = generate_org_key(); assert_eq!(key.len(), 32); } /// Pinned RFC 8032 known-answer vector for the ed25519→X25519 map. The seed /// and expected X25519 public key are from ed25519-dalek's own reference /// test (`tests/x25519.rs`, section 7.1 vector A). The expected value is a /// HARD-CODED LITERAL — NOT recomputed by the production code path — so a /// correlated cross-crate-version regression in the birational map (where /// both our derivation and a naive re-derivation would drift together) is /// still caught. If this test ever fails after a dep bump, the wrap/unwrap /// keyspace changed and every existing `keys/.enc` blob is invalidated. #[test] fn ed25519_to_x25519_pinned_rfc8032_vector() { let seed: [u8; 32] = hex::decode("9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60") .unwrap() .try_into() .unwrap(); // Derive the X25519 *public* key the same way wrap/unwrap derives the // recipient's static secret from a seed. let secret = ed25519_seed_to_x25519_secret(&seed); let public = x25519_dalek::PublicKey::from(&secret); assert_eq!( hex::encode(public.as_bytes()), "d85e07ec22b0ad881537c2f44d662d1a143cf830c57aca4305d85c7a90f6b62e", ); } #[test] fn wrap_unwrap_round_trip() { // Generate an ed25519 keypair to act as the member's device key use ed25519_dalek::SigningKey; let mut seed = [0u8; 32]; OsRng.fill_bytes(&mut seed); let signing_key = SigningKey::from_bytes(&seed); let pubkey_openssh = ssh_key::PrivateKey::from( ssh_key::private::Ed25519Keypair::from(&signing_key), ) .public_key() .to_openssh() .expect("openssh"); let org_key = generate_org_key(); let wrapped = wrap_org_key(&org_key, &pubkey_openssh).expect("wrap"); let seed_zeroizing = Zeroizing::new(seed); let unwrapped = unwrap_org_key(&wrapped, &seed_zeroizing).expect("unwrap"); assert_eq!(*org_key, *unwrapped); } #[test] fn unwrap_with_wrong_seed_fails() { use ed25519_dalek::SigningKey; let mut seed = [0u8; 32]; OsRng.fill_bytes(&mut seed); let signing_key = SigningKey::from_bytes(&seed); let pubkey_openssh = ssh_key::PrivateKey::from( ssh_key::private::Ed25519Keypair::from(&signing_key), ) .public_key() .to_openssh() .expect("openssh"); let org_key = generate_org_key(); let wrapped = wrap_org_key(&org_key, &pubkey_openssh).expect("wrap"); let wrong_seed = Zeroizing::new([0xFFu8; 32]); let result = unwrap_org_key(&wrapped, &wrong_seed); assert!(result.is_err()); } }