diff --git a/crates/relicario-core/src/lib.rs b/crates/relicario-core/src/lib.rs index 829ac50..8599466 100644 --- a/crates/relicario-core/src/lib.rs +++ b/crates/relicario-core/src/lib.rs @@ -94,6 +94,11 @@ pub mod device; pub use device::{fingerprint, DeviceEntry, RevokedEntry, generate_keypair, sign, verify}; pub mod org; +pub use org::{ + generate_org_key, unwrap_org_key, wrap_org_key, + CollectionDef, MemberId, OrgCollections, OrgId, OrgManifest, + OrgManifestEntry, OrgMember, OrgMembers, OrgMeta, OrgRole, +}; pub mod tar_safe; pub use tar_safe::{safe_unpack_git_archive, DEFAULT_MAX_UNCOMPRESSED}; diff --git a/crates/relicario-core/src/org.rs b/crates/relicario-core/src/org.rs index 5444b35..5b02caa 100644 --- a/crates/relicario-core/src/org.rs +++ b/crates/relicario-core/src/org.rs @@ -1 +1,493 @@ //! 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 ephemeral_pk = x25519_dalek::PublicKey::from( + <[u8; 32]>::try_from(&wrapped[..32]).unwrap() + ); + 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); + } + + #[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()); + } +}