Compare commits
13 Commits
v0.7.0
...
feature/en
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
743a46f3d5 | ||
|
|
409ddce049 | ||
|
|
631608e6e5 | ||
|
|
ca4936cf95 | ||
|
|
da4dc44f80 | ||
|
|
f249395644 | ||
|
|
b655024320 | ||
|
|
8c19e3cfda | ||
|
|
21ed8d83b8 | ||
|
|
ac6756e698 | ||
|
|
2543ed30f6 | ||
|
|
2a6f6f1307 | ||
|
|
108965ec84 |
@@ -187,10 +187,26 @@ function devRole(letter) {
|
||||
|
||||
// ── Routing ───────────────────────────────────────────────────────────────────
|
||||
|
||||
const action = (args && args.action) || 'develop'
|
||||
const mode = (args && args.mode) || 'single'
|
||||
const release = args && args.release
|
||||
const context = args && args.context
|
||||
// Support both object args {action, mode, release} and space-separated string
|
||||
// "action mode release-label" (e.g. "develop multi enterprise-org-vault").
|
||||
let _args = args
|
||||
if (typeof args === 'string') {
|
||||
const parts = args.trim().split(/\s+/)
|
||||
// "develop multi enterprise-org-vault" → 3 parts
|
||||
// "develop enterprise-org-vault" → 2 parts (mode defaults to single)
|
||||
if (parts.length >= 3) {
|
||||
_args = { action: parts[0], mode: parts[1], release: parts.slice(2).join(' ') }
|
||||
} else if (parts.length === 2) {
|
||||
_args = { action: parts[0], mode: 'single', release: parts[1] }
|
||||
} else {
|
||||
_args = { action: parts[0] || 'develop' }
|
||||
}
|
||||
}
|
||||
|
||||
const action = (_args && _args.action) || 'develop'
|
||||
const mode = (_args && _args.mode) || 'single'
|
||||
const release = _args && _args.release
|
||||
const context = _args && _args.context
|
||||
|
||||
// ── ACTION: preflight ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
13
Cargo.lock
generated
13
Cargo.lock
generated
@@ -2209,6 +2209,7 @@ dependencies = [
|
||||
"thiserror 2.0.18",
|
||||
"unicode-normalization",
|
||||
"url",
|
||||
"x25519-dalek",
|
||||
"zeroize",
|
||||
"zstd",
|
||||
"zxcvbn",
|
||||
@@ -3709,6 +3710,18 @@ version = "0.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd"
|
||||
|
||||
[[package]]
|
||||
name = "x25519-dalek"
|
||||
version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277"
|
||||
dependencies = [
|
||||
"curve25519-dalek",
|
||||
"rand_core",
|
||||
"serde",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "yoke"
|
||||
version = "0.8.2"
|
||||
|
||||
@@ -16,6 +16,7 @@ sha2 = "0.10"
|
||||
sha1 = "0.10"
|
||||
hmac = "0.12"
|
||||
ed25519-dalek = { version = "2", features = ["rand_core"] }
|
||||
x25519-dalek = { version = "2", features = ["static_secrets"] }
|
||||
ssh-key = { version = "0.6", features = ["ed25519", "std"] }
|
||||
image = { version = "0.25", default-features = false, features = ["jpeg"] }
|
||||
|
||||
|
||||
@@ -78,8 +78,8 @@ pub use generators::{generate_passphrase, generate_password, rate_passphrase, va
|
||||
|
||||
pub mod vault;
|
||||
pub use vault::{
|
||||
decrypt_item, decrypt_manifest, decrypt_settings,
|
||||
encrypt_item, encrypt_manifest, encrypt_settings,
|
||||
decrypt_item, decrypt_manifest, decrypt_org_manifest, decrypt_settings,
|
||||
encrypt_item, encrypt_manifest, encrypt_org_manifest, encrypt_settings,
|
||||
};
|
||||
|
||||
pub mod imgsecret;
|
||||
@@ -93,6 +93,13 @@ pub use import_lastpass::{parse_lastpass_csv, ImportWarning};
|
||||
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};
|
||||
|
||||
|
||||
494
crates/relicario-core/src/org.rs
Normal file
494
crates/relicario-core/src/org.rs
Normal file
@@ -0,0 +1,494 @@
|
||||
//! 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 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<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);
|
||||
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/<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());
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ use crate::crypto::{decrypt, encrypt};
|
||||
use crate::error::Result;
|
||||
use crate::item::Item;
|
||||
use crate::manifest::Manifest;
|
||||
use crate::org::OrgManifest;
|
||||
use crate::settings::VaultSettings;
|
||||
|
||||
pub fn encrypt_item(item: &Item, master_key: &Zeroizing<[u8; 32]>) -> Result<Vec<u8>> {
|
||||
@@ -52,6 +53,19 @@ pub fn decrypt_settings(encrypted: &[u8], master_key: &Zeroizing<[u8; 32]>) -> R
|
||||
Ok(settings)
|
||||
}
|
||||
|
||||
pub fn encrypt_org_manifest(manifest: &OrgManifest, org_key: &Zeroizing<[u8; 32]>) -> Result<Vec<u8>> {
|
||||
let json = serde_json::to_vec(manifest)?;
|
||||
let plaintext = Zeroizing::new(json);
|
||||
encrypt(org_key, plaintext.as_slice())
|
||||
}
|
||||
|
||||
pub fn decrypt_org_manifest(encrypted: &[u8], org_key: &Zeroizing<[u8; 32]>) -> Result<OrgManifest> {
|
||||
let plaintext = decrypt(org_key, encrypted)?;
|
||||
let plaintext = Zeroizing::new(plaintext);
|
||||
let manifest: OrgManifest = serde_json::from_slice(&plaintext)?;
|
||||
Ok(manifest)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -87,4 +101,27 @@ mod tests {
|
||||
assert_eq!(decoded.attachment_caps.per_attachment_max_bytes,
|
||||
s.attachment_caps.per_attachment_max_bytes);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn org_manifest_round_trip() {
|
||||
use crate::org::{OrgManifest, OrgManifestEntry};
|
||||
use crate::ids::ItemId;
|
||||
use crate::item_types::ItemType;
|
||||
|
||||
let mut m = OrgManifest::new();
|
||||
m.entries.push(OrgManifestEntry {
|
||||
id: ItemId::new(),
|
||||
r#type: ItemType::SecureNote,
|
||||
title: "test".into(),
|
||||
tags: vec![],
|
||||
modified: 0,
|
||||
trashed_at: None,
|
||||
collection: "prod".into(),
|
||||
});
|
||||
let key = key();
|
||||
let bytes = encrypt_org_manifest(&m, &key).unwrap();
|
||||
let decoded = decrypt_org_manifest(&bytes, &key).unwrap();
|
||||
assert_eq!(decoded.entries.len(), 1);
|
||||
assert_eq!(decoded.entries[0].collection, "prod");
|
||||
}
|
||||
}
|
||||
|
||||
120
crates/relicario-core/tests/org.rs
Normal file
120
crates/relicario-core/tests/org.rs
Normal file
@@ -0,0 +1,120 @@
|
||||
use relicario_core::{
|
||||
generate_org_key, wrap_org_key, unwrap_org_key,
|
||||
encrypt_org_manifest, decrypt_org_manifest,
|
||||
OrgManifest, OrgManifestEntry, OrgMember, OrgMembers, OrgRole,
|
||||
MemberId, ItemId,
|
||||
};
|
||||
use relicario_core::item_types::ItemType;
|
||||
use rand::rngs::OsRng;
|
||||
use rand::RngCore;
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
fn make_member_keypair() -> (Zeroizing<[u8; 32]>, String) {
|
||||
let mut seed = [0u8; 32];
|
||||
OsRng.fill_bytes(&mut seed);
|
||||
let signing_key = ed25519_dalek::SigningKey::from_bytes(&seed);
|
||||
let pubkey_openssh = ssh_key::PrivateKey::from(
|
||||
ssh_key::private::Ed25519Keypair::from(&signing_key),
|
||||
)
|
||||
.public_key()
|
||||
.to_openssh()
|
||||
.expect("openssh");
|
||||
(Zeroizing::new(seed), pubkey_openssh)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn org_key_wrap_unwrap_round_trip() {
|
||||
let (seed, pubkey) = make_member_keypair();
|
||||
let org_key = generate_org_key();
|
||||
let wrapped = wrap_org_key(&org_key, &pubkey).expect("wrap");
|
||||
let unwrapped = unwrap_org_key(&wrapped, &seed).expect("unwrap");
|
||||
assert_eq!(*org_key, *unwrapped);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn revoked_member_cannot_decrypt_after_rotation() {
|
||||
// Alice and Bob both get access
|
||||
let (alice_seed, alice_pubkey) = make_member_keypair();
|
||||
let (_bob_seed, bob_pubkey) = make_member_keypair();
|
||||
|
||||
let org_key = generate_org_key();
|
||||
let _alice_wrapped = wrap_org_key(&org_key, &alice_pubkey).expect("wrap alice");
|
||||
let _bob_wrapped = wrap_org_key(&org_key, &bob_pubkey).expect("wrap bob");
|
||||
|
||||
// Rotate: new key, only Bob gets re-wrapped
|
||||
let new_org_key = generate_org_key();
|
||||
let new_bob_wrapped = wrap_org_key(&new_org_key, &bob_pubkey).expect("wrap bob new");
|
||||
|
||||
// Alice tries to use old org_key — she can still decrypt old items,
|
||||
// but new_bob_wrapped was encrypted with new_org_key, not org_key.
|
||||
// Verify: unwrapping new_bob_wrapped with Alice's seed fails.
|
||||
let result = unwrap_org_key(&new_bob_wrapped, &alice_seed);
|
||||
assert!(result.is_err(), "Alice should not be able to unwrap Bob's new key blob");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn org_manifest_filter_restricts_to_granted_collections() {
|
||||
let mut manifest = OrgManifest::new();
|
||||
for (title, collection) in &[("A", "prod"), ("B", "dev"), ("C", "prod")] {
|
||||
manifest.entries.push(OrgManifestEntry {
|
||||
id: ItemId::new(),
|
||||
r#type: ItemType::SecureNote,
|
||||
title: title.to_string(),
|
||||
tags: vec![],
|
||||
modified: 0,
|
||||
trashed_at: None,
|
||||
collection: collection.to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
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(), 2);
|
||||
assert!(filtered.entries.iter().all(|e| e.collection == "prod"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn org_manifest_encrypt_decrypt_round_trip() {
|
||||
let key = generate_org_key();
|
||||
let mut manifest = OrgManifest::new();
|
||||
manifest.entries.push(OrgManifestEntry {
|
||||
id: ItemId::new(),
|
||||
r#type: ItemType::Login,
|
||||
title: "GitHub".into(),
|
||||
tags: vec!["work".into()],
|
||||
modified: 1748000000,
|
||||
trashed_at: None,
|
||||
collection: "eng-tools".into(),
|
||||
});
|
||||
|
||||
let encrypted = encrypt_org_manifest(&manifest, &key).expect("encrypt");
|
||||
let decrypted = decrypt_org_manifest(&encrypted, &key).expect("decrypt");
|
||||
|
||||
assert_eq!(decrypted.entries.len(), 1);
|
||||
assert_eq!(decrypted.entries[0].title, "GitHub");
|
||||
assert_eq!(decrypted.entries[0].collection, "eng-tools");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn members_validation_rejects_invalid_id() {
|
||||
let mut members = OrgMembers::new();
|
||||
members.members.push(OrgMember {
|
||||
member_id: MemberId("not-hex-lol!!".to_string()),
|
||||
display_name: "Bad".into(),
|
||||
role: OrgRole::Member,
|
||||
ed25519_pubkey: String::new(),
|
||||
collections: vec![],
|
||||
added_at: 0,
|
||||
added_by: MemberId::new(),
|
||||
});
|
||||
assert!(members.validate().is_err());
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
# Dev-C ARCHITECTURE.md slice — Plan C Phase 6 (`get_vault_status` + sidebar status indicator)
|
||||
|
||||
Ready-to-fold additions for `extension/ARCHITECTURE.md`, scoped to Dev-C's Phase 6 work only.
|
||||
Phase 3 (`create_vault`/`attach_vault`, setup-SW migration) and Phase 4 (the `vault.ts` →
|
||||
`vault-shell`/`vault-sidebar`/`vault-list`/`vault-drawer`/`vault-form-wrapper` split) doc updates
|
||||
are Dev-A's / Dev-B's slices — not included here.
|
||||
|
||||
Merged to origin/main as `397cc78` (Merge Plan C Phase 6). Local source ref: `675452a`.
|
||||
|
||||
---
|
||||
|
||||
## 1. SW message-protocol row — `get_vault_status` (read-only, popup-only)
|
||||
|
||||
**Where:** the `router/popup-only.ts` bullet in the service-worker module map (around line 270),
|
||||
and/or wherever the read-only popup messages are enumerated.
|
||||
|
||||
**Add:**
|
||||
|
||||
> - `get_vault_status` (popup-only, read-only) — returns the cached sync summary
|
||||
> `{ ahead, behind, lastSyncAt, pendingItems }` with **no network call**. `ahead`/`behind`/
|
||||
> `lastSyncAt` are read straight off `state.gitHost` (populated by the `sync` handler, which
|
||||
> records `lastSyncAt = Math.floor(Date.now()/1000)` — unix **seconds** — after a successful
|
||||
> manifest fetch). `pendingItems` is a live count of active (non-trashed) manifest entries via
|
||||
> `vault.listItems(manifest).length`. `ahead`/`behind` are structurally always `0` in the
|
||||
> extension (it writes straight to the host via the Contents REST API; there is no local commit
|
||||
> graph) and exist for parity with `relicario status`. Handler: `vault.handleGetVaultStatus(state)`
|
||||
> — synchronous; its `Pick<GitHost,'lastSyncAt'|'ahead'|'behind'>`-typed param both breaks the
|
||||
> `PopupState` import cycle and structurally forbids it from making a network call.
|
||||
|
||||
## 2. `git-host.ts` cache fields
|
||||
|
||||
**Where:** the `git-host.ts` bullet in the SW module map (around line 299, listing the interface methods).
|
||||
|
||||
**Amend** the interface description to note the cached sync metadata:
|
||||
|
||||
> The `GitHost` interface also carries cached sync metadata —
|
||||
> `lastSyncAt: number | null` (unix seconds), `ahead: number`, `behind: number` — initialized to
|
||||
> `null`/`0`/`0` in both `GiteaHost` and `GitHubHost`. The cache rides the gitHost lifecycle: it is
|
||||
> created on unlock and cleared whenever `state.gitHost` is nulled — on session-timer expiry
|
||||
> (`index.ts`) **and** on the explicit `lock` message handler (`popup-only.ts`), which now nulls
|
||||
> `state.gitHost` symmetrically so a lock→unlock cycle can't surface a stale `lastSyncAt`.
|
||||
|
||||
## 3. Sidebar status-indicator UI flow
|
||||
|
||||
**Where:** the `src/vault/` module map (around line 184). Add a `vault-status.ts` entry and a note on
|
||||
the `vault-sidebar.ts` footer wiring. (If Dev-B's Phase 4 slice has already added the `vault-sidebar.ts`
|
||||
entry, fold the status note into it rather than duplicating.)
|
||||
|
||||
**Add:**
|
||||
|
||||
> - `vault-status.ts` — sidebar-footer sync indicator renderer. `renderStatusIndicator(el, status)`
|
||||
> is pure DOM: it renders, by priority, `N pending` / `N ahead` / `N behind`, falling back to
|
||||
> `in sync`, plus a `last sync <relativeTime>` / `never synced` line. Reuses `shared/glyphs.ts`
|
||||
> (`GLYPH_PENDING`/`AHEAD`/`BEHIND`/`SYNCED`) and `shared/relative-time.ts`. `VaultStatus` is an
|
||||
> alias of `GetVaultStatusResponse['data']`, so the renderer's input shape is single-sourced from
|
||||
> the message contract and can't drift from the SW handler.
|
||||
> - **Status-indicator flow** (in the `vault-sidebar.ts` entry): the footer holds a
|
||||
> `#vault-status-slot` plus a manual `↻` refresh button (`GLYPH_REFRESH`). `wireSidebar` calls
|
||||
> `refreshStatus()` once on mount and again on the button's click — sending `get_vault_status` via
|
||||
> `ctx.sendMessage` and rendering the result into the slot. There is **no timer polling**: the
|
||||
> indicator only refreshes on mount + explicit button press, matching the spec's
|
||||
> no-network-without-user-intent discipline (sync is user-initiated).
|
||||
|
||||
## 4. Living-docs note
|
||||
|
||||
This closes the last `relicario status` CLI/extension parity gap (called out in the extension
|
||||
restructure spec, `docs/superpowers/specs/2026-05-04-extension-restructure-design.md`). `STATUS.md`
|
||||
should move the extension-restructure line to shipped as part of the Task 7.1 pass.
|
||||
6178
docs/superpowers/plans/2026-06-06-enterprise-org-vault.md
Normal file
6178
docs/superpowers/plans/2026-06-06-enterprise-org-vault.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,360 @@
|
||||
# Relicario Enterprise Org Vault — Design Spec
|
||||
|
||||
**Scope:** Multi-user organizational vault for security-conscious self-hosting shops. Covers the git-native org model, the per-member key-wrapping scheme, collection-scoped item storage, role-based access control, org item CRUD, the signature-verifying pre-receive hook, the audit trail, and extension parity. Does not cover SSO/SAML, live SIEM streaming, or the HTTP management plane (deferred to a later server-tier spec).
|
||||
|
||||
**Next:** `docs/superpowers/specs/2026-05-02-relay-server-design.md` (relay server — future phase 2 management plane)
|
||||
|
||||
> **Revision note (2026-06-19):** This spec was revised after an adversarial multi-agent review of the first draft + its implementation plan. The review confirmed the cryptographic wrap/unwrap scheme is correct but found that the original access-control design was unenforceable (flat item paths the hook could not authorize), the hook never actually verified signatures, the audit actor was read from spoofable commit trailers, and there was no item CRUD. This revision corrects all of those at the design level. See `docs/superpowers/plans/2026-06-06-enterprise-org-vault.md` for the implementation.
|
||||
|
||||
---
|
||||
|
||||
## Target Audience
|
||||
|
||||
Security-focused organizations that self-host their entire stack: infosec shops, security consultancies, law firms handling privileged client data, small financial firms. Key requirements:
|
||||
|
||||
- Full air-gap capability — no mandatory internet connectivity
|
||||
- Cryptographically provenance-linked, **tamper-evident** audit trail
|
||||
- Personal vaults remain isolated from org vault (separate cryptographic domains)
|
||||
- Least-privilege blast-radius limiting via collections, **server-enforced** (not advisory)
|
||||
- Member offboarding with clean key revocation that protects past secrets
|
||||
- Deployable without an IdP, SSO provider, or cloud dependency
|
||||
|
||||
---
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
An org vault is a second git repository alongside each member's personal vault. Personal and org vaults are cryptographically isolated — the personal vault's two-factor KDF (passphrase + image → Argon2id → master key) is completely untouched by org operations.
|
||||
|
||||
```
|
||||
Personal: ~/.config/relicario/personal/ → personal git repo (passphrase + image → Argon2id → master key)
|
||||
Org: ~/.config/relicario/acme-org/ → org git repo (org master key, wrapped per-member)
|
||||
```
|
||||
|
||||
**Two cryptographic domains, one CLI and one extension.**
|
||||
|
||||
The org vault uses a random 256-bit **org master key** to encrypt all org items and the org manifest. Each authorized member receives a copy of the org master key wrapped (ECIES: X25519 + XChaCha20-Poly1305) to their existing ed25519 device public key — converted to X25519 for the Diffie-Hellman step. To open the org vault, a member uses their device private key to unwrap their copy of the org master key, then decrypts items exactly as today.
|
||||
|
||||
**Two enforcement boundaries, working together:**
|
||||
|
||||
1. **Cryptographic** — only holders of a wrapped key can decrypt the org master key, and only the org master key can decrypt items. Revocation + key rotation re-encrypts everything under a fresh key.
|
||||
2. **Git pre-receive hook** — every commit is signature-verified against `members.json`, and writes are authorized by role (for management files) or by **collection path segment** (for item files). This is what makes least-privilege real rather than advisory, and what makes the audit trail tamper-evident.
|
||||
|
||||
**Key security properties:**
|
||||
|
||||
- Member departure = delete their `keys/<member-id>.enc`, then `rotate-key` re-wraps the org key for remaining members **and re-encrypts every item blob**. A removed member who kept the old key and a clone can decrypt nothing written or rotated after their removal.
|
||||
- Every write to the org repo is a **signed** git commit; the hook rejects unsigned commits and commits from non-members. The git log is the audit log, and its actor attribution comes from the **verified signing key**, not from spoofable commit-message text.
|
||||
- Fully air-gapped: the org repo is just git, push/pull over SSH.
|
||||
- A compromised org master key does not expose personal vault items.
|
||||
|
||||
**Phase 2 (not in this spec):** live SIEM streaming, SSO/SAML, LDAP/IdP member sync, HTTP management plane via the `relicario-server` relay skeleton, server-mediated read audit, and "hide value" (autofill without revealing plaintext).
|
||||
|
||||
---
|
||||
|
||||
## Data Model
|
||||
|
||||
The org repo has a defined on-disk schema. The pre-receive hook rejects pushes that violate it.
|
||||
|
||||
```
|
||||
acme-org/
|
||||
├── org.json # org identity: name, org_id, created_at, schema_version
|
||||
├── members.json # user directory (unencrypted — roles are not secrets)
|
||||
├── collections.json # collection definitions
|
||||
├── keys/
|
||||
│ └── <member-id>.enc # org master key wrapped to each member's X25519 public key
|
||||
├── manifest.enc # encrypted org manifest (item index + collection membership)
|
||||
└── items/
|
||||
└── <collection-slug>/
|
||||
└── <item-id>.enc # encrypted item, stored UNDER its collection directory
|
||||
```
|
||||
|
||||
**Collection-scoped item storage is load-bearing.** Items live under `items/<collection-slug>/<item-id>.enc`, not in a flat `items/` directory. The leading path segment is the collection slug, in cleartext, so the pre-receive hook can authorize a write by comparing the path's collection against the signing member's grants — *without* decrypting anything. (The original flat layout made this impossible: the item→collection mapping existed only inside the encrypted manifest the server cannot read.)
|
||||
|
||||
### `org.json`
|
||||
|
||||
```json
|
||||
{
|
||||
"schema_version": 1,
|
||||
"org_id": "<16-char hex>",
|
||||
"display_name": "Acme Security",
|
||||
"created_at": 1748000000
|
||||
}
|
||||
```
|
||||
|
||||
### `members.json`
|
||||
|
||||
Public and unencrypted — readable without the org master key. Roles are not secrets; the key material is in `keys/`.
|
||||
|
||||
```json
|
||||
{
|
||||
"schema_version": 1,
|
||||
"members": [
|
||||
{
|
||||
"member_id": "<16-char hex>",
|
||||
"display_name": "Alice",
|
||||
"role": "owner",
|
||||
"ed25519_pubkey": "ssh-ed25519 AAAA... ",
|
||||
"collections": ["prod-infra", "shared-tools"],
|
||||
"added_at": 1748000000,
|
||||
"added_by": "<member-id>"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`role` is one of `owner`, `admin`, `member`. `collections` is the list of collection slugs this member is granted. `member_id` is a 16-char lowercase hex string generated from 64 bits of `OsRng` entropy — the same convention as `ItemId`/`FieldId` in `relicario-core/src/ids.rs`. `ed25519_pubkey` is the member's device public key in OpenSSH format; the hook canonicalizes it to a SHA-256 fingerprint (via `relicario_core::fingerprint`) for matching, so whitespace/comment differences do not lock a member out.
|
||||
|
||||
### `collections.json`
|
||||
|
||||
```json
|
||||
{
|
||||
"schema_version": 1,
|
||||
"collections": [
|
||||
{
|
||||
"slug": "prod-infra",
|
||||
"display_name": "Production Infrastructure",
|
||||
"created_by": "<member-id>",
|
||||
"created_at": 1748000000
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Slugs are validated: non-empty, no `/`, no `.` (so they are safe single path segments).
|
||||
|
||||
### `keys/<member-id>.enc`
|
||||
|
||||
The org master key (32 bytes) encrypted with ECIES to the member's device key. Wrapped-blob layout: `ephemeral_x25519_pubkey(32) || version(1) || nonce(24) || ciphertext+tag`. The wrapping key is `SHA-256(dh_shared || ephemeral_pubkey || recipient_pubkey)`; all secret intermediates (shared secret, derived wrap key) are held in `Zeroizing`. The ed25519→X25519 conversion (SHA-512(seed)[:32] + RFC 7748 clamp for the scalar; birational Montgomery map for the point) is the standard one; its correctness was verified against ed25519-dalek's own reference test vector.
|
||||
|
||||
### `manifest.enc`
|
||||
|
||||
Encrypted with the org master key. Same shape as the personal vault manifest but each entry carries a `collection` slug. The manifest is the authoritative item index; item blobs carry no metadata.
|
||||
|
||||
### `items/<collection-slug>/<item-id>.enc`
|
||||
|
||||
Identical `.enc` format to personal vault items (XChaCha20-Poly1305, random 24-byte nonce, org master key used directly — no Argon2id). Item IDs follow the 16-char hex convention. The blob does not name its collection; the directory path does.
|
||||
|
||||
---
|
||||
|
||||
## Access Control
|
||||
|
||||
### Roles
|
||||
|
||||
| Role | Permissions |
|
||||
|---|---|
|
||||
| **Owner** | All operations. Add/remove admins and owners. Create/delete collections. Rotate org key. Transfer ownership. Delete org. |
|
||||
| **Admin** | Add/remove **members** (not owners/admins). Create/delete collections. Grant/revoke collection access. Read all collections. |
|
||||
| **Member** | Read/write items in granted collections only. Cannot see or write items in other collections. |
|
||||
|
||||
Role gating is enforced both client-side (the CLI refuses) and server-side (the hook rejects). An admin cannot mint an owner or admin — only an owner can.
|
||||
|
||||
### Collection Access
|
||||
|
||||
Grants are stored in the member's `collections` array in `members.json`. No separate ACL file. An admin edits the member record and commits; the hook validates the committing member's role.
|
||||
|
||||
### Enforcement Layers
|
||||
|
||||
1. **Manifest filtering (read)** — the CLI and extension filter the decrypted manifest to entries whose `collection` is in the authenticated member's grant list. Members never see items for collections they are not granted.
|
||||
|
||||
2. **Pre-receive hook (write)** — for `items/<slug>/<id>.enc`, the hook requires `<slug>` to be in the signing member's grants. For `members.json` / `collections.json` / `org.json`, it requires owner/admin role. Every commit must additionally carry a **valid signature** from a current member. This makes both confidentiality *and integrity* of collections server-enforced.
|
||||
|
||||
### Known Limitations (honest)
|
||||
|
||||
- **Shared org master key — reads are not cryptographically scoped per collection.** Every member holds the *same* org master key (wrapped to their device key). The hook scopes *writes* by collection path and the client filters the *manifest* on read, but the cryptography itself does not partition reads: a member who obtains the raw ciphertext of an item in a collection they were not granted can still decrypt it, because the one org key opens everything. Collection grants are therefore an access-control boundary (enforced by the hook on write and by manifest filtering + optional git-host directory read-ACLs on fetch), not a cryptographic one. For *cryptographic* separation, put the sensitive material in a **separate org vault**. Per-collection subkeys are an explicit non-goal for this phase.
|
||||
- **No read audit.** Git commits record writes, not reads. A member decrypting an item without writing leaves no git trace. Read audit needs a server that mediates fetch — phase 2.
|
||||
- **No "hide value."** Autofill-without-revealing requires per-item subkeys or a mediating relay — phase 2.
|
||||
|
||||
---
|
||||
|
||||
## Org Items (CRUD)
|
||||
|
||||
The org vault stores secrets via `relicario org` item commands that mirror the personal-vault item model (`Item`, `ItemCore`, the typed builders) but operate on the org repo and enforce collection grants.
|
||||
|
||||
```
|
||||
relicario org add --collection <slug> <type> [type-specific flags]
|
||||
relicario org get --collection <slug> <query> [--show] [--copy]
|
||||
relicario org list [--collection <slug>] [--type <t>]
|
||||
relicario org edit --collection <slug> <query>
|
||||
relicario org rm | restore | purge --collection <slug> <query>
|
||||
```
|
||||
|
||||
Every item operation:
|
||||
|
||||
1. Requires the caller's `current_member()` to have `<slug>` in their grants, and `<slug>` to exist in `collections.json`.
|
||||
2. Reads/writes `items/<slug>/<id>.enc` with the org master key.
|
||||
3. Upserts/removes the `OrgManifestEntry` (with `collection = <slug>`) and re-encrypts `manifest.enc`.
|
||||
4. Commits with the structured trailer block, emitting the matching `item-*` action.
|
||||
|
||||
`get`/`list` apply manifest filtering so a member only sees their granted collections; secret fields are masked unless `--show`. Trash uses `trashed_at` like the personal vault.
|
||||
|
||||
---
|
||||
|
||||
## Admin Operations
|
||||
|
||||
All org management uses `relicario org <subcommand>`. Command bodies live in `commands/org.rs` as `run_<verb>`.
|
||||
|
||||
```
|
||||
relicario org init --name "Acme Security" # create org repo, generate org key, add caller as owner, configure signing
|
||||
relicario org add-member --key <openssh-pubkey> --name Alice --role member
|
||||
relicario org remove-member <member-id> # delete key blob; prompts to run rotate-key
|
||||
relicario org set-role <member-id> admin|member
|
||||
relicario org create-collection <slug> --name "..."
|
||||
relicario org grant <member-id> <slug>
|
||||
relicario org revoke <member-id> <slug>
|
||||
relicario org rotate-key # new org key: re-wrap for members AND re-encrypt all items + manifest
|
||||
relicario org transfer-ownership <member-id> # owner → another member (owner only; caller demoted to admin unless --keep-owner)
|
||||
relicario org delete-org # owner only; explicit confirmation; LOCAL tombstone only (see caveat below)
|
||||
relicario org status # members, roles, collections — no decryption
|
||||
relicario org audit [--since ..] [--member ..] [--collection ..] [--action ..] [--format json]
|
||||
```
|
||||
|
||||
> **`delete-org` caveat (phase 1):** the pre-receive hook rejects deletion of the protected JSON files (`members.json` / `collections.json` / `org.json`) as part of schema-monotonicity enforcement. Therefore phase-1 `delete-org` is a **local tombstone only** — it removes the org files in the working tree and records a delete commit locally, but that commit **cannot be pushed to a hook-protected remote**. Pushing org teardown to a protected remote (a hook-side "owner may delete" exception) is a tracked phase-2 follow-up. `transfer-ownership` is fully hook-compatible (it only mutates `members.json` roles, owner-signed).
|
||||
|
||||
### Onboarding Flow
|
||||
|
||||
1. Alice runs `relicario device add`, exports her ed25519 public key (`signing.pub`).
|
||||
2. Alice sends her public key to an admin out-of-band (Signal, email, printed QR — Relicario does not mediate key exchange).
|
||||
3. Admin runs `org add-member --key <pubkey> --name Alice`. (An admin may add only `member` role; promoting to admin/owner requires an owner.)
|
||||
4. Alice pulls the org repo. She can now open the org vault.
|
||||
|
||||
### Offboarding Flow
|
||||
|
||||
1. Admin runs `org remove-member <id>` (deletes the key blob, updates `members.json`).
|
||||
2. Admin runs `org rotate-key` — generates a new org key, re-wraps it for remaining members, and **re-encrypts every item blob and the manifest** under the new key.
|
||||
3. The former member, even with the old key and a clone, can decrypt nothing post-rotation.
|
||||
|
||||
### Signing
|
||||
|
||||
`org init` calls `configure_git_signing(org_root, device_name)` so the org repo signs commits with the device's ed25519 key. All org writes are signed; the hook rejects anything else.
|
||||
|
||||
### Extension — Org Context
|
||||
|
||||
The vault tab gains a top-level org switcher (Personal + each configured org). Switching loads the selected org's manifest through the service worker. The SW holds the unwrapped org master key in a `Zeroizing` session handle — identical to the personal master key. The org master key is **never** written to `localStorage`, `IndexedDB`, or any persistent browser storage. If the git remote is unreachable, the org context is read-only with an "org offline — writes disabled" indicator. Phase-1 extension scope is: org switching, browsing/reading org items (grant-filtered), and the parity acceptance tests; full in-extension org item editing may be a tracked follow-up if it balloons.
|
||||
|
||||
---
|
||||
|
||||
## Audit Trail
|
||||
|
||||
### Git Log as Tamper-Evident Audit Record
|
||||
|
||||
Every write is a signed git commit carrying structured trailers:
|
||||
|
||||
```
|
||||
add item to prod-infra collection
|
||||
|
||||
Relicario-Actor: alice <a1b2c3d4e5f6a1b2>
|
||||
Relicario-Action: item-create
|
||||
Relicario-Collection: prod-infra
|
||||
Relicario-Item: 9f8e7d6c5b4a3f2e
|
||||
```
|
||||
|
||||
**Trailers are advisory, not authoritative.** A malicious committer can write any trailer text. The trustworthy actor identity is the **verified signing key**: `relicario org audit` resolves each commit's signature fingerprint to a `members.json` entry and reports that as the actor. Where the trailer's claimed actor disagrees with the verified signer, the commit is flagged `TAMPERED`. Timestamps use the committer date (`%cI`).
|
||||
|
||||
### Action Vocabulary
|
||||
|
||||
| `Relicario-Action` | Trigger |
|
||||
|---|---|
|
||||
| `item-create` / `item-update` / `item-delete` / `item-restore` / `item-purge` | org item add / edit / trash / restore / purge |
|
||||
| `member-add` / `member-remove` / `member-role-change` | member management |
|
||||
| `collection-create` / `collection-grant` / `collection-revoke` | collection management |
|
||||
| `key-rotate` | org key rotation |
|
||||
| `org-init` / `ownership-transfer` / `org-delete` | org lifecycle |
|
||||
|
||||
### `relicario org audit`
|
||||
|
||||
Parses `git log` (record separator `%x1e`, field separator `%x1f` to survive multi-line trailer values), resolves signer→member, applies `--since/--member/--collection/--action` filters, and emits a table or, with `--format json`, a JSON array ready for `… | <siem-ingest>` via cron. Each event includes the verified actor, action, collection, item, commit, committer timestamp, and a `tampered` flag.
|
||||
|
||||
---
|
||||
|
||||
## Pre-Receive Hook (`relicario-server verify-org-commit`)
|
||||
|
||||
`relicario-server` gains an org mode. For each pushed commit it:
|
||||
|
||||
1. **Verifies the signature** by building a temporary `allowed_signers` from `members.json` ed25519 keys, injecting `gpg.ssh.allowedSignersFile` via `GIT_CONFIG_*`, running `git verify-commit --raw`, and parsing the `SHA256:` fingerprint from stderr — the same mechanism the existing `verify-commit` uses. A commit with no good signature, or whose signer is not a current member, is rejected. (Bare `git %GF` is **not** used — it returns empty without an allowed-signers file.)
|
||||
2. **Authorizes the change** by inspecting `git diff-tree` paths:
|
||||
- `members.json` / `collections.json` / `org.json` → signer must be owner/admin; a `member-role-change` granting owner/admin must be signed by an owner.
|
||||
- `items/<slug>/<id>.enc` → `<slug>` must be in the signing member's grants.
|
||||
3. **Validates schema** — `schema_version` must not decrease for any of the three JSON files (compared against `{commit}^:<file>`), and `members.json`/`collections.json` must pass `validate()`.
|
||||
4. **Handles genesis and merges** — the root commit (no parent) is the org-init genesis: it is allowed if signed by the sole owner it introduces. Merge commits are rejected (org history is linear) to avoid first-parent-only diff blind spots.
|
||||
|
||||
`relicario-server generate-org-hook` emits the wrapper script that runs `verify-org-commit` per pushed commit.
|
||||
|
||||
---
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Key Rotation Race
|
||||
|
||||
`rotate-key` does `git pull --rebase` first. If the pull surfaces a non-fast-forward / conflict (a concurrent rotation), it aborts with `"Concurrent key rotation detected — pull and re-run org rotate-key."` A missing remote (local-only org) is distinguished and does not abort.
|
||||
|
||||
### Org Repo Schema Invalid
|
||||
|
||||
If `members.json`/`collections.json` fail validation on pull, the CLI refuses to open the org vault with a clear error. No silent degradation.
|
||||
|
||||
### Member Device Key Lost
|
||||
|
||||
If a member loses their device key before a backup device was added, an owner re-wraps the org key to a replacement device key the member generates. No master key escrow is needed — owners hold the org key and can always re-grant.
|
||||
|
||||
### Extension Offline
|
||||
|
||||
If the git remote is unreachable, the extension serves read-only from the last-pulled state and blocks writes with an indicator. Identical to personal vault offline behavior.
|
||||
|
||||
---
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### Unit Tests (`relicario-core`)
|
||||
|
||||
- Org key wrap/unwrap round-trip (ed25519→X25519 + XChaCha20-Poly1305), including a pinned RFC 8032 known-answer vector so a future crate-version regression in the birational map is caught.
|
||||
- Manifest filtering by collection grant list.
|
||||
- `members.json` / `collections.json` schema validation (valid + invalid).
|
||||
- Secret intermediates are `Zeroizing` (compile-level).
|
||||
|
||||
### Integration Tests (`relicario-cli`)
|
||||
|
||||
- Full lifecycle against a local bare git repo: `org init → add-member → create-collection → grant → org add (item write) → audit` — verifying the item lands at `items/<slug>/<id>.enc` and the audit attributes the verified signer.
|
||||
- `remove-member → rotate-key` → former member cannot decrypt a re-encrypted item; remaining member can.
|
||||
- Grant enforcement: a member without a collection grant is refused `org add/get` for it.
|
||||
- `org audit --format json` is valid JSON matching the action vocabulary; a forged-trailer commit is flagged `TAMPERED`.
|
||||
- Concurrent `rotate-key` race aborts with the spec error string.
|
||||
|
||||
### Hook Tests (`relicario-server`)
|
||||
|
||||
- Unsigned commit rejected; commit signed by a non-member rejected.
|
||||
- Item write to an ungranted collection path rejected; to a granted one accepted.
|
||||
- Protected-file write by a member (non-admin) rejected.
|
||||
- `schema_version` decrease rejected. Genesis commit accepted; merge commit rejected.
|
||||
|
||||
### Extension Tests (vitest)
|
||||
|
||||
- SW org context switching replaces the personal manifest cleanly (no cross-contamination).
|
||||
- Org master key lives only in the Zeroizing session — never in `localStorage`/`IndexedDB`.
|
||||
- Offline read-only mode triggers on a git network error.
|
||||
|
||||
Org crypto bypasses Argon2id (key wrapping is X25519-based), so the fast-Argon2id test-params convention is irrelevant to org tests; standard params apply only where shared fixtures touch the personal path.
|
||||
|
||||
---
|
||||
|
||||
## Living-Docs Impact
|
||||
|
||||
This feature introduces new on-disk formats, a new crypto path, and a new dependency, so the following docs must be updated as the work lands (per CLAUDE.md living-docs discipline):
|
||||
|
||||
- `docs/FORMATS.md` — the four org JSON files, the `keys/<id>.enc` wrapped-blob layout, and `items/<slug>/<id>.enc`.
|
||||
- `docs/CRYPTO.md` — the ECIES org-key wrap/unwrap path and key-rotation re-encryption.
|
||||
- `DESIGN.md` — org-master-key row in the secrets map; the `x25519-dalek` dependency; relicario-server org mode.
|
||||
- `docs/SECURITY.md` — org device-key auth, the signature-verifying hook, and the honest limitations above.
|
||||
- `crates/relicario-core/ARCHITECTURE.md` and `crates/relicario-cli/ARCHITECTURE.md` — the new `org` modules.
|
||||
- `STATUS.md` / `ROADMAP.md` — the org-vault track and any tracked follow-ups (e.g. full extension org editing, SSO/LDAP).
|
||||
|
||||
---
|
||||
|
||||
## Phase Boundary
|
||||
|
||||
This spec covers phase 1 (git-native org, CLI + extension parity). Phase 2 adds:
|
||||
|
||||
- HTTP management plane via `relicario-server` (relay skeleton → org API)
|
||||
- Live audit event streaming to SIEM (webhooks, not cron-poll)
|
||||
- SSO/SAML assertion validation + LDAP/IdP member sync
|
||||
- Server-mediated read audit
|
||||
- "Hide value" autofill (per-item subkeys or server-mediated relay)
|
||||
- Per-collection cryptographic isolation (subkeys — explicit non-goal for phase 1)
|
||||
- Pushable `delete-org` org teardown (a hook-side "owner may delete protected files" exception); phase-1 `delete-org` is a local tombstone only
|
||||
3
tools/relay/.gitignore
vendored
Normal file
3
tools/relay/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
# Runtime message archive written by queue.ts post() — local relay traffic,
|
||||
# not source. Regenerated each session; never committed.
|
||||
relay-log.jsonl
|
||||
49
tools/relay/pm
Executable file
49
tools/relay/pm
Executable file
@@ -0,0 +1,49 @@
|
||||
#!/usr/bin/env bash
|
||||
# PM relay helper — absolute-path wrapper around call.py so it can be invoked
|
||||
# from ANY working directory with no `cd` and no JSON-quoting by hand.
|
||||
#
|
||||
# Usage:
|
||||
# tools/relay/pm read # drain PM inbox
|
||||
# tools/relay/pm pending # pending counts for all roles
|
||||
# tools/relay/pm send <to> <kind> <body> # post_message from pm
|
||||
# e.g. tools/relay/pm send dev-c directive "## DIRECTIVE ... "
|
||||
#
|
||||
# Always works regardless of cwd because it resolves call.py by absolute path.
|
||||
set -euo pipefail
|
||||
|
||||
RELAY_DIR="/home/alee/Sources/relicario/tools/relay"
|
||||
CALL="python3 $RELAY_DIR/call.py"
|
||||
|
||||
cmd="${1:-}"
|
||||
case "$cmd" in
|
||||
read)
|
||||
$CALL read_messages '{"for":"pm"}'
|
||||
;;
|
||||
pending)
|
||||
for r in dev-a dev-b dev-c pm; do
|
||||
printf '%s: ' "$r"
|
||||
$CALL list_pending "{\"for\":\"$r\"}"
|
||||
echo
|
||||
done
|
||||
;;
|
||||
send)
|
||||
to="${2:?usage: pm send <to> <kind> <body>}"
|
||||
kind="${3:?usage: pm send <to> <kind> <body>}"
|
||||
body="${4:?usage: pm send <to> <kind> <body>}"
|
||||
# Build JSON with python to handle escaping of the body safely.
|
||||
python3 - "$to" "$kind" "$body" <<'PY'
|
||||
import json, sys, urllib.request
|
||||
to, kind, body = sys.argv[1], sys.argv[2], sys.argv[3]
|
||||
payload = {"from": "pm", "to": to, "kind": kind, "body": body}
|
||||
import subprocess
|
||||
print(subprocess.run(
|
||||
["python3", "/home/alee/Sources/relicario/tools/relay/call.py",
|
||||
"post_message", json.dumps(payload)],
|
||||
capture_output=True, text=True).stdout, end="")
|
||||
PY
|
||||
;;
|
||||
*)
|
||||
echo "usage: pm {read|pending|send <to> <kind> <body>}" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
@@ -1,4 +1,13 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { appendFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, join } from "node:path";
|
||||
|
||||
// Append-only archive of every posted message. The in-memory queues are
|
||||
// consume-once (read() drains the inbox) and vanish on restart, so this is
|
||||
// the only durable, full-body record of relay traffic. One JSON object per
|
||||
// line; never truncated.
|
||||
const LOG_PATH = join(dirname(fileURLToPath(import.meta.url)), "relay-log.jsonl");
|
||||
|
||||
export type Role = "pm" | "dev-a" | "dev-b" | "dev-c" | "dev-d" | "dev-e" | "dev-f";
|
||||
export type MessageKind = "status" | "question" | "directive" | "free";
|
||||
@@ -39,6 +48,11 @@ export class RelayQueue {
|
||||
ts: new Date().toISOString(),
|
||||
};
|
||||
this.queues.get(to)!.push(msg);
|
||||
try {
|
||||
appendFileSync(LOG_PATH, JSON.stringify(msg) + "\n");
|
||||
} catch {
|
||||
// Logging is best-effort; never let a disk error drop a message.
|
||||
}
|
||||
return msg;
|
||||
}
|
||||
|
||||
|
||||
@@ -86,8 +86,8 @@ function handleToolCall(name: string, args: Record<string, string>) {
|
||||
const kind = args.kind as "status" | "question" | "directive" | "free";
|
||||
const msg = queue.post(args.from, args.to, kind, args.body);
|
||||
const ts = new Date(msg.ts).toTimeString().slice(0, 8);
|
||||
const preview = args.body.slice(0, 60).replace(/\n/g, " ");
|
||||
const ellipsis = args.body.length > 60 ? "..." : "";
|
||||
const preview = args.body.slice(0, 120).replace(/\n/g, " ");
|
||||
const ellipsis = args.body.length > 120 ? "..." : "";
|
||||
process.stdout.write(`[${ts}] ${args.from} → ${args.to} [${kind}] "${preview}${ellipsis}"\n`);
|
||||
return { content: [{ type: "text" as const, text: JSON.stringify({ id: msg.id }) }] };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user