feat(core/org): org types, manifest, and X25519 key wrap/unwrap (Zeroizing KDF)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TJo44YM3UbBjro2fG6NrKy
This commit is contained in:
adlee-was-taken
2026-06-19 23:07:13 -04:00
parent da4dc44f80
commit ca4936cf95
2 changed files with 497 additions and 0 deletions

View File

@@ -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};

View File

@@ -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<String>,
pub added_at: i64,
pub added_by: MemberId,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OrgMembers {
pub schema_version: u32,
pub members: Vec<OrgMember>,
}
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<CollectionDef>,
}
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<String>,
pub modified: i64,
#[serde(skip_serializing_if = "Option::is_none")]
pub trashed_at: Option<i64>,
/// Collection this item belongs to.
pub collection: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OrgManifest {
pub schema_version: u32,
pub entries: Vec<OrgManifestEntry>,
}
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<x25519_dalek::PublicKey> {
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<Vec<u8>> {
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<Vec<u8>> = 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<Zeroizing<[u8; 32]>> {
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<Vec<u8>> = 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/<id>.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());
}
}