Corrects every critical/high finding from the adversarial review and adds the
two scope expansions (full item CRUD + extension parity):
- Device-key helpers built on the real devices/<name>/signing.{key,pub} layout
+ ssh-key CLI dep (was: invented ~/.config/relicario/device.key)
- Signature-verifying pre-receive hook on every commit + path-scoped write
authz via items/<slug>/<id>.enc (was: bare %GF, unenforceable flat items)
- Org item CRUD (add/get/list/edit/rm/restore/purge), collection-scoped
- Audit attributed to verified signer + TAMPERED flag (was: spoofable trailers)
- rotate-key re-encrypts every item blob (was: manifest only)
- Zeroize KDF intermediates; fix ssh_key::PrivateKey::from test helpers
- Owner-only role-gating; fingerprint-based member matching; %x1e/%x1f audit
parser framing; signed org commits via org_git_run
- Extension stream (WASM bindings + SW org session + switcher + 3 vitest tests)
- Stream-prefixed task IDs (A/B/C/D) with explicit cross-stream deps
- Living-docs task
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
212 KiB
Enterprise Org Vault Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Implement git-native multi-user org vaults for security-conscious self-hosting shops — per-user repos + a shared org repo, X25519-wrapped org master key per member, collection-scoped item storage, role-based access, full org item CRUD (add/get/list/edit/rm/restore/purge), a signature-verifying pre-receive hook with path-scoped write authorization, a tamper-evident structured-git-trailer audit trail attributed to the verified signer, and extension parity (org switcher + read-only browse + the spec's vitest acceptance tests).
Architecture: The org repo is a separate git repository with a defined schema (org.json, members.json, collections.json, keys/<member-id>.enc, manifest.enc, and collection-scoped items at items/<collection-slug>/<item-id>.enc). Each member holds a copy of the 256-bit org master key wrapped (ECIES/X25519 + XChaCha20-Poly1305) to their existing ed25519 device key. Two enforcement boundaries work together: (1) cryptographic — only a wrapped-key holder can unwrap the org master key, and rotation re-encrypts every item blob under a fresh key; (2) the git pre-receive hook — every commit is signature-verified against members.json (signer resolved by ed25519 fingerprint), and writes are authorized by role (for management files) or by collection path segment (for item files). Item storage is collection-scoped precisely so the hook can authorize an item write by its leading path segment without decrypting anything. All org commits are signed; org init configures git signing and org commits route through a non-hardened org_git_run (the standard helpers::git_run force-disables signing). Both the CLI and the extension consume the same relicario-core::org module; the extension ships org switch + read in this phase (writes are a tracked follow-up).
Tech Stack: Rust, x25519-dalek 2 (new, in relicario-core), ed25519-dalek 2, sha2, chacha20poly1305 0.10, ssh-key 0.6 (new, in relicario-cli), regex, tempfile, serde_json, clap, anyhow, zeroize; WASM bindings (relicario-wasm, base64) + extension TypeScript with vitest for the parity acceptance tests.
Multi-stream assignment for PM:
| Stream | Tasks | Scope | Depends on |
|---|---|---|---|
| Dev-A | A1–A4 (+ A5 docs, final) | relicario-core org module: deps, types, crypto, vault wrappers, integration tests; then the living-docs sweep |
A5 depends on all streams merged |
| Dev-B | B1–B14 | relicario-cli: session, device helpers, admin commands, item CRUD, wiring |
A2–A3 merged first (imports relicario_core::org); B9–B13 depend on B1; B14 depends on the command fns existing |
| Dev-C | C1–C2 | relicario-server pre-receive hook: signature + path-scoped authz, schema monotonicity |
A2–A3 merged first (imports relicario_core::org) |
| Dev-D | D1–D4 | Extension parity: WASM org bindings, SW org session/handlers, vault-tab switcher, vitest tests | A2–A3 merged first (bindings call relicario_core::{unwrap_org_key, decrypt_org_manifest, decrypt_item}); independent of Dev-B/Dev-C |
Hard dependency note: Dev-B, Dev-C, and Dev-D all import
relicario_core::orgtypes/functions, so Tasks A2–A3 must be MERGED before those streams begin. Within Dev-B, the item-CRUD tasks B9–B13 depend on B1 (the collection-scopeditem_path+ session helpers), and B14 depends on everyrun_*command fn existing (it wires them intomain.rs). A5 is the final living-docs task and runs after all code streams merge.
File Map
| Action | Path | Responsibility |
|---|---|---|
| Modify | crates/relicario-core/Cargo.toml |
Add x25519-dalek = "2" |
| Create | crates/relicario-core/src/org.rs |
Org types + crypto (IDs, members, collections, key wrap/unwrap; KDF intermediates in Zeroizing) |
| Modify | crates/relicario-core/src/vault.rs |
Add encrypt_org_manifest / decrypt_org_manifest |
| Modify | crates/relicario-core/src/lib.rs |
pub mod org + re-exports |
| Create | crates/relicario-core/tests/org.rs |
Integration tests for org crypto |
| Modify | crates/relicario-cli/Cargo.toml |
Add ssh-key = "0.6"; ensure regex = "1" + tempfile = "3" in [dependencies]; ed25519-dalek dev-dep |
| Create | crates/relicario-cli/src/org_session.rs |
UnlockedOrgVault session type (collection-scoped item_path, item I/O helpers, fingerprint member match, signed org_git_run) |
| Modify | crates/relicario-cli/src/device.rs |
current_device_seed / current_device_pubkey helpers |
| Create | crates/relicario-cli/src/commands/org.rs |
All relicario org subcommands — admin and item commands |
| Modify | crates/relicario-cli/src/commands/mod.rs |
pub mod org |
| Modify | crates/relicario-cli/src/main.rs |
Commands::Org arm + OrgCommands (admin + item subcommands) |
| Modify | crates/relicario-server/Cargo.toml |
Ensure tempfile in [dependencies]; add [lib] + [[bin]] |
| Create | crates/relicario-server/src/lib.rs |
Pure path-classification + schema-version helpers (classify_path, PathClass, extract_schema_version) |
| Modify | crates/relicario-server/src/main.rs |
verify-org-commit (signature + path/role authz + schema monotonicity) + generate-org-hook |
| Create | crates/relicario-server/tests/org_hook.rs |
Hook path-classification + schema-version tests |
| Modify | crates/relicario-wasm/src/lib.rs |
org_open / org_manifest_decrypt / org_item_decrypt bindings |
| Modify | crates/relicario-wasm/Cargo.toml |
base64 (if absent) |
| Regenerate | extension/wasm/relicario_wasm.{js,d.ts}, relicario_wasm_bg.wasm |
wasm-pack output consumed by the extension |
| Create | extension/src/service-worker/org.ts |
SW org session + read ops |
| Modify | extension/src/service-worker/index.ts |
Wire org.setWasm; clear org session on expiry |
| Modify | extension/src/service-worker/router/popup-only.ts |
4 org handler arms |
| Modify | extension/src/shared/messages.ts |
Org message types + responses + capability-set entries |
| Create | extension/src/vault/vault-org-switcher.ts |
Vault-tab org context switcher + read-only banner |
| Modify | extension/src/vault/vault-sidebar.ts, vault-context.ts, vault.ts, vault.css |
Mount switcher; activeOrgId state; styling |
| Create | extension/src/service-worker/__tests__/org.test.ts |
3 spec-mandated vitest tests |
| Modify | living docs (see A5) | FORMATS, CRYPTO, DESIGN, SECURITY, core/cli/ext ARCHITECTURE, STATUS, ROADMAP |
Dev-A — relicario-core org module
[Dev-A] Task A1: Add x25519-dalek and stub org module
Files:
-
Modify:
crates/relicario-core/Cargo.toml -
Create:
crates/relicario-core/src/org.rs(stub) -
Modify:
crates/relicario-core/src/lib.rs -
Step 1: Add x25519-dalek dependency
In crates/relicario-core/Cargo.toml, add after the ed25519-dalek line:
x25519-dalek = { version = "2", features = ["static_secrets"] }
- Step 2: Create org.rs stub
Create crates/relicario-core/src/org.rs with just a module-level comment:
//! Org vault types, crypto, and schema for multi-user self-hosted deployments.
- Step 3: Wire into lib.rs
In crates/relicario-core/src/lib.rs, add after the device module block. Wire the pub mod org; line only, no pub use yet (the re-exports would fail until Task A2 defines the symbols):
pub mod org;
Then add the pub use items in Task A2 once the symbols exist.
- Step 4: Verify it compiles
cargo check -p relicario-core
Expected: compiles (stub is empty; only pub mod org; is wired).
- Step 5: Verify the WASM target still builds
x25519-dalek lands in the core that relicario-wasm compiles, so build the WASM target now to catch any feature-unification regression early:
cargo build -p relicario-wasm --target wasm32-unknown-unknown 2>&1 | tail -10
Expected: clean build.
- Step 6: Commit
git add crates/relicario-core/Cargo.toml crates/relicario-core/src/org.rs crates/relicario-core/src/lib.rs
git commit -m "feat(core/org): add x25519-dalek dep + stub org module"
[Dev-A] Task A2: Org types — IDs, members, collections, org meta, ECIES wrap/unwrap
Files:
- Modify:
crates/relicario-core/src/org.rs - Modify:
crates/relicario-core/src/lib.rs
This task implements all org types plus the ECIES key-wrap/unwrap. Two corrections from the adversarial review are folded in: H6 (all KDF intermediates carrying the DH secret are held in Zeroizing) and H7 (the test keypair helper uses PrivateKey::from(Ed25519Keypair::from(&signing_key)) — ssh-key 0.6.7 has no From<SigningKey> for PrivateKey).
- Step 1: Write failing test for MemberId format
#[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);
}
}
Add this at the bottom of org.rs, run:
cargo test -p relicario-core org::tests::member_id_is_16_hex_chars 2>&1 | tail -5
Expected: FAIL — MemberId not defined.
- Step 2: Implement all org types + ECIES wrap/unwrap (with H6 Zeroizing folded in)
Replace the stub org.rs with:
//! 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.as_ref());
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);
}
#[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());
}
}
- Step 3: Run tests
cargo test -p relicario-core org:: 2>&1 | tail -20
Expected: all org tests pass.
- Step 4: Update lib.rs re-exports
Replace the pub mod org; stub line in crates/relicario-core/src/lib.rs with:
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,
};
cargo check -p relicario-core
Expected: clean compile.
- Step 5: Commit
git add crates/relicario-core/src/org.rs crates/relicario-core/src/lib.rs
git commit -m "feat(core/org): org types, manifest, and X25519 key wrap/unwrap (Zeroizing KDF)"
[Dev-A] Task A3: Org manifest vault wrappers
Files:
-
Modify:
crates/relicario-core/src/vault.rs -
Modify:
crates/relicario-core/src/lib.rs -
Step 1: Write failing tests
Add to crates/relicario-core/src/vault.rs tests block:
#[test]
fn org_manifest_round_trip() {
use crate::org::{OrgManifest, OrgManifestEntry, MemberId};
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");
}
cargo test -p relicario-core vault::tests::org_manifest_round_trip 2>&1 | tail -5
Expected: FAIL — encrypt_org_manifest not defined.
- Step 2: Add org manifest wrappers to vault.rs
Add to crates/relicario-core/src/vault.rs (after the existing decrypt_settings function):
use crate::org::OrgManifest;
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)
}
Also add the re-exports in lib.rs vault pub use block:
pub use vault::{
decrypt_item, decrypt_manifest, decrypt_org_manifest, decrypt_settings,
encrypt_item, encrypt_manifest, encrypt_org_manifest, encrypt_settings,
};
- Step 3: Run tests
cargo test -p relicario-core vault::tests::org_manifest_round_trip
cargo test -p relicario-core
Expected: all tests pass.
- Step 4: Commit
git add crates/relicario-core/src/vault.rs crates/relicario-core/src/lib.rs
git commit -m "feat(core/org): encrypt/decrypt_org_manifest vault wrappers"
[Dev-A] Task A4: Full org lifecycle integration test (core)
Files:
- Create:
crates/relicario-core/tests/org.rs
This test exercises the complete core-level org flow without requiring a device key on disk. The keypair helper uses the H7 fix (PrivateKey::from(Ed25519Keypair::from(&signing_key))).
Note: The old plan's "add ed25519-dalek and ssh-key as dev-dependencies" step is removed — these crates are already in
relicario-core's[dependencies], so integration tests (which compile against the crate) already have them available. No[dev-dependencies]edit is required.
- Step 1: Write the integration test
Create crates/relicario-core/tests/org.rs:
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());
}
- Step 2: Run all org integration tests
cargo test -p relicario-core --test org 2>&1 | tail -20
cargo test 2>&1 | tail -20
Expected: all 5 tests pass; full suite green.
- Step 3: Final commit
git add crates/relicario-core/tests/org.rs
git commit -m "test(core/org): full org lifecycle integration tests"
Dev-B — relicario-cli (session, device, admin commands, item CRUD, wiring)
Stream prerequisite: Tasks A2–A3 must be merged before Dev-B begins (every command imports
relicario_core::orgtypes/functions).
[Dev-B] Task B1: UnlockedOrgVault session type (collection-scoped item_path + fingerprint member match + signed org_git_run)
Files:
- Create:
crates/relicario-cli/src/org_session.rs - Modify:
crates/relicario-cli/src/main.rs
UnlockedOrgVault holds the org master key for one CLI invocation. Two corrections from the review are baked in from the start: (1) item_path is collection-scoped — items/<collection-slug>/<id>.enc — so the hook can authorize item writes by leading path segment without decrypting; (2) the caller is matched to members.json by ed25519 fingerprint (via relicario_core::fingerprint), not brittle raw-OpenSSH-string equality. org_git_run runs bare git (NOT the hardened helpers::git_run, which force-disables signing) so org commits are signed.
- Step 1: Implement UnlockedOrgVault
Create crates/relicario-cli/src/org_session.rs:
//! Unlocked org vault session: holds the org master key for the duration of a
//! CLI invocation.
use std::fs;
use std::path::{Path, PathBuf};
use anyhow::{bail, Context, Result};
use zeroize::Zeroizing;
use relicario_core::{
decrypt_item, decrypt_org_manifest, encrypt_item, encrypt_org_manifest,
Item, ItemId, MemberId, OrgCollections, OrgManifest, OrgMember, OrgMembers, OrgMeta,
};
pub struct UnlockedOrgVault {
pub root: PathBuf,
pub org_key: Zeroizing<[u8; 32]>,
}
impl UnlockedOrgVault {
pub fn root(&self) -> &Path { &self.root }
pub fn key(&self) -> &Zeroizing<[u8; 32]> { &self.org_key }
pub fn manifest_path(&self) -> PathBuf { self.root.join("manifest.enc") }
/// Collection-scoped item path: `items/<collection-slug>/<id>.enc`.
/// The leading slug segment is what the pre-receive hook authorizes against
/// members.json — it never decrypts the blob. The slug must be non-empty and
/// already validated.
pub fn item_path(&self, collection_slug: &str, id: &ItemId) -> PathBuf {
self.root
.join("items")
.join(collection_slug)
.join(format!("{}.enc", id.as_str()))
}
pub fn member_key_path(&self, id: &MemberId) -> PathBuf {
self.root.join("keys").join(format!("{}.enc", id.as_str()))
}
pub fn members_path(&self) -> PathBuf { self.root.join("members.json") }
pub fn collections_path(&self) -> PathBuf { self.root.join("collections.json") }
pub fn org_meta_path(&self) -> PathBuf { self.root.join("org.json") }
pub fn load_meta(&self) -> Result<OrgMeta> {
let s = fs::read_to_string(self.org_meta_path()).context("read org.json")?;
Ok(serde_json::from_str(&s).context("parse org.json")?)
}
pub fn load_members(&self) -> Result<OrgMembers> {
let s = fs::read_to_string(self.members_path()).context("read members.json")?;
Ok(serde_json::from_str(&s).context("parse members.json")?)
}
pub fn save_members(&self, members: &OrgMembers) -> Result<()> {
let json = serde_json::to_string_pretty(members)?;
atomic_write(&self.members_path(), json.as_bytes())
}
pub fn load_collections(&self) -> Result<OrgCollections> {
let s = fs::read_to_string(self.collections_path()).context("read collections.json")?;
Ok(serde_json::from_str(&s).context("parse collections.json")?)
}
pub fn save_collections(&self, collections: &OrgCollections) -> Result<()> {
let json = serde_json::to_string_pretty(collections)?;
atomic_write(&self.collections_path(), json.as_bytes())
}
pub fn load_manifest(&self) -> Result<OrgManifest> {
let bytes = fs::read(self.manifest_path()).context("read manifest.enc")?;
Ok(decrypt_org_manifest(&bytes, &self.org_key)?)
}
pub fn save_manifest(&self, manifest: &OrgManifest) -> Result<()> {
let bytes = encrypt_org_manifest(manifest, &self.org_key)?;
atomic_write(&self.manifest_path(), &bytes)
}
/// Encrypt + write an item under its collection directory, creating the
/// directory if needed. Returns the repo-relative path for git staging.
pub fn save_item(&self, collection_slug: &str, item: &Item) -> Result<String> {
let path = self.item_path(collection_slug, &item.id);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("create {}", parent.display()))?;
}
let bytes = encrypt_item(item, &self.org_key)?;
atomic_write(&path, &bytes)?;
Ok(format!("items/{}/{}.enc", collection_slug, item.id.as_str()))
}
/// Read + decrypt an item from its collection directory.
pub fn load_item(&self, collection_slug: &str, id: &ItemId) -> Result<Item> {
let path = self.item_path(collection_slug, id);
let bytes = fs::read(&path)
.with_context(|| format!("read item {}", path.display()))?;
Ok(decrypt_item(&bytes, &self.org_key)?)
}
/// Delete an item blob. Missing file is not an error (partial-write
/// recovery, same as the personal-vault purge path).
pub fn remove_item(&self, collection_slug: &str, id: &ItemId) -> Result<()> {
let path = self.item_path(collection_slug, id);
match fs::remove_file(&path) {
Ok(()) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(anyhow::Error::from(e)
.context(format!("delete {}", path.display()))),
}
}
/// Bail unless `member` has `slug` in their collection grants. The slug
/// existence check is done separately by the caller against collections.json.
pub fn ensure_grant(member: &OrgMember, slug: &str) -> Result<()> {
if member.collections.iter().any(|c| c == slug) {
Ok(())
} else {
bail!(
"access denied: you do not have a grant for collection `{slug}` — ask an admin to run `relicario org grant`"
)
}
}
/// Load members.json and find the caller's member entry by matching the
/// current device's ed25519 fingerprint against each member's pubkey
/// fingerprint. Fingerprint comparison (not raw OpenSSH-string equality)
/// tolerates comment/whitespace differences in the serialized key.
pub fn current_member(&self) -> Result<relicario_core::OrgMember> {
let device_fp = current_device_fingerprint()?;
let members = self.load_members()?;
members
.members
.into_iter()
.find(|m| {
relicario_core::fingerprint(&m.ed25519_pubkey)
.ok()
.as_deref()
== Some(device_fp.as_str())
})
.ok_or_else(|| {
anyhow::anyhow!(
"your device key is not registered in this org — ask an admin to run `org add-member`"
)
})
}
}
/// Locate the org vault root from RELICARIO_ORG_DIR env var or --dir flag value.
pub fn org_dir(dir_flag: Option<&std::path::Path>) -> Result<PathBuf> {
if let Some(d) = dir_flag {
return Ok(d.to_path_buf());
}
if let Ok(v) = std::env::var("RELICARIO_ORG_DIR") {
return Ok(PathBuf::from(v));
}
bail!("org vault location required: set RELICARIO_ORG_DIR or pass --dir <path>")
}
/// Open an org vault: locate the root, read members.json to find the caller's
/// member entry (by ed25519 fingerprint), then unwrap their keys/<id>.enc to
/// recover the org master key.
pub fn open_org_vault(dir_flag: Option<&std::path::Path>) -> Result<UnlockedOrgVault> {
let root = org_dir(dir_flag)?;
let device_fp = current_device_fingerprint()?;
let members_json = fs::read_to_string(root.join("members.json"))
.context("read members.json — is this an org vault?")?;
let members: OrgMembers = serde_json::from_str(&members_json).context("parse members.json")?;
let member = members
.members
.iter()
.find(|m| {
relicario_core::fingerprint(&m.ed25519_pubkey)
.ok()
.as_deref()
== Some(device_fp.as_str())
})
.ok_or_else(|| anyhow::anyhow!("your device key is not in this org"))?;
// Load this member's wrapped key blob.
let key_path = root
.join("keys")
.join(format!("{}.enc", member.member_id.as_str()));
let wrapped =
fs::read(&key_path).with_context(|| format!("read {}", key_path.display()))?;
// Recover the device ed25519 seed and unwrap.
let seed = current_device_seed()?;
let org_key = relicario_core::unwrap_org_key(&wrapped, &seed)?;
Ok(UnlockedOrgVault { root, org_key })
}
/// OpenSSH SHA-256 fingerprint of the active device's signing key.
fn current_device_fingerprint() -> Result<String> {
let name = crate::device::current_device()?
.ok_or_else(|| anyhow::anyhow!("no active device — run `relicario device add` first"))?;
let pub_path = crate::device::device_dir(&name)?.join("signing.pub");
let pubkey = fs::read_to_string(&pub_path)
.with_context(|| format!("read {}", pub_path.display()))?;
Ok(relicario_core::fingerprint(pubkey.trim())?)
}
/// Recover the active device's ed25519 seed (the 32-byte private scalar source)
/// from its OpenSSH `signing.key`, for ECIES unwrap.
fn current_device_seed() -> Result<Zeroizing<[u8; 32]>> {
let name = crate::device::current_device()?
.ok_or_else(|| anyhow::anyhow!("no active device — run `relicario device add` first"))?;
let key_pem = crate::device::load_signing_key(&name)?;
let private = ssh_key::PrivateKey::from_openssh(key_pem.as_str())
.map_err(|e| anyhow::anyhow!("parse device signing key: {e}"))?;
let ed = private
.key_data()
.ed25519()
.ok_or_else(|| anyhow::anyhow!("device signing key is not ed25519"))?;
// Ed25519PrivateKey derefs to its 32-byte seed.
let seed_bytes: &[u8] = ed.private.as_ref();
if seed_bytes.len() != 32 {
anyhow::bail!("ed25519 seed has wrong length: {}", seed_bytes.len());
}
let mut seed = Zeroizing::new([0u8; 32]);
seed.copy_from_slice(seed_bytes);
Ok(seed)
}
pub(crate) fn atomic_write(path: &Path, data: &[u8]) -> Result<()> {
let mut tmp = path.as_os_str().to_owned();
tmp.push(".tmp");
let tmp = PathBuf::from(tmp);
fs::write(&tmp, data).with_context(|| format!("write {}", tmp.display()))?;
fs::rename(&tmp, path).with_context(|| format!("rename {}", tmp.display()))?;
Ok(())
}
/// Run `git <args>` in the org repo, capturing output and replaying it on
/// failure. Unlike `crate::helpers::git_run`, this does NOT inject
/// `commit.gpgsign=false` / `core.hooksPath=/dev/null`: org commits MUST be
/// signed (the pre-receive hook verifies every commit's signature), and the
/// repo's signing config is established by `configure_git_signing` during
/// `org init`.
pub(crate) fn org_git_run(root: &Path, args: &[&str], context: &str) -> Result<()> {
let output = std::process::Command::new("git")
.current_dir(root)
.args(args)
.output()
.with_context(|| format!("{context}: failed to spawn git"))?;
if !output.status.success() {
if !output.stdout.is_empty() {
eprint!("{}", String::from_utf8_lossy(&output.stdout));
}
if !output.stderr.is_empty() {
eprint!("{}", String::from_utf8_lossy(&output.stderr));
}
anyhow::bail!("{context}: git failed ({})", output.status);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
use std::fs;
fn make_vault(key: Zeroizing<[u8; 32]>) -> (TempDir, UnlockedOrgVault) {
let dir = TempDir::new().unwrap();
let root = dir.path().to_path_buf();
fs::create_dir_all(root.join("items")).unwrap();
fs::create_dir_all(root.join("keys")).unwrap();
let vault = UnlockedOrgVault { root, org_key: key };
(dir, vault)
}
#[test]
fn unlocked_org_vault_paths() {
let key = Zeroizing::new([0u8; 32]);
let (dir, vault) = make_vault(key);
let root = dir.path().to_path_buf();
assert_eq!(vault.manifest_path(), root.join("manifest.enc"));
assert_eq!(
vault.member_key_path(&MemberId("abc0def1abc0def1".into())),
root.join("keys/abc0def1abc0def1.enc")
);
assert_eq!(
vault.item_path("prod", &relicario_core::ItemId("0123456789abcdef".into())),
root.join("items/prod/0123456789abcdef.enc")
);
}
#[test]
fn save_and_load_manifest() {
let key = Zeroizing::new([0xAAu8; 32]);
let (dir, vault) = make_vault(key);
let _ = dir; // keep alive
let mut m = OrgManifest::new();
m.entries.push(relicario_core::OrgManifestEntry {
id: relicario_core::ItemId::new(),
r#type: relicario_core::ItemType::SecureNote,
title: "test".into(),
tags: vec![],
modified: 0,
trashed_at: None,
collection: "prod".into(),
});
vault.save_manifest(&m).unwrap();
let loaded = vault.load_manifest().unwrap();
assert_eq!(loaded.entries.len(), 1);
}
#[test]
fn save_and_load_members() {
let key = Zeroizing::new([0u8; 32]);
let (dir, vault) = make_vault(key);
let _ = dir;
let members = OrgMembers::new();
vault.save_members(&members).unwrap();
let loaded = vault.load_members().unwrap();
assert_eq!(loaded.schema_version, 1);
}
}
Note:
current_device(),device_dir(name), andload_signing_key(name)are real accessors incrates/relicario-cli/src/device.rs(device.rs:32, :27, :95).relicario_core::fingerprintis re-exported at corelib.rs:94. TheItemId(..)/MemberId(..)tuple constructors in the tests are valid because both arepubtuple structs. Ifitem_path/save_item/load_item/remove_item/ensure_grantwarn as unused at this point, that is fine — B9–B13 consume them in the same PR series; do NOT add#[allow(dead_code)].
- Step 2: Wire into main.rs module declarations
In crates/relicario-cli/src/main.rs, add after the existing mod session; line:
mod org_session;
- Step 3: Run tests
cargo test -p relicario-cli org_session 2>&1 | tail -20
Expected: all org_session tests pass.
- Step 4: Commit
git add crates/relicario-cli/src/org_session.rs crates/relicario-cli/src/main.rs
git commit -m "feat(cli/org): UnlockedOrgVault session (collection-scoped item_path, fingerprint match, signed org_git_run)"
[Dev-B] Task B2: Device seed/pubkey helpers + ssh-key CLI dep
Files:
- Modify:
crates/relicario-cli/Cargo.toml - Modify:
crates/relicario-cli/src/device.rs
Context (verified against the real codebase): Device keys live under
~/.config/relicario/devices/<name>/signing.key(OpenSSH private) +signing.pub(OpenSSH public single line). The active device name is in~/.config/relicario/devices/current. There is no~/.config/relicario/device.keyand noRELICARIO_DEVICE_KEYenv var. Use the real accessors already incrates/relicario-cli/src/device.rs:current_device() -> Result<Option<String>>(device.rs:32),device_dir(name) -> Result<PathBuf>(device.rs:27),load_signing_key(name) -> Result<Zeroizing<String>>(device.rs:95, returns the OpenSSH private PEM text). The seed-extraction path is verified againstssh-key0.6.7:PrivateKey::from_openssh(&pem)→.key_data()→.ed25519()(Option<&Ed25519Keypair>) →.private(Ed25519PrivateKey) →.as_ref()(&[u8; 32]). The core crate already uses this exact chain inrelicario-core/src/device.rs:64-69.
- Step 1: Add
ssh-keyto the CLI crate's dependencies
ssh-key is not currently a dependency of relicario-cli (verified: no ssh-key line in crates/relicario-cli/Cargo.toml). It is already in the workspace lock at version 0.6.7 (pulled in by relicario-core). In crates/relicario-cli/Cargo.toml, under [dependencies], add the line after the qrcode entry:
ssh-key = { version = "0.6", features = ["ed25519", "std"] }
The full [dependencies] tail should read:
reqwest = { version = "0.12", features = ["blocking", "json"] }
qrcode = { version = "0.14", features = ["svg"] }
ssh-key = { version = "0.6", features = ["ed25519", "std"] }
Why these features:
ed25519enables theEd25519Keypair/Ed25519PrivateKeyaccessors and thekey_data().ed25519()path;stdenablesPrivateKey::from_openssh/PublicKey::to_openssh. This matches the feature setrelicario-corealready relies on, so resolution stays at 0.6.7 — no new lockfile entry, no version bump.
- Step 2: Confirm the dependency resolves without changing the lock version
cargo tree -p relicario-cli -i ssh-key 2>&1 | head -5
grep -A1 'name = "ssh-key"' Cargo.lock | head -3
Expected: ssh-key v0.6.7 appears for relicario-cli, and Cargo.lock still pins version = "0.6.7" (no second copy of ssh-key added).
- Step 3: Add a failing unit test for the round-trip
Append this test module to the end of crates/relicario-cli/src/device.rs. It generates a keypair with relicario_core::device::generate_keypair(), writes it to a temp device dir, points current at it, and asserts the seed we extract re-derives the same OpenSSH public key.
Note: This test mutates
XDG_CONFIG_HOMEto redirectdirs::config_dir(); it serializes via aMutexand restores the env afterwards. Requires thetempfiledev-dependency (already present in[dev-dependencies]).
#[cfg(test)]
mod seed_helper_tests {
use super::*;
use std::sync::Mutex;
// dirs::config_dir() reads process-wide env; serialize these tests.
static ENV_LOCK: Mutex<()> = Mutex::new(());
#[test]
fn current_device_seed_and_pubkey_round_trip() {
let _guard = ENV_LOCK.lock().unwrap();
let tmp = tempfile::tempdir().unwrap();
let prev_xdg = std::env::var_os("XDG_CONFIG_HOME");
std::env::set_var("XDG_CONFIG_HOME", tmp.path());
// Generate a real ed25519 device keypair (OpenSSH text) via core.
let (private_openssh, public_openssh) =
relicario_core::device::generate_keypair().unwrap();
// Lay out devices/test-dev/{signing.key,signing.pub} + devices/current.
let dir = device_dir("test-dev").unwrap();
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("signing.key"), private_openssh.as_str()).unwrap();
std::fs::write(dir.join("signing.pub"), &public_openssh).unwrap();
set_current_device("test-dev").unwrap();
// pubkey helper returns exactly the stored OpenSSH public line.
let got_pub = current_device_pubkey().unwrap();
assert_eq!(got_pub.trim(), public_openssh.trim());
// seed helper returns the 32-byte ed25519 seed; re-derive the public
// key from it and confirm it matches.
let seed = current_device_seed().unwrap();
let signing = ed25519_dalek::SigningKey::from_bytes(&seed);
let derived = signing.verifying_key();
let parsed_pub = ssh_key::PublicKey::from_openssh(&public_openssh).unwrap();
let parsed_bytes: &[u8] = parsed_pub.key_data().ed25519().unwrap().as_ref();
assert_eq!(derived.as_bytes().as_slice(), parsed_bytes);
// restore env
match prev_xdg {
Some(v) => std::env::set_var("XDG_CONFIG_HOME", v),
None => std::env::remove_var("XDG_CONFIG_HOME"),
}
}
}
New dev-dependency note: This test references
ed25519_dalek::SigningKey.ed25519-dalekis not a dev-dependency ofrelicario-cli. Add it tocrates/relicario-cli/Cargo.tomlunder[dev-dependencies](already in the workspace lock at the versionrelicario-coreuses, so no new lock entry):ed25519-dalek = "2"
Run it — it must fail to compile now (the two helper fns don't exist yet):
cargo test -p relicario-cli --lib seed_helper_tests 2>&1 | tail -20
Expected: compile error cannot find function 'current_device_seed' (and current_device_pubkey).
- Step 4: Implement the two helpers
Add these two public functions to crates/relicario-cli/src/device.rs, immediately after load_signing_key (after device.rs:100). Do not add a device_key_path().
/// Read the active device's ed25519 public key (OpenSSH single-line format,
/// e.g. `ssh-ed25519 AAAA... comment`) from `signing.pub`.
///
/// Errors if no device is selected (`devices/current` missing/empty) — the
/// caller should hint the user to run `relicario device add` first.
pub fn current_device_pubkey() -> Result<String> {
let name = current_device()?
.ok_or_else(|| anyhow::anyhow!("no active device — run `relicario device add` first"))?;
let path = device_dir(&name)?.join("signing.pub");
let pubkey = fs::read_to_string(&path)
.with_context(|| format!("read signing.pub for device '{name}'"))?;
let trimmed = pubkey.trim();
if trimmed.is_empty() {
anyhow::bail!("signing.pub for device '{name}' is empty");
}
Ok(trimmed.to_string())
}
/// Read the active device's 32-byte ed25519 seed from `signing.key`
/// (OpenSSH private-key format).
///
/// The seed is the secret scalar used to sign org commits and to unwrap the
/// org key. It is returned in `Zeroizing` so it is wiped on drop. Errors if no
/// device is selected, the key file is unreadable, or the key is not ed25519.
pub fn current_device_seed() -> Result<Zeroizing<[u8; 32]>> {
let name = current_device()?
.ok_or_else(|| anyhow::anyhow!("no active device — run `relicario device add` first"))?;
// load_signing_key reads signing.key as OpenSSH private-key text.
let pem = load_signing_key(&name)?;
let private = ssh_key::PrivateKey::from_openssh(pem.as_str())
.map_err(|e| anyhow::anyhow!("parse signing.key for device '{name}': {e}"))?;
let keypair = private
.key_data()
.ed25519()
.ok_or_else(|| anyhow::anyhow!("signing.key for device '{name}' is not ed25519"))?;
// Ed25519PrivateKey::as_ref() yields &[u8; 32] (verified: ssh-key 0.6.7
// private/ed25519.rs:42). Copy into a Zeroizing array so the seed is wiped.
let mut seed = Zeroizing::new([0u8; 32]);
seed.copy_from_slice(keypair.private.as_ref());
Ok(seed)
}
Import note:
fs,Context,Result, andZeroizingare already imported at the top ofdevice.rs(lines 13–17).ssh_keyis referenced by fully-qualified path.anyhow::anyhow!/anyhow::bail!are used fully-qualified to match the existing style.
Note on
current_device_seedoverlap:org_session.rs(Task B1) defines its own privatecurrent_device_seed/current_device_fingerprintfor the unwrap path. These device.rs helpers are the public device-module surface (also used by Task B4's signing test and by any future caller). They are not the same functions; B1's are module-private toorg_session. Both reading the same on-disk key is intentional and correct.
- Step 5: Verify the test goes green + regression
cargo test -p relicario-cli --lib seed_helper_tests 2>&1 | tail -20
cargo check -p relicario-cli 2>&1 | tail -5
cargo test -p relicario-cli --lib 2>&1 | tail -15
Expected: current_device_seed_and_pubkey_round_trip ... ok; clean check; all lib tests pass.
- Step 6: Commit
git add crates/relicario-cli/Cargo.toml Cargo.lock crates/relicario-cli/src/device.rs
git commit -m "feat(cli/device): current_device_seed + current_device_pubkey helpers
Read the active device's ed25519 seed/pubkey from
devices/<name>/signing.{key,pub}. Adds ssh-key (0.6) as a CLI dep
(already at 0.6.7 in the workspace lock via relicario-core) and
ed25519-dalek as a dev-dep for the round-trip test.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
[Dev-B] Task B3: Org commands module stub + pub mod org wiring
Files:
-
Create:
crates/relicario-cli/src/commands/org.rs(stub) -
Modify:
crates/relicario-cli/src/commands/mod.rs -
Step 1: Check existing module layout
grep -n "pub mod" crates/relicario-cli/src/commands/mod.rs | head -30
- Step 2: Create org commands stub
Create crates/relicario-cli/src/commands/org.rs:
//! `relicario org` subcommands for multi-user org vault management.
use anyhow::Result;
pub fn run_init(_dir: &std::path::Path, _name: &str) -> Result<()> {
todo!("org init")
}
Add to crates/relicario-cli/src/commands/mod.rs:
pub mod org;
- Step 3: Verify compile
cargo check -p relicario-cli
Expected: clean (todo! is fine at compile time).
- Step 4: Commit
git add crates/relicario-cli/src/commands/org.rs crates/relicario-cli/src/commands/mod.rs
git commit -m "feat(cli/org): org commands module stub + pub mod wiring"
[Dev-B] Task B4: org init (with git signing)
Files:
- Modify:
crates/relicario-cli/src/commands/org.rs - Create:
crates/relicario-cli/tests/org_init.rs - Create:
crates/relicario-cli/tests/org_init_signing.rs
org init creates the org directory structure, generates the org master key, wraps it to the caller's device key, writes all initial files, runs git init, configures git signing (configure_git_signing), and makes a signed bootstrap commit via org_git_run.
Why signing is load-bearing:
crate::helpers::git_run(helpers.rs:73) flows throughgit_command(helpers.rs:46), which hard-codes-c commit.gpgsign=false(helpers.rs:51). Any commit made throughgit_runis unsigned — and the pre-receive hook (Dev-C) rejects/cannot-attribute unsigned commits. Soorg initmust (1) callconfigure_git_signing(dir, &device_name)(device.rs:133) to setgpg.format=ssh,user.signingkey=<signing.key>,commit.gpgsign=true, and (2) make the bootstrap commit viaorg_git_run(Task B1), which does NOT disable signing. This task folds the org-init structure/wrap logic and the signing wiring into one — there is no separate standalone signing task.
- Step 1: Write the plain integration test
Create crates/relicario-cli/tests/org_init.rs:
use tempfile::TempDir;
fn run(args: &[&str]) -> std::process::Output {
std::process::Command::new(env!("CARGO_BIN_EXE_relicario"))
.args(args)
.output()
.expect("run relicario")
}
#[test]
#[ignore] // requires a device key on disk; run manually or via org_init_signing
fn org_init_creates_expected_files() {
let dir = TempDir::new().unwrap();
let path = dir.path().to_str().unwrap();
let out = run(&["--dir", path, "org", "init", "--name", "Test Org"]);
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
assert!(dir.path().join("org.json").exists());
assert!(dir.path().join("members.json").exists());
assert!(dir.path().join("collections.json").exists());
assert!(dir.path().join("manifest.enc").exists());
assert!(dir.path().join(".git").exists());
}
- Step 2: Write the failing signing integration test
Create crates/relicario-cli/tests/org_init_signing.rs:
use std::path::Path;
use std::process::Command;
use tempfile::TempDir;
fn relicario(config_home: &Path, args: &[&str]) -> std::process::Output {
Command::new(env!("CARGO_BIN_EXE_relicario"))
.env("XDG_CONFIG_HOME", config_home)
.env("HOME", config_home) // belt-and-suspenders for dirs on all platforms
.args(args)
.output()
.expect("run relicario")
}
fn git(repo: &Path, args: &[&str]) -> std::process::Output {
Command::new("git")
.current_dir(repo)
.args(args)
.output()
.expect("run git")
}
#[test]
fn org_init_produces_a_signed_initial_commit() {
let cfg = TempDir::new().unwrap();
let org = TempDir::new().unwrap();
// Register a device so current_device_pubkey()/configure_git_signing work.
let add = relicario(cfg.path(), &["device", "add", "--name", "test-dev", "--local"]);
assert!(
add.status.success(),
"device add failed: {}",
String::from_utf8_lossy(&add.stderr)
);
// Initialize the org vault.
let init = relicario(
cfg.path(),
&["--dir", org.path().to_str().unwrap(), "org", "init", "--name", "Acme"],
);
assert!(
init.status.success(),
"org init failed: {}",
String::from_utf8_lossy(&init.stderr)
);
// The org repo must be configured to sign.
let cfg_out = git(org.path(), &["config", "commit.gpgsign"]);
assert_eq!(
String::from_utf8_lossy(&cfg_out.stdout).trim(),
"true",
"org repo must have commit.gpgsign=true"
);
// The HEAD commit object must carry a signature header.
let head = git(org.path(), &["cat-file", "commit", "HEAD"]);
let body = String::from_utf8_lossy(&head.stdout);
assert!(
body.contains("gpgsig "),
"HEAD commit must be signed (no gpgsig header found):\n{body}"
);
// And it must actually verify against the configured signer.
let verify = git(org.path(), &["verify-commit", "HEAD"]);
assert!(
verify.status.success(),
"git verify-commit HEAD failed: {}",
String::from_utf8_lossy(&verify.stderr)
);
}
Test-harness assumption:
relicario device add --name <n> --localcreates the device keys under$XDG_CONFIG_HOME/relicario/devices/<n>/and setscurrentwithout contacting Gitea. If the realdevice addsurface differs (verify againstmain.rsDeviceActionandcommands::device), adjust the flags — but the assertions (signed HEAD,commit.gpgsign=true,verify-commitsuccess) are load-bearing and must stay. Ifdevice addcannot run offline in CI, lay outdevices/<n>/signing.{key,pub}+currentdirectly usingrelicario_core::device::generate_keypair()(same layout as Task B2 Step 3).
cargo test -p relicario-cli --test org_init_signing 2>&1 | tail -25
Expected red: commit.gpgsign empty or gpgsig header absent (init not implemented / committed via unsigned path).
- Step 3: Implement
run_init(structure + wrap + signing + signed commit)
Replace run_init stub in commands/org.rs:
use std::fs;
use std::path::Path;
use anyhow::{Context, Result};
use relicario_core::{
generate_org_key, wrap_org_key,
CollectionDef, MemberId, OrgCollections, OrgManifest, OrgMembers, OrgMeta, OrgRole, OrgMember,
encrypt_org_manifest,
};
use crate::org_session::atomic_write;
pub fn run_init(dir: &Path, name: &str) -> Result<()> {
// Create directory structure
fs::create_dir_all(dir.join("items")).context("create items/")?;
fs::create_dir_all(dir.join("keys")).context("create keys/")?;
// Get caller's device info
let device_pubkey = crate::device::current_device_pubkey()
.context("read device key — run `relicario device add` first")?;
// Generate org master key
let org_key = generate_org_key();
// Wrap org key to caller's device key
let wrapped = wrap_org_key(&org_key, &device_pubkey)
.context("wrap org key to device key")?;
// Create initial members.json with caller as owner
let caller_id = MemberId::new();
let now = relicario_core::now_unix();
let member = OrgMember {
member_id: caller_id.clone(),
display_name: whoami(),
role: OrgRole::Owner,
ed25519_pubkey: device_pubkey,
collections: vec![],
added_at: now,
added_by: caller_id.clone(),
};
let mut members = OrgMembers::new();
members.members.push(member);
// Write wrapped key
let key_path = dir.join("keys").join(format!("{}.enc", caller_id.as_str()));
fs::write(&key_path, &wrapped).context("write caller key blob")?;
// Write org.json
let meta = OrgMeta::new(name.to_string());
let meta_json = serde_json::to_string_pretty(&meta)?;
atomic_write(&dir.join("org.json"), meta_json.as_bytes())?;
// Write members.json
let members_json = serde_json::to_string_pretty(&members)?;
atomic_write(&dir.join("members.json"), members_json.as_bytes())?;
// Write collections.json (empty)
let collections = OrgCollections::new();
let coll_json = serde_json::to_string_pretty(&collections)?;
atomic_write(&dir.join("collections.json"), coll_json.as_bytes())?;
// Write empty manifest.enc
let manifest = OrgManifest::new();
let manifest_bytes = encrypt_org_manifest(&manifest, &org_key)?;
atomic_write(&dir.join("manifest.enc"), &manifest_bytes)?;
// git init, then configure THIS repo to sign commits with the active device
// key. Org commits must be signed; the pre-receive hook verifies every one.
crate::helpers::git_run(dir, &["init"], "git init")?;
let device_name = crate::device::current_device()?
.ok_or_else(|| anyhow::anyhow!("no active device — run `relicario device add` first"))?;
crate::device::configure_git_signing(dir, &device_name)
.context("configure org repo signing")?;
// Stage everything and make the signed bootstrap commit via org_git_run
// (which does NOT disable signing, unlike helpers::git_run).
crate::org_session::org_git_run(dir, &["add", "."], "git add")?;
let commit_msg = format!(
"init: org vault \"{name}\"\n\nRelicario-Actor: {} {}\nRelicario-Action: org-init",
members.members[0].display_name,
caller_id.as_str()
);
crate::org_session::org_git_run(dir, &["commit", "-m", &commit_msg], "git commit")?;
println!("Org vault initialized at {}", dir.display());
println!("Your member ID: {}", caller_id.as_str());
Ok(())
}
fn whoami() -> String {
std::env::var("USER")
.or_else(|_| std::env::var("USERNAME"))
.unwrap_or_else(|_| "unknown".into())
}
Verified:
configure_git_signing(vault_root: &Path, name: &str) -> Result<()>(device.rs:133). The trailer usesRelicario-Actor: <name> <member_id>(space-separated, no angle brackets) per the canonical contract — the hook ignores the trailer for actor attribution (it uses the verified signer), but keeping it contract-shaped matters for the auditTAMPEREDcross-check.
- Step 4: Verify both tests + build
cargo test -p relicario-cli --test org_init_signing 2>&1 | tail -25
cargo build -p relicario-cli 2>&1 | tail -10
Expected: org_init_produces_a_signed_initial_commit ... ok; clean build.
- Step 5: Commit
git add crates/relicario-cli/src/commands/org.rs crates/relicario-cli/tests/org_init.rs crates/relicario-cli/tests/org_init_signing.rs
git commit -m "feat(cli/org): org init — structure + wrap + configure_git_signing + signed bootstrap commit"
[Dev-B] Task B5: org add-member / remove-member / set-role (with owner-only role-gating)
Files:
- Modify:
crates/relicario-cli/src/commands/org.rs
All three commands share the open-vault → edit members.json → signed commit pattern. Role-gating correction: add-member only checked can_manage_members() and then trusted the caller-supplied role unconditionally — an admin could mint a new owner. The fix adds an owner-only gate so non-owners cannot create owner/admin members (spec line 148/273).
- Step 1: Write failing unit test
Add to commands/org.rs:
#[cfg(test)]
mod tests {
use super::*;
use relicario_core::{MemberId, OrgMembers, OrgRole, OrgMember};
fn alice() -> OrgMember {
OrgMember {
member_id: MemberId::new(),
display_name: "Alice".into(),
role: OrgRole::Member,
ed25519_pubkey: "ssh-ed25519 AAAA fake".into(),
collections: vec![],
added_at: 0,
added_by: MemberId::new(),
}
}
#[test]
fn set_role_changes_role() {
let mut members = OrgMembers::new();
let a = alice();
let id = a.member_id.clone();
members.members.push(a);
if let Some(m) = members.find_by_id_mut(&id) {
m.role = OrgRole::Admin;
}
assert_eq!(members.find_by_id(&id).unwrap().role, OrgRole::Admin);
}
}
cargo test -p relicario-cli commands::org::tests 2>&1 | tail -5
Expected: PASS (pure logic test).
- Step 2: Implement add-member (with owner-only escalation guard)
Add to commands/org.rs:
pub fn run_add_member(
dir: &Path,
pubkey: &str,
name: &str,
role: OrgRole,
) -> Result<()> {
let vault = crate::org_session::open_org_vault(Some(dir))?;
let caller = vault.current_member()?;
if !caller.role.can_manage_members() {
anyhow::bail!("only owners and admins can add members");
}
// Privilege-escalation guard: only an owner may create an owner or admin.
if matches!(role, OrgRole::Owner | OrgRole::Admin) && !caller.role.can_manage_owners() {
anyhow::bail!("only owners can add members with the owner or admin role");
}
let mut members = vault.load_members()?;
// Check pubkey not already present
if members.members.iter().any(|m| m.ed25519_pubkey.trim() == pubkey.trim()) {
anyhow::bail!("this public key is already registered in the org");
}
let new_id = MemberId::new();
let now = relicario_core::now_unix();
let wrapped = wrap_org_key(vault.key(), pubkey)
.context("wrap org key to new member's key")?;
fs::write(vault.member_key_path(&new_id), &wrapped)
.context("write member key blob")?;
members.members.push(OrgMember {
member_id: new_id.clone(),
display_name: name.to_string(),
role,
ed25519_pubkey: pubkey.trim().to_string(),
collections: vec![],
added_at: now,
added_by: caller.member_id.clone(),
});
vault.save_members(&members)?;
let commit_msg = format!(
"org: add member \"{name}\"\n\nRelicario-Actor: {} {}\nRelicario-Action: member-add\nRelicario-Member: {}",
caller.display_name, caller.member_id.as_str(), new_id.as_str()
);
crate::org_session::org_git_run(
&vault.root,
&["add", "members.json", &format!("keys/{}.enc", new_id.as_str())],
"git add",
)?;
crate::org_session::org_git_run(&vault.root, &["commit", "-m", &commit_msg], "git commit")?;
println!("Added {} ({})", name, new_id.as_str());
Ok(())
}
role: OrgRoleis the existing parameter;OrgRolederivesCopy, somatches!(role, ...)does not move it.
- Step 3: Implement remove-member
pub fn run_remove_member(dir: &Path, member_id_prefix: &str) -> Result<()> {
let vault = crate::org_session::open_org_vault(Some(dir))?;
let caller = vault.current_member()?;
if !caller.role.can_manage_members() {
anyhow::bail!("only owners and admins can remove members");
}
let mut members = vault.load_members()?;
let target_id = resolve_member_id(&members, member_id_prefix)?;
let target = members.find_by_id(&target_id).unwrap();
if target.role == OrgRole::Owner && !caller.role.can_manage_owners() {
anyhow::bail!("only owners can remove other owners");
}
let target_name = target.display_name.clone();
// Delete key blob
let key_path = vault.member_key_path(&target_id);
if key_path.exists() { fs::remove_file(&key_path).context("delete key blob")?; }
members.members.retain(|m| m.member_id != target_id);
vault.save_members(&members)?;
let commit_msg = format!(
"org: remove member \"{target_name}\"\n\nRelicario-Actor: {} {}\nRelicario-Action: member-remove\nRelicario-Member: {}",
caller.display_name, caller.member_id.as_str(), target_id.as_str()
);
crate::org_session::org_git_run(
&vault.root,
&["add", "members.json", &format!("keys/{}.enc", target_id.as_str())],
"git add",
)?;
crate::org_session::org_git_run(&vault.root, &["commit", "-m", &commit_msg], "git commit")?;
eprintln!("⚠ Run `relicario org rotate-key --dir {}` to complete revocation.", vault.root.display());
println!("Removed {}", target_name);
Ok(())
}
- Step 4: Implement set-role
pub fn run_set_role(dir: &Path, member_id_prefix: &str, role: OrgRole) -> Result<()> {
let vault = crate::org_session::open_org_vault(Some(dir))?;
let caller = vault.current_member()?;
let mut members = vault.load_members()?;
let target_id = resolve_member_id(&members, member_id_prefix)?;
if matches!(role, OrgRole::Admin | OrgRole::Owner) && !caller.role.can_manage_owners() {
anyhow::bail!("only owners can promote to admin or owner");
}
if !caller.role.can_manage_members() {
anyhow::bail!("only owners and admins can change roles");
}
let target = members.find_by_id_mut(&target_id)
.ok_or_else(|| anyhow::anyhow!("member not found"))?;
let old_role = target.role;
target.role = role;
vault.save_members(&members)?;
let commit_msg = format!(
"org: set role {} → {:?}\n\nRelicario-Actor: {} {}\nRelicario-Action: member-role-change\nRelicario-Member: {}",
target_id.as_str(), role,
caller.display_name, caller.member_id.as_str(),
target_id.as_str()
);
crate::org_session::org_git_run(&vault.root, &["add", "members.json"], "git add")?;
crate::org_session::org_git_run(&vault.root, &["commit", "-m", &commit_msg], "git commit")?;
println!("Changed role {:?} → {:?}", old_role, role);
Ok(())
}
/// Resolve a member_id prefix (or full ID) to a MemberId.
fn resolve_member_id(members: &OrgMembers, prefix: &str) -> Result<MemberId> {
let hits: Vec<_> = members.members.iter()
.filter(|m| m.member_id.as_str().starts_with(prefix))
.collect();
match hits.len() {
0 => anyhow::bail!("no member matches `{prefix}`"),
1 => Ok(hits[0].member_id.clone()),
_ => anyhow::bail!("ambiguous prefix `{prefix}` — {} matches", hits.len()),
}
}
- Step 5: Compile + commit
cargo build -p relicario-cli 2>&1 | tail -10
git add crates/relicario-cli/src/commands/org.rs
git commit -m "feat(cli/org): add-member (owner-only escalation guard), remove-member, set-role"
[Dev-B] Task B6: org create-collection / grant / revoke
Files:
-
Modify:
crates/relicario-cli/src/commands/org.rs -
Step 1: Write failing tests
Add to the tests block in commands/org.rs:
#[test]
fn grant_adds_slug_to_member_collections() {
let mut members = OrgMembers::new();
let a = alice();
let id = a.member_id.clone();
members.members.push(a);
let m = members.find_by_id_mut(&id).unwrap();
if !m.collections.contains(&"prod".to_string()) {
m.collections.push("prod".to_string());
}
assert!(members.find_by_id(&id).unwrap().collections.contains(&"prod".to_string()));
}
#[test]
fn revoke_removes_slug_from_member_collections() {
let mut members = OrgMembers::new();
let mut a = alice();
a.collections = vec!["prod".into(), "dev".into()];
let id = a.member_id.clone();
members.members.push(a);
let m = members.find_by_id_mut(&id).unwrap();
m.collections.retain(|s| s != "prod");
assert!(!members.find_by_id(&id).unwrap().collections.contains(&"prod".to_string()));
assert!(members.find_by_id(&id).unwrap().collections.contains(&"dev".to_string()));
}
cargo test -p relicario-cli commands::org::tests 2>&1 | tail -5
Expected: all pass.
- Step 2: Implement create-collection
pub fn run_create_collection(dir: &Path, slug: &str, display_name: &str) -> Result<()> {
let vault = crate::org_session::open_org_vault(Some(dir))?;
let caller = vault.current_member()?;
if !caller.role.can_manage_members() {
anyhow::bail!("only owners and admins can create collections");
}
let mut collections = vault.load_collections()?;
if collections.contains_slug(slug) {
anyhow::bail!("collection `{slug}` already exists");
}
if slug.is_empty() || slug.contains('/') || slug.contains('.') {
anyhow::bail!("invalid slug `{slug}` — no slashes or dots, no empty string");
}
collections.collections.push(CollectionDef {
slug: slug.to_string(),
display_name: display_name.to_string(),
created_by: caller.member_id.clone(),
created_at: relicario_core::now_unix(),
});
vault.save_collections(&collections)?;
let commit_msg = format!(
"org: create collection \"{slug}\"\n\nRelicario-Actor: {} {}\nRelicario-Action: collection-create\nRelicario-Collection: {slug}",
caller.display_name, caller.member_id.as_str()
);
crate::org_session::org_git_run(&vault.root, &["add", "collections.json"], "git add")?;
crate::org_session::org_git_run(&vault.root, &["commit", "-m", &commit_msg], "git commit")?;
println!("Created collection `{slug}`");
Ok(())
}
- Step 3: Implement grant
pub fn run_grant(dir: &Path, member_id_prefix: &str, slug: &str) -> Result<()> {
let vault = crate::org_session::open_org_vault(Some(dir))?;
let caller = vault.current_member()?;
if !caller.role.can_manage_members() {
anyhow::bail!("only owners and admins can grant collection access");
}
let collections = vault.load_collections()?;
if !collections.contains_slug(slug) {
anyhow::bail!("collection `{slug}` does not exist — create it first");
}
let mut members = vault.load_members()?;
let target_id = resolve_member_id(&members, member_id_prefix)?;
let target = members.find_by_id_mut(&target_id).unwrap();
if target.collections.contains(&slug.to_string()) {
anyhow::bail!("member already has access to `{slug}`");
}
target.collections.push(slug.to_string());
vault.save_members(&members)?;
let commit_msg = format!(
"org: grant {slug} to {}\n\nRelicario-Actor: {} {}\nRelicario-Action: collection-grant\nRelicario-Collection: {slug}\nRelicario-Member: {}",
target_id.as_str(), caller.display_name, caller.member_id.as_str(), target_id.as_str()
);
crate::org_session::org_git_run(&vault.root, &["add", "members.json"], "git add")?;
crate::org_session::org_git_run(&vault.root, &["commit", "-m", &commit_msg], "git commit")?;
println!("Granted `{slug}` to {}", target_id.as_str());
Ok(())
}
- Step 4: Implement revoke
pub fn run_revoke(dir: &Path, member_id_prefix: &str, slug: &str) -> Result<()> {
let vault = crate::org_session::open_org_vault(Some(dir))?;
let caller = vault.current_member()?;
if !caller.role.can_manage_members() {
anyhow::bail!("only owners and admins can revoke collection access");
}
let mut members = vault.load_members()?;
let target_id = resolve_member_id(&members, member_id_prefix)?;
let target = members.find_by_id_mut(&target_id).unwrap();
if !target.collections.contains(&slug.to_string()) {
anyhow::bail!("member does not have access to `{slug}`");
}
target.collections.retain(|s| s != slug);
vault.save_members(&members)?;
let commit_msg = format!(
"org: revoke {slug} from {}\n\nRelicario-Actor: {} {}\nRelicario-Action: collection-revoke\nRelicario-Collection: {slug}\nRelicario-Member: {}",
target_id.as_str(), caller.display_name, caller.member_id.as_str(), target_id.as_str()
);
crate::org_session::org_git_run(&vault.root, &["add", "members.json"], "git add")?;
crate::org_session::org_git_run(&vault.root, &["commit", "-m", &commit_msg], "git commit")?;
println!("Revoked `{slug}` from {}", target_id.as_str());
Ok(())
}
- Step 5: Compile + commit
cargo build -p relicario-cli 2>&1 | tail -5
git add crates/relicario-cli/src/commands/org.rs
git commit -m "feat(cli/org): create-collection, grant, revoke commands"
[Dev-B] Task B7: org rotate-key (re-encrypt every item blob + race abort)
Files:
- Modify:
crates/relicario-cli/src/commands/org.rs
rotate-key generates a new org master key, re-wraps it for all current members, re-encrypts every item blob AND the manifest under the new key, and commits. Two corrections from the review: (H9) the old code re-encrypted only manifest.enc, leaving every items/<slug>/<id>.enc under the old key — a removed member with a clone could still decrypt all pre-rotation items; we now walk and re-encrypt every blob. (race) the old pull-failure handler swallowed all errors (including a genuine non-fast-forward / concurrent rotation) and proceeded; we now abort on a real conflict with the exact spec string while still distinguishing a missing remote.
- Step 1: Write failing test
Add to tests block:
#[test]
fn new_key_differs_from_old_key() {
let k1 = relicario_core::generate_org_key();
let k2 = relicario_core::generate_org_key();
assert_ne!(*k1, *k2);
}
cargo test -p relicario-cli commands::org::tests::new_key_differs 2>&1 | tail -5
Expected: PASS.
- Step 2: Implement rotate-key
pub fn run_rotate_key(dir: &Path) -> Result<()> {
// Pull latest state first to detect a concurrent rotation. We must
// distinguish three outcomes:
// * success -> proceed
// * no upstream / no remote -> local-only org, proceed
// * non-fast-forward / conflict -> concurrent rotation, ABORT
let pull = std::process::Command::new("git")
.current_dir(dir)
.args(["pull", "--rebase"])
.output()
.context("spawn git pull --rebase")?;
if !pull.status.success() {
let stderr = String::from_utf8_lossy(&pull.stderr);
let no_upstream = stderr.contains("no tracking information")
|| stderr.contains("There is no tracking information")
|| stderr.contains("does not appear to be a git repository")
|| stderr.contains("Could not read from remote repository")
|| stderr.contains("No remote repository specified");
if no_upstream {
eprintln!("Note: no upstream configured; proceeding with local state.");
} else {
// Best-effort: leave the working tree clean for the retry.
let _ = std::process::Command::new("git")
.current_dir(dir)
.args(["rebase", "--abort"])
.output();
anyhow::bail!(
"Concurrent key rotation detected — pull and re-run org rotate-key."
);
}
}
let vault = crate::org_session::open_org_vault(Some(dir))?;
let caller = vault.current_member()?;
if !caller.role.can_manage_owners() {
anyhow::bail!("only owners can rotate the org master key");
}
let members = vault.load_members()?;
let new_org_key = relicario_core::generate_org_key();
// Re-wrap the org key for every current member.
let mut staged_paths: Vec<String> = Vec::new();
for member in &members.members {
let wrapped = wrap_org_key(&new_org_key, &member.ed25519_pubkey)
.with_context(|| format!("wrap key for {}", member.display_name))?;
let key_path = vault.member_key_path(&member.member_id);
fs::write(&key_path, &wrapped)
.with_context(|| format!("write key for {}", member.display_name))?;
staged_paths.push(format!("keys/{}.enc", member.member_id.as_str()));
}
// Re-encrypt EVERY item blob under the new key. Items live collection-scoped
// at items/<collection-slug>/<id>.enc. Decrypt with the old key (held in the
// open vault session) and re-encrypt with the new one, in place. Without this
// a removed member who kept the old key + a clone could still decrypt every
// pre-rotation item.
let items_root = vault.root().join("items");
if items_root.is_dir() {
for slug_entry in fs::read_dir(&items_root).context("read items/")? {
let slug_entry = slug_entry.context("read items/ entry")?;
let slug_dir = slug_entry.path();
if !slug_dir.is_dir() {
continue;
}
let slug = slug_entry.file_name().to_string_lossy().to_string();
for item_entry in fs::read_dir(&slug_dir)
.with_context(|| format!("read items/{slug}/"))?
{
let item_entry = item_entry.context("read item entry")?;
let item_path = item_entry.path();
if item_path.extension().and_then(|e| e.to_str()) != Some("enc") {
continue;
}
let old_bytes = fs::read(&item_path)
.with_context(|| format!("read {}", item_path.display()))?;
let item = relicario_core::decrypt_item(&old_bytes, vault.key())
.with_context(|| format!("decrypt {}", item_path.display()))?;
let new_bytes = relicario_core::encrypt_item(&item, &new_org_key)
.with_context(|| format!("re-encrypt {}", item_path.display()))?;
crate::org_session::atomic_write(&item_path, &new_bytes)?;
let file_name = item_entry.file_name().to_string_lossy().to_string();
staged_paths.push(format!("items/{slug}/{file_name}"));
}
}
}
// Re-encrypt the manifest with the new key.
let manifest = vault.load_manifest()?;
let new_manifest_bytes = relicario_core::encrypt_org_manifest(&manifest, &new_org_key)?;
crate::org_session::atomic_write(&vault.manifest_path(), &new_manifest_bytes)?;
staged_paths.push("manifest.enc".to_string());
// Commit
let mut add_args = vec!["add"];
let path_refs: Vec<&str> = staged_paths.iter().map(|s| s.as_str()).collect();
add_args.extend_from_slice(&path_refs);
crate::org_session::org_git_run(&vault.root, &add_args, "git add")?;
let commit_msg = format!(
"org: rotate org master key\n\nRelicario-Actor: {} {}\nRelicario-Action: key-rotate",
caller.display_name, caller.member_id.as_str()
);
crate::org_session::org_git_run(&vault.root, &["commit", "-m", &commit_msg], "git commit")?;
println!(
"Key rotated. {} member key(s) re-wrapped; all item blobs + manifest re-encrypted.",
members.members.len()
);
Ok(())
}
Verified:
decrypt_item(&[u8], &Zeroizing<[u8;32]>) -> Result<Item>andencrypt_item(&Item, &Zeroizing<[u8;32]>) -> Result<Vec<u8>>(re-exported at core lib.rs:81–82).vault.key()returns the OLD key;vault.root()returns&PathBuf. Thegit addstep stagesstaged_paths, now including everyitems/<slug>/<file>.enc.
- Step 3: Build + commit
cargo build -p relicario-cli 2>&1 | tail -5
git add crates/relicario-cli/src/commands/org.rs
git commit -m "feat(cli/org): rotate-key — re-encrypt every item blob + abort on concurrent rotation"
[Dev-B] Task B8: org status + org audit (verified-signer attribution + TAMPERED flag)
Files:
- Modify:
crates/relicario-cli/src/commands/org.rs - Modify:
crates/relicario-cli/Cargo.toml(ensureregex+tempfilein[dependencies])
status prints members + collections with no decryption. audit parses git log, resolves each commit's verified signer to a member and reports that as the actor (trailers are advisory), flags trailer/signer mismatch as TAMPERED, and frames records with %x1e/%x1f (so multi-line trailer values cannot misalign records) using the committer date (%cI).
Dependency note:
run_audit/resolve_signerneedregexandtempfileavailable torelicario-cliat runtime (not just dev). Add tocrates/relicario-cli/Cargo.toml[dependencies]if absent:regex = "1"andtempfile = "3"(both already in the workspace lock via relicario-server).
- Step 1: Implement status
pub fn run_status(dir: &Path) -> Result<()> {
let root = crate::org_session::org_dir(Some(dir))?;
let meta: relicario_core::OrgMeta = {
let s = fs::read_to_string(root.join("org.json")).context("read org.json")?;
serde_json::from_str(&s)?
};
let members: OrgMembers = {
let s = fs::read_to_string(root.join("members.json")).context("read members.json")?;
serde_json::from_str(&s)?
};
let collections: OrgCollections = {
let s = fs::read_to_string(root.join("collections.json")).context("read collections.json")?;
serde_json::from_str(&s)?
};
println!("Org: {} ({})", meta.display_name, meta.org_id.as_str());
println!();
println!("Members ({}):", members.members.len());
for m in &members.members {
let colls = if m.collections.is_empty() {
"(no collections)".to_string()
} else {
m.collections.join(", ")
};
println!(" {:?} {} {} [{}]", m.role, m.member_id.as_str(), m.display_name, colls);
}
println!();
println!("Collections ({}):", collections.collections.len());
for c in &collections.collections {
println!(" {} — {}", c.slug, c.display_name);
}
Ok(())
}
- Step 2: Write failing test for audit trailer parsing
Add to tests block:
#[test]
fn parse_trailers_extracts_relicario_fields() {
// Contract trailer shape: "Relicario-Actor: <name> <member_id>".
let raw = "Relicario-Actor: alice a1b2c3d4e5f6a1b2\nRelicario-Action: item-create\nRelicario-Collection: prod\n";
let event = parse_trailer_block("abc123", "2026-06-06T12:00:00+00:00", raw);
assert_eq!(event.action.as_deref(), Some("item-create"));
assert_eq!(event.collection.as_deref(), Some("prod"));
// The verified actor_id is resolved later from the signature, not the trailer;
// the trailer only populates trailer_actor_id here.
assert_eq!(event.trailer_actor_id.as_deref(), Some("a1b2c3d4e5f6a1b2"));
assert_eq!(event.actor_id, None);
assert!(!event.tampered);
}
cargo test -p relicario-cli commands::org::tests::parse_trailers 2>&1 | tail -5
Expected: FAIL — parse_trailer_block not defined.
- Step 3: Implement audit (struct, trailer parser, signer resolver, run_audit)
#[derive(Debug, serde::Serialize)]
pub struct AuditEvent {
pub commit: String,
pub timestamp: String,
/// Actor as resolved from the VERIFIED signing key (authoritative).
pub actor_name: Option<String>,
pub actor_id: Option<String>,
/// Actor id as CLAIMED by the commit trailer (advisory; for tamper-checking).
pub trailer_actor_id: Option<String>,
pub action: Option<String>,
pub collection: Option<String>,
pub item_id: Option<String>,
pub device_id: Option<String>,
/// True when the trailer's claimed actor disagrees with the verified signer,
/// or when no current member matches the signing key.
pub tampered: bool,
}
fn parse_trailer_block(commit: &str, timestamp: &str, trailers: &str) -> AuditEvent {
let mut ev = AuditEvent {
commit: commit.to_string(),
timestamp: timestamp.to_string(),
actor_name: None,
actor_id: None,
trailer_actor_id: None,
action: None,
collection: None,
item_id: None,
device_id: None,
tampered: false,
};
for line in trailers.lines() {
let line = line.trim();
if let Some(rest) = line.strip_prefix("Relicario-Actor:") {
// Contract format: "<name> <member_id>" (member_id is the last token).
let rest = rest.trim();
if let Some((_name, id)) = rest.rsplit_once(' ') {
ev.trailer_actor_id = Some(id.trim().to_string());
} else if !rest.is_empty() {
ev.trailer_actor_id = Some(rest.to_string());
}
} else if let Some(v) = line.strip_prefix("Relicario-Action:") {
ev.action = Some(v.trim().to_string());
} else if let Some(v) = line.strip_prefix("Relicario-Collection:") {
ev.collection = Some(v.trim().to_string());
} else if let Some(v) = line.strip_prefix("Relicario-Item:") {
ev.item_id = Some(v.trim().to_string());
} else if let Some(v) = line.strip_prefix("Relicario-Device:") {
ev.device_id = Some(v.trim().to_string());
}
}
ev
}
/// Resolve a commit's SSH signature fingerprint to a current member, mirroring
/// the pre-receive hook: build an allowed_signers from members.json, inject it
/// via GIT_CONFIG_*, run `git verify-commit --raw`, parse the SHA256: key from
/// stderr. Returns None if the commit is unsigned or the signer is not a member.
fn resolve_signer<'m>(
root: &Path,
commit: &str,
members: &'m relicario_core::OrgMembers,
) -> Option<&'m relicario_core::OrgMember> {
use std::io::Write;
let mut tmp = tempfile::NamedTempFile::new().ok()?;
for m in &members.members {
let _ = writeln!(tmp, "relicario {}", m.ed25519_pubkey.trim());
}
let allowed_path = tmp.path();
let output = std::process::Command::new("git")
.current_dir(root)
.args(["verify-commit", "--raw", commit])
.env("GIT_CONFIG_COUNT", "1")
.env("GIT_CONFIG_KEY_0", "gpg.ssh.allowedSignersFile")
.env("GIT_CONFIG_VALUE_0", allowed_path.as_os_str())
.output()
.ok()?;
let stderr = String::from_utf8_lossy(&output.stderr);
let re = regex::Regex::new(r"key (SHA256:[A-Za-z0-9+/]+)").ok()?;
let fp = re.captures(&stderr)?.get(1)?.as_str().to_string();
members.members.iter().find(|m| {
relicario_core::fingerprint(&m.ed25519_pubkey).ok().as_deref() == Some(fp.as_str())
})
}
pub fn run_audit(
dir: &Path,
since: Option<&str>,
member_filter: Option<&str>,
collection_filter: Option<&str>,
action_filter: Option<&str>,
json: bool,
) -> Result<()> {
let root = crate::org_session::org_dir(Some(dir))?;
// members.json — needed to resolve each commit's verified signer to a member.
let members: relicario_core::OrgMembers = {
let s = fs::read_to_string(root.join("members.json")).context("read members.json")?;
serde_json::from_str(&s).context("parse members.json")?
};
// git log framed with a record separator (%x1e, U+001E) PER COMMIT and a
// field separator (%x1f, U+001F) between fields, so multi-line trailer
// values cannot misalign record boundaries. Committer date (%cI), not
// author date: it is what revocation/audit is anchored to.
let fmt = "%x1e%H%x1f%cI%x1f%(trailers:only=true,unfold=true)";
let mut args: Vec<String> = vec!["log".into(), format!("--format={fmt}")];
if let Some(s) = since {
args.push(format!("--since={s}"));
}
let output = std::process::Command::new("git")
.current_dir(&root)
.args(&args)
.output()
.context("git log")?;
let log = String::from_utf8_lossy(&output.stdout);
let mut events: Vec<AuditEvent> = Vec::new();
for record in log.split('\u{1e}') {
let record = record.trim_start_matches('\n');
if record.trim().is_empty() {
continue;
}
let mut fields = record.splitn(3, '\u{1f}');
let commit = fields.next().unwrap_or("").trim();
let ts = fields.next().unwrap_or("").trim();
let trailers = fields.next().unwrap_or("");
if commit.is_empty() {
continue;
}
let mut ev = parse_trailer_block(commit, ts, trailers);
if ev.action.is_none() {
continue; // not an org commit
}
// Resolve the VERIFIED signer and attribute it as the authoritative actor.
match resolve_signer(&root, commit, &members) {
Some(m) => {
ev.actor_name = Some(m.display_name.clone());
ev.actor_id = Some(m.member_id.as_str().to_string());
// Tampered if the trailer claims a different actor than the signer.
if let Some(claimed) = ev.trailer_actor_id.as_deref() {
if claimed != m.member_id.as_str() {
ev.tampered = true;
}
}
}
None => {
// No current member matched the signature -> cannot trust the
// trailer's claimed actor.
ev.tampered = true;
}
}
if let Some(mid) = member_filter {
// Filter on the VERIFIED actor id, not the spoofable trailer.
if ev.actor_id.as_deref() != Some(mid) {
continue;
}
}
if let Some(col) = collection_filter {
if ev.collection.as_deref() != Some(col) {
continue;
}
}
if let Some(act) = action_filter {
if ev.action.as_deref() != Some(act) {
continue;
}
}
events.push(ev);
}
if json {
println!("{}", serde_json::to_string_pretty(&events)?);
} else {
println!("{:<44} {:<26} {:<20} {:<18} {}", "COMMIT", "TIMESTAMP", "ACTION", "ACTOR", "FLAG");
for ev in &events {
println!("{:<44} {:<26} {:<20} {:<18} {}",
ev.commit,
ev.timestamp,
ev.action.as_deref().unwrap_or("-"),
ev.actor_name.as_deref().unwrap_or("<unverified>"),
if ev.tampered { "TAMPERED" } else { "" },
);
}
}
Ok(())
}
Verified: mirrors
relicario-server'sverify_commitexactly (temp allowed_signers prefixedrelicario,GIT_CONFIG_COUNT/KEY_0/VALUE_0 = gpg.ssh.allowedSignersFile,git verify-commit --raw, regexkey (SHA256:[A-Za-z0-9+/]+)over stderr, fingerprint match viarelicario_core::fingerprint). git 2.54.0 supports%(trailers:only=true,unfold=true)and%x1e/%x1f;%x1eprefixes each record sosplit('\u{1e}')yields a leading empty element (filtered by the empty-record guard).%cIemits strict ISO 8601 with offset.
- Step 4: Run the trailer test + build
cargo test -p relicario-cli commands::org::tests::parse_trailers 2>&1 | tail -5
cargo build -p relicario-cli 2>&1 | tail -5
Expected: PASS; clean build.
- Step 5: Commit
git add crates/relicario-cli/src/commands/org.rs crates/relicario-cli/Cargo.toml Cargo.lock
git commit -m "feat(cli/org): status + audit (verified-signer attribution, TAMPERED flag, committer-date framing)"
[Dev-B] Task B9: Confirm collection-scoped item helpers on UnlockedOrgVault
Files:
- Verify only:
crates/relicario-cli/src/org_session.rs
Reconciliation note: The collection-scoped
item_path(collection_slug, id)and the item I/O helpers (save_item,load_item,remove_item,ensure_grant) were already defined in Task B1 to avoid a duplicateitem_pathdefinition. This task is the checkpoint that those helpers exist with the exact signatures the item commands (B10–B13) consume. It adds no newitem_pathdefinition — there is exactly ONE, in B1.
- Step 1: Verify the helper surface exists
grep -n "pub fn item_path\|pub fn save_item\|pub fn load_item\|pub fn remove_item\|pub fn ensure_grant" crates/relicario-cli/src/org_session.rs
Expected: each appears exactly once. item_path(&self, collection_slug: &str, id: &ItemId), save_item(&self, collection_slug, &Item) -> Result<String>, load_item(&self, collection_slug, &ItemId) -> Result<Item>, remove_item(&self, collection_slug, &ItemId) -> Result<()>, ensure_grant(member: &OrgMember, slug: &str) -> Result<()>.
grep -rn "item_path" crates/relicario-cli/src/ | grep -v "collection_slug"
Expected: the only non-collection_slug matches are the personal-vault session.rs (a different type — leave it alone) and the B1 doc-comment lines. No flat items/<id>.enc call sites.
- Step 2: Compile check
cargo build -p relicario-cli 2>&1 | tail -15
Expected: clean build (unused-helper warnings are fine — B10–B13 consume them in the same PR series; do NOT add #[allow(dead_code)]).
No commit for this task — it is a verification gate. If a helper is missing, fix it in B1's file and amend B1's commit (or add a follow-up commit) before proceeding.
[Dev-B] Task B10: org add — create a typed item in a collection
Files:
- Modify:
crates/relicario-cli/src/commands/org.rs - Modify:
crates/relicario-cli/src/main.rs - Create:
crates/relicario-cli/tests/org_items.rs
run_add mirrors the personal-vault cmd_add: build a typed Item, write the encrypted blob (collection-scoped), upsert the manifest entry, re-encrypt the manifest, commit with structured trailers. The caller's grant is enforced and the collection must exist. Supports Login, SecureNote, and Identity (the three non-interactive builders); Card/Key/Document/Totp parity is deferred (see the follow-up note after B13).
- Step 1: Write the failing integration test
Create crates/relicario-cli/tests/org_items.rs. The harness seeds a device signing key under a tempdir XDG_CONFIG_HOME, runs org init to create the owner + wrap the org key, then drives org add / org get / org list.
mod common;
use assert_cmd::cargo::CommandCargoExt as _;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use tempfile::TempDir;
/// A throwaway org vault with a device signing key wired via XDG_CONFIG_HOME.
struct OrgFixture {
_config: TempDir,
vault: TempDir,
xdg: PathBuf,
}
impl OrgFixture {
/// Generate an ed25519 signing key in OpenSSH format using ssh-keygen and
/// register it as the current device, then `org init`.
fn new() -> Self {
let config = TempDir::new().unwrap();
let xdg = config.path().to_path_buf();
let devices = xdg.join("relicario").join("devices").join("laptop");
std::fs::create_dir_all(&devices).unwrap();
// Generate an OpenSSH ed25519 keypair without a passphrase.
let keyfile = devices.join("signing.key");
let status = Command::new("ssh-keygen")
.args(["-t", "ed25519", "-N", "", "-C", "relicario-test", "-f"])
.arg(&keyfile)
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.expect("ssh-keygen");
assert!(status.success(), "ssh-keygen failed");
// ssh-keygen writes signing.key + signing.key.pub; rename the .pub to signing.pub.
std::fs::rename(devices.join("signing.key.pub"), devices.join("signing.pub")).unwrap();
// Mark this device current.
std::fs::write(
xdg.join("relicario").join("devices").join("current"),
"laptop\n",
)
.unwrap();
let vault = TempDir::new().unwrap();
let f = OrgFixture { _config: config, vault, xdg };
let out = f.run(&["org", "init", "--dir", f.vault_str(), "--name", "Acme"]);
assert!(out.status.success(), "org init failed: {}", String::from_utf8_lossy(&out.stderr));
f
}
fn vault_path(&self) -> &Path { self.vault.path() }
fn vault_str(&self) -> &str { self.vault.path().to_str().unwrap() }
fn run(&self, args: &[&str]) -> std::process::Output {
let mut cmd = Command::cargo_bin("relicario").unwrap();
cmd.env("XDG_CONFIG_HOME", &self.xdg)
.env("RELICARIO_ORG_DIR", self.vault.path())
.args(args)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
cmd.output().unwrap()
}
/// Owner member id printed by `org init`/`org status`. We read it from
/// members.json directly to avoid parsing stdout.
fn owner_member_id(&self) -> String {
let s = std::fs::read_to_string(self.vault.path().join("members.json")).unwrap();
let v: serde_json::Value = serde_json::from_str(&s).unwrap();
v["members"][0]["member_id"].as_str().unwrap().to_string()
}
}
#[test]
fn org_add_get_list_round_trip() {
let f = OrgFixture::new();
let owner = f.owner_member_id();
// Create a collection and grant the owner access to it.
let out = f.run(&["org", "create-collection", "prod", "--name", "Production"]);
assert!(out.status.success(), "create-collection: {}", String::from_utf8_lossy(&out.stderr));
let out = f.run(&["org", "grant", &owner, "prod"]);
assert!(out.status.success(), "grant: {}", String::from_utf8_lossy(&out.stderr));
// Add a login into the prod collection.
let out = f.run(&[
"org", "add", "login", "--collection", "prod",
"--title", "GitHub", "--username", "alice",
"--url", "https://github.com", "--password", "hunter2",
]);
assert!(out.status.success(), "org add: {}", String::from_utf8_lossy(&out.stderr));
// The blob must live under items/prod/, NOT flat items/.
let prod_dir = f.vault_path().join("items").join("prod");
let blobs: Vec<_> = std::fs::read_dir(&prod_dir).unwrap().collect();
assert_eq!(blobs.len(), 1, "expected one blob under items/prod/");
// list shows it.
let out = f.run(&["org", "list"]);
let stdout = String::from_utf8_lossy(&out.stdout).to_string();
assert!(stdout.contains("GitHub"), "list missing GitHub: {stdout}");
// get masks by default.
let out = f.run(&["org", "get", "GitHub"]);
let stdout = String::from_utf8_lossy(&out.stdout).to_string();
assert!(stdout.contains("********"), "expected masked secret: {stdout}");
assert!(!stdout.contains("hunter2"), "leaked plaintext: {stdout}");
// get --show reveals.
let out = f.run(&["org", "get", "GitHub", "--show"]);
let stdout = String::from_utf8_lossy(&out.stdout).to_string();
assert!(stdout.contains("hunter2"), "expected plaintext with --show: {stdout}");
// The commit trailer records the action + collection + item.
let log = Command::new("git")
.args(["-C", f.vault_str(), "log", "-1", "--format=%B"])
.output()
.unwrap();
let body = String::from_utf8_lossy(&log.stdout).to_string();
assert!(body.contains("Relicario-Action: item-create"), "missing action trailer: {body}");
assert!(body.contains("Relicario-Collection: prod"), "missing collection trailer: {body}");
assert!(body.contains("Relicario-Item: "), "missing item trailer: {body}");
}
#[test]
fn org_add_rejects_ungranted_collection() {
let f = OrgFixture::new();
// Create the collection but do NOT grant the owner.
let out = f.run(&["org", "create-collection", "secret", "--name", "Secret"]);
assert!(out.status.success(), "create-collection: {}", String::from_utf8_lossy(&out.stderr));
let out = f.run(&[
"org", "add", "login", "--collection", "secret",
"--title", "X", "--username", "u", "--password", "p",
]);
assert!(!out.status.success(), "add into ungranted collection must fail");
let stderr = String::from_utf8_lossy(&out.stderr).to_string();
assert!(stderr.contains("access denied") || stderr.contains("grant"), "unexpected error: {stderr}");
}
#[test]
fn org_add_rejects_unknown_collection() {
let f = OrgFixture::new();
let out = f.run(&[
"org", "add", "login", "--collection", "ghost",
"--title", "X", "--username", "u", "--password", "p",
]);
assert!(!out.status.success(), "add into nonexistent collection must fail");
let stderr = String::from_utf8_lossy(&out.stderr).to_string();
assert!(stderr.contains("does not exist") || stderr.contains("ghost"), "unexpected error: {stderr}");
}
cargo test -p relicario-cli --test org_items 2>&1 | tail -20
Expected: FAIL to compile (org add subcommand + run_add not defined yet).
Note: This test requires
ssh-keygenonPATH.mod common;reusestests/common/mod.rsforCommand::cargo_bin-style ergonomics viaassert_cmd(already a dev-dependency viabasic_flows.rs).
- Step 2: Implement
run_addand the three item builders incommands/org.rs
Append after the existing command fns:
use relicario_core::{Item, ItemCore, ItemId};
/// Item kinds `org add` supports without interactive prompts.
pub enum OrgAddKind {
Login {
title: String,
username: Option<String>,
url: Option<String>,
password: Option<String>,
},
SecureNote {
title: String,
body: String,
},
Identity {
title: String,
full_name: Option<String>,
email: Option<String>,
phone: Option<String>,
},
}
fn build_org_item(kind: OrgAddKind, tags: Vec<String>) -> Result<Item> {
use relicario_core::item_types::{IdentityCore, LoginCore, SecureNoteCore};
use zeroize::Zeroizing;
let mut item = match kind {
OrgAddKind::Login { title, username, url, password } => {
let parsed_url = match url {
Some(s) => Some(url::Url::parse(&s).with_context(|| format!("invalid URL: {s}"))?),
None => None,
};
let password = password.map(Zeroizing::new);
Item::new(title, ItemCore::Login(LoginCore {
username,
password,
url: parsed_url,
totp: None,
}))
}
OrgAddKind::SecureNote { title, body } => {
Item::new(title, ItemCore::SecureNote(SecureNoteCore {
body: Zeroizing::new(body),
}))
}
OrgAddKind::Identity { title, full_name, email, phone } => {
Item::new(title, ItemCore::Identity(IdentityCore {
full_name,
address: None,
phone,
email,
date_of_birth: None,
}))
}
};
item.tags = tags;
Ok(item)
}
pub fn run_add(dir: &Path, collection: &str, kind: OrgAddKind, tags: Vec<String>) -> Result<()> {
use crate::org_session::UnlockedOrgVault;
let vault = crate::org_session::open_org_vault(Some(dir))?;
let caller = vault.current_member()?;
// Slug must exist in collections.json…
let collections = vault.load_collections()?;
if !collections.contains_slug(collection) {
anyhow::bail!("collection `{collection}` does not exist — create it with `relicario org create-collection`");
}
// …and the caller must hold a grant for it.
UnlockedOrgVault::ensure_grant(&caller, collection)?;
let item = build_org_item(kind, tags)?;
let item_rel = vault.save_item(collection, &item)?;
// Upsert the manifest entry (collection slug stored plaintext inside the
// encrypted manifest).
let mut manifest = vault.load_manifest()?;
upsert_org_entry(&mut manifest, &item, collection);
vault.save_manifest(&manifest)?;
let subject = format!(
"org add: {} ({})",
crate::helpers::sanitize_for_commit(&item.title),
item.id.as_str()
);
let commit_msg = format!(
"{subject}\n\nRelicario-Actor: {} {}\nRelicario-Action: item-create\nRelicario-Collection: {}\nRelicario-Item: {}",
caller.display_name,
caller.member_id.as_str(),
collection,
item.id.as_str()
);
crate::org_session::org_git_run(
&vault.root,
&["add", &item_rel, "manifest.enc"],
"org add: git add",
)?;
crate::org_session::org_git_run(&vault.root, &["commit", "-m", &commit_msg], "org add: git commit")?;
println!("Added {} ({}) to `{}`", item.title, item.id.as_str(), collection);
Ok(())
}
/// Insert-or-replace an `OrgManifestEntry` mirroring the personal-vault
/// `Manifest::upsert`. Keyed by item id.
fn upsert_org_entry(
manifest: &mut relicario_core::OrgManifest,
item: &Item,
collection: &str,
) {
let entry = relicario_core::OrgManifestEntry {
id: item.id.clone(),
r#type: item.r#type,
title: item.title.clone(),
tags: item.tags.clone(),
modified: item.modified,
trashed_at: item.trashed_at,
collection: collection.to_string(),
};
if let Some(slot) = manifest.entries.iter_mut().find(|e| e.id == item.id) {
*slot = entry;
} else {
manifest.entries.push(entry);
}
}
Note: Keep exactly one set of imports for
Item, ItemCore, ItemId. If an earlier task already imported them at module scope, drop theuse relicario_core::{Item, ItemCore, ItemId};line here. Do not importencrypt_org_manifest—vault.save_manifestalready wraps it.
- Step 3: Add the
Addvariant toOrgCommandsand dispatch (extends B14)
In crates/relicario-cli/src/main.rs, add to the OrgCommands enum:
/// Add an item to a collection in the org vault.
Add {
#[command(subcommand)]
kind: OrgAddKind,
},
Add a top-level clap subcommand enum next to OrgCommands:
#[derive(Subcommand)]
pub(crate) enum OrgAddKind {
/// A login (username / url / password).
Login {
#[arg(long)] collection: String,
#[arg(long)] title: String,
#[arg(long)] username: Option<String>,
#[arg(long)] url: Option<String>,
#[arg(long)] password: Option<String>,
#[arg(long, value_delimiter = ',')] tags: Vec<String>,
},
/// A secure note.
SecureNote {
#[arg(long)] collection: String,
#[arg(long)] title: String,
#[arg(long)] body: String,
#[arg(long, value_delimiter = ',')] tags: Vec<String>,
},
/// An identity record.
Identity {
#[arg(long)] collection: String,
#[arg(long)] title: String,
#[arg(long)] full_name: Option<String>,
#[arg(long)] email: Option<String>,
#[arg(long)] phone: Option<String>,
#[arg(long, value_delimiter = ',')] tags: Vec<String>,
},
}
In the Commands::Org { dir, subcommand } dispatch block, add an arm to the inner match subcommand:
OrgCommands::Add { kind } => {
let d = crate::org_session::org_dir(dir_path)?;
let (collection, add_kind, tags) = match kind {
OrgAddKind::Login { collection, title, username, url, password, tags } => (
collection,
commands::org::OrgAddKind::Login { title, username, url, password },
tags,
),
OrgAddKind::SecureNote { collection, title, body, tags } => (
collection,
commands::org::OrgAddKind::SecureNote { title, body },
tags,
),
OrgAddKind::Identity { collection, title, full_name, email, phone, tags } => (
collection,
commands::org::OrgAddKind::Identity { title, full_name, email, phone },
tags,
),
};
commands::org::run_add(&d, &collection, add_kind, tags)?;
}
Note: The clap-side
OrgAddKind(inmain.rs, withcollection/tagsper-variant) and the handler-sidecommands::org::OrgAddKind(nocollection/tags) are deliberately two distinct enums so the handler stays unaware of clap. Do not merge them.
- Step 4: Run the guard tests
cargo test -p relicario-cli --test org_items org_add_rejects 2>&1 | tail -20
Expected: both guard tests PASS. (The full org_add_get_list_round_trip passes once B11 lands get/list.)
- Step 5: Commit
git add crates/relicario-cli/src/commands/org.rs crates/relicario-cli/src/main.rs crates/relicario-cli/tests/org_items.rs
git commit -m "feat(cli/org): org add — collection-scoped typed item create with grant guard"
[Dev-B] Task B11: org get + org list
Files:
- Modify:
crates/relicario-cli/src/commands/org.rs - Modify:
crates/relicario-cli/src/main.rs
run_get mirrors commands/get.rs masking; run_list uses OrgManifest::filter_for_member so a member only ever sees entries in collections they hold a grant for. Both enforce that the caller has a grant for the target item's collection.
- Step 1: Implement
run_list
Append to commands/org.rs:
pub fn run_list(dir: &Path, trashed: bool) -> Result<()> {
let vault = crate::org_session::open_org_vault(Some(dir))?;
let caller = vault.current_member()?;
let manifest = vault.load_manifest()?;
// filter_for_member restricts to the caller's granted collections.
let visible = manifest.filter_for_member(&caller);
let mut entries: Vec<_> = visible.entries.iter()
.filter(|e| if trashed { e.trashed_at.is_some() } else { e.trashed_at.is_none() })
.collect();
entries.sort_by(|a, b| a.title.to_lowercase().cmp(&b.title.to_lowercase()));
if entries.is_empty() {
eprintln!("(no items match)");
return Ok(());
}
println!("{:<16} {:<14} {:<12} TITLE", "ID", "TYPE", "COLLECTION");
for e in entries {
println!(
"{:<16} {:<14} {:<12} {}",
e.id.as_str(),
format!("{:?}", e.r#type),
e.collection,
e.title
);
}
Ok(())
}
- Step 2: Implement
run_get(query resolution + masking)
Append to commands/org.rs. The query resolver runs over the caller-visible manifest only:
pub fn run_get(dir: &Path, query: &str, show: bool) -> Result<()> {
use relicario_core::ItemCore;
use zeroize::Zeroizing;
let vault = crate::org_session::open_org_vault(Some(dir))?;
let caller = vault.current_member()?;
let manifest = vault.load_manifest()?;
let visible = manifest.filter_for_member(&caller);
let entry = resolve_org_query(&visible, query)?;
// Double-check the grant for the resolved collection (defense in depth).
crate::org_session::UnlockedOrgVault::ensure_grant(&caller, &entry.collection)?;
let item = vault.load_item(&entry.collection, &entry.id)?;
println!("ID: {}", item.id.as_str());
println!("Title: {}", item.title);
println!("Type: {:?}", item.r#type);
println!("Collection: {}", entry.collection);
if !item.tags.is_empty() { println!("Tags: {}", item.tags.join(", ")); }
println!("Modified: {}", crate::helpers::iso8601(item.modified));
if let Some(t) = item.trashed_at { println!("Trashed: {}", crate::helpers::iso8601(t)); }
println!();
let primary_secret: Option<Zeroizing<String>> = match &item.core {
ItemCore::Login(l) => {
if let Some(u) = &l.username { println!("Username: {u}"); }
if let Some(u) = &l.url { println!("URL: {u}"); }
l.password.clone()
}
ItemCore::SecureNote(n) => {
if show { println!("Body:\n{}", n.body.as_str()); }
else { println!("Body: ********"); }
None
}
ItemCore::Identity(i) => {
if let Some(v) = &i.full_name { println!("Name: {v}"); }
if let Some(v) = &i.email { println!("Email: {v}"); }
if let Some(v) = &i.phone { println!("Phone: {v}"); }
None
}
ItemCore::Card(c) => {
if let Some(h) = &c.holder { println!("Holder: {h}"); }
c.number.clone()
}
ItemCore::Key(k) => {
if let Some(l) = &k.label { println!("Label: {l}"); }
Some(k.key_material.clone())
}
ItemCore::Document(d) => {
println!("Filename: {}", d.filename);
println!("MIME: {}", d.mime_type);
None
}
ItemCore::Totp(t) => {
if let Some(i) = &t.issuer { println!("Issuer: {i}"); }
if let Some(l) = &t.label { println!("Label: {l}"); }
None
}
};
if let Some(secret) = primary_secret {
if show {
println!("Secret: {}", secret.as_str());
} else {
println!("Secret: ******** (use --show to reveal)");
}
}
Ok(())
}
/// Resolve a query (exact id, else case-insensitive title substring) against an
/// already-grant-filtered manifest.
fn resolve_org_query<'a>(
manifest: &'a relicario_core::OrgManifest,
query: &str,
) -> Result<&'a relicario_core::OrgManifestEntry> {
if let Some(entry) = manifest.entries.iter().find(|e| e.id.as_str() == query) {
return Ok(entry);
}
let needle = query.to_lowercase();
let hits: Vec<&relicario_core::OrgManifestEntry> = manifest.entries.iter()
.filter(|e| e.title.to_lowercase().contains(&needle))
.collect();
match hits.len() {
0 => anyhow::bail!("no item matches `{query}`"),
1 => Ok(hits[0]),
_ => {
let titles: Vec<&str> = hits.iter().map(|e| e.title.as_str()).collect();
anyhow::bail!("ambiguous — {} matches: {}", hits.len(), titles.join(", "))
}
}
}
- Step 3: Add
Get/Listvariants + dispatch (extends B14)
In main.rs OrgCommands enum, add:
/// Print an org item (secrets masked unless --show).
Get {
/// Item id or case-insensitive title substring.
query: String,
#[arg(long)] show: bool,
},
/// List org items visible to you (filtered by your collection grants).
List {
#[arg(long)] trashed: bool,
},
In the match subcommand dispatch, add:
OrgCommands::Get { query, show } => {
let d = crate::org_session::org_dir(dir_path)?;
commands::org::run_get(&d, &query, show)?;
}
OrgCommands::List { trashed } => {
let d = crate::org_session::org_dir(dir_path)?;
commands::org::run_list(&d, trashed)?;
}
- Step 4: Run the full add/get/list integration test
cargo test -p relicario-cli --test org_items 2>&1 | tail -30
Expected: all three tests in org_items.rs now PASS.
- Step 5: Commit
git add crates/relicario-cli/src/commands/org.rs crates/relicario-cli/src/main.rs
git commit -m "feat(cli/org): org get + list with per-member grant filtering"
[Dev-B] Task B12: org edit
Files:
- Modify:
crates/relicario-cli/src/commands/org.rs - Modify:
crates/relicario-cli/src/main.rs
run_edit mirrors commands/edit.rs for the three supported types but takes new values from flags. It enforces the caller's grant, re-saves the blob in place under items/<slug>/, upserts the manifest, and commits with Relicario-Action: item-update.
- Step 1: Write a failing integration test (append to
tests/org_items.rs)
#[test]
fn org_edit_updates_fields_and_commits_update_trailer() {
let f = OrgFixture::new();
let owner = f.owner_member_id();
assert!(f.run(&["org", "create-collection", "prod", "--name", "Production"]).status.success());
assert!(f.run(&["org", "grant", &owner, "prod"]).status.success());
assert!(f.run(&[
"org", "add", "login", "--collection", "prod",
"--title", "Mail", "--username", "old", "--password", "pw",
]).status.success());
// Edit the username.
let out = f.run(&[
"org", "edit", "Mail", "--username", "new-user",
]);
assert!(out.status.success(), "org edit: {}", String::from_utf8_lossy(&out.stderr));
// get --show reflects the new username.
let out = f.run(&["org", "get", "Mail", "--show"]);
let stdout = String::from_utf8_lossy(&out.stdout).to_string();
assert!(stdout.contains("new-user"), "edit did not take: {stdout}");
let log = Command::new("git")
.args(["-C", f.vault_str(), "log", "-1", "--format=%B"])
.output().unwrap();
let body = String::from_utf8_lossy(&log.stdout).to_string();
assert!(body.contains("Relicario-Action: item-update"), "missing update trailer: {body}");
assert!(body.contains("Relicario-Collection: prod"), "missing collection trailer: {body}");
}
cargo test -p relicario-cli --test org_items org_edit 2>&1 | tail -20
Expected: FAIL — org edit not defined.
- Step 2: Implement
run_edit
Append to commands/org.rs:
pub fn run_edit(
dir: &Path,
query: &str,
title: Option<String>,
username: Option<String>,
url: Option<String>,
password: Option<String>,
body: Option<String>,
email: Option<String>,
phone: Option<String>,
full_name: Option<String>,
) -> Result<()> {
use relicario_core::time::now_unix;
use relicario_core::ItemCore;
use zeroize::Zeroizing;
let vault = crate::org_session::open_org_vault(Some(dir))?;
let caller = vault.current_member()?;
let manifest = vault.load_manifest()?;
let visible = manifest.filter_for_member(&caller);
let entry = resolve_org_query(&visible, query)?;
let collection = entry.collection.clone();
let id = entry.id.clone();
crate::org_session::UnlockedOrgVault::ensure_grant(&caller, &collection)?;
let mut item = vault.load_item(&collection, &id)?;
if let Some(t) = title { item.title = t; }
match &mut item.core {
ItemCore::Login(l) => {
if let Some(u) = username { l.username = Some(u); }
if let Some(u) = url {
l.url = Some(url::Url::parse(&u).with_context(|| format!("invalid URL: {u}"))?);
}
if let Some(p) = password { l.password = Some(Zeroizing::new(p)); }
}
ItemCore::SecureNote(n) => {
if let Some(b) = body { n.body = Zeroizing::new(b); }
}
ItemCore::Identity(i) => {
if let Some(v) = full_name { i.full_name = Some(v); }
if let Some(v) = email { i.email = Some(v); }
if let Some(v) = phone { i.phone = Some(v); }
}
_ => anyhow::bail!("org edit currently supports login, secure-note, and identity items"),
}
item.modified = now_unix();
let item_rel = vault.save_item(&collection, &item)?;
let mut manifest = vault.load_manifest()?;
upsert_org_entry(&mut manifest, &item, &collection);
vault.save_manifest(&manifest)?;
let subject = format!(
"org edit: {} ({})",
crate::helpers::sanitize_for_commit(&item.title),
item.id.as_str()
);
let commit_msg = format!(
"{subject}\n\nRelicario-Actor: {} {}\nRelicario-Action: item-update\nRelicario-Collection: {}\nRelicario-Item: {}",
caller.display_name, caller.member_id.as_str(), collection, item.id.as_str()
);
crate::org_session::org_git_run(&vault.root, &["add", &item_rel, "manifest.enc"], "org edit: git add")?;
crate::org_session::org_git_run(&vault.root, &["commit", "-m", &commit_msg], "org edit: git commit")?;
println!("Updated {}", item.id.as_str());
Ok(())
}
- Step 3: Add
Editvariant + dispatch (extends B14)
In main.rs OrgCommands:
/// Edit an org item's fields (flag-driven; blank flags keep current values).
Edit {
/// Item id or case-insensitive title substring.
query: String,
#[arg(long)] title: Option<String>,
#[arg(long)] username: Option<String>,
#[arg(long)] url: Option<String>,
#[arg(long)] password: Option<String>,
#[arg(long)] body: Option<String>,
#[arg(long)] email: Option<String>,
#[arg(long)] phone: Option<String>,
#[arg(long)] full_name: Option<String>,
},
Dispatch arm:
OrgCommands::Edit { query, title, username, url, password, body, email, phone, full_name } => {
let d = crate::org_session::org_dir(dir_path)?;
commands::org::run_edit(&d, &query, title, username, url, password, body, email, phone, full_name)?;
}
- Step 4: Run the test
cargo test -p relicario-cli --test org_items org_edit 2>&1 | tail -20
Expected: PASS.
- Step 5: Commit
git add crates/relicario-cli/src/commands/org.rs crates/relicario-cli/src/main.rs
git commit -m "feat(cli/org): org edit — flag-driven field update for login/note/identity"
[Dev-B] Task B13: org rm / org restore / org purge
Files:
- Modify:
crates/relicario-cli/src/commands/org.rs - Modify:
crates/relicario-cli/src/main.rs
Trash lifecycle mirrors commands/trash.rs: rm soft-deletes (sets trashed_at), restore clears it, purge permanently git rms the blob and drops the manifest entry. All three enforce the caller's grant.
- Step 1: Write a failing integration test (append to
tests/org_items.rs)
#[test]
fn org_rm_restore_purge_cycle() {
let f = OrgFixture::new();
let owner = f.owner_member_id();
assert!(f.run(&["org", "create-collection", "prod", "--name", "Production"]).status.success());
assert!(f.run(&["org", "grant", &owner, "prod"]).status.success());
assert!(f.run(&[
"org", "add", "secure-note", "--collection", "prod",
"--title", "Recovery", "--body", "codes-here",
]).status.success());
// rm → appears only with --trashed.
assert!(f.run(&["org", "rm", "Recovery"]).status.success());
let listed = String::from_utf8_lossy(&f.run(&["org", "list"]).stdout).to_string();
assert!(!listed.contains("Recovery"), "trashed item still in default list: {listed}");
let trashed = String::from_utf8_lossy(&f.run(&["org", "list", "--trashed"]).stdout).to_string();
assert!(trashed.contains("Recovery"), "trashed item not in --trashed list: {trashed}");
// restore → back in default list.
assert!(f.run(&["org", "restore", "Recovery"]).status.success());
let listed = String::from_utf8_lossy(&f.run(&["org", "list"]).stdout).to_string();
assert!(listed.contains("Recovery"), "restore did not bring it back: {listed}");
// purge → blob gone, entry gone, item-purge trailer.
assert!(f.run(&["org", "purge", "Recovery"]).status.success());
let prod_dir = f.vault_path().join("items").join("prod");
let count = std::fs::read_dir(&prod_dir).map(|d| d.count()).unwrap_or(0);
assert_eq!(count, 0, "blob not purged from items/prod/");
let listed = String::from_utf8_lossy(&f.run(&["org", "list", "--trashed"]).stdout).to_string();
assert!(!listed.contains("Recovery"), "purged item still listed: {listed}");
let log = Command::new("git")
.args(["-C", f.vault_str(), "log", "-1", "--format=%B"])
.output().unwrap();
let body = String::from_utf8_lossy(&log.stdout).to_string();
assert!(body.contains("Relicario-Action: item-purge"), "missing purge trailer: {body}");
}
cargo test -p relicario-cli --test org_items org_rm_restore_purge 2>&1 | tail -20
Expected: FAIL — org rm/restore/purge not defined.
- Step 2: Implement
run_rm,run_restore,run_purge
Append to commands/org.rs:
/// Resolve a query to (collection, item) with grant enforcement. Used by the
/// trash-lifecycle commands.
fn open_org_item(
vault: &crate::org_session::UnlockedOrgVault,
caller: &relicario_core::OrgMember,
query: &str,
) -> Result<(String, relicario_core::Item)> {
let manifest = vault.load_manifest()?;
let visible = manifest.filter_for_member(caller);
let entry = resolve_org_query(&visible, query)?;
let collection = entry.collection.clone();
let id = entry.id.clone();
crate::org_session::UnlockedOrgVault::ensure_grant(caller, &collection)?;
let item = vault.load_item(&collection, &id)?;
Ok((collection, item))
}
pub fn run_rm(dir: &Path, query: &str) -> Result<()> {
let vault = crate::org_session::open_org_vault(Some(dir))?;
let caller = vault.current_member()?;
let (collection, mut item) = open_org_item(&vault, &caller, query)?;
item.soft_delete();
let item_rel = vault.save_item(&collection, &item)?;
let mut manifest = vault.load_manifest()?;
upsert_org_entry(&mut manifest, &item, &collection);
vault.save_manifest(&manifest)?;
let commit_msg = format!(
"org trash: {} ({})\n\nRelicario-Actor: {} {}\nRelicario-Action: item-trash\nRelicario-Collection: {}\nRelicario-Item: {}",
crate::helpers::sanitize_for_commit(&item.title), item.id.as_str(),
caller.display_name, caller.member_id.as_str(), collection, item.id.as_str()
);
crate::org_session::org_git_run(&vault.root, &["add", &item_rel, "manifest.enc"], "org rm: git add")?;
crate::org_session::org_git_run(&vault.root, &["commit", "-m", &commit_msg], "org rm: git commit")?;
println!("Moved to trash: {}", item.title);
Ok(())
}
pub fn run_restore(dir: &Path, query: &str) -> Result<()> {
let vault = crate::org_session::open_org_vault(Some(dir))?;
let caller = vault.current_member()?;
let (collection, mut item) = open_org_item(&vault, &caller, query)?;
item.restore();
let item_rel = vault.save_item(&collection, &item)?;
let mut manifest = vault.load_manifest()?;
upsert_org_entry(&mut manifest, &item, &collection);
vault.save_manifest(&manifest)?;
let commit_msg = format!(
"org restore: {} ({})\n\nRelicario-Actor: {} {}\nRelicario-Action: item-restore\nRelicario-Collection: {}\nRelicario-Item: {}",
crate::helpers::sanitize_for_commit(&item.title), item.id.as_str(),
caller.display_name, caller.member_id.as_str(), collection, item.id.as_str()
);
crate::org_session::org_git_run(&vault.root, &["add", &item_rel, "manifest.enc"], "org restore: git add")?;
crate::org_session::org_git_run(&vault.root, &["commit", "-m", &commit_msg], "org restore: git commit")?;
println!("Restored: {}", item.title);
Ok(())
}
pub fn run_purge(dir: &Path, query: &str) -> Result<()> {
let vault = crate::org_session::open_org_vault(Some(dir))?;
let caller = vault.current_member()?;
let (collection, item) = open_org_item(&vault, &caller, query)?;
let title = item.title.clone();
let id = item.id.clone();
// Remove the blob from disk, drop the manifest entry, stage with git rm.
vault.remove_item(&collection, &id)?;
let mut manifest = vault.load_manifest()?;
manifest.entries.retain(|e| e.id != id);
vault.save_manifest(&manifest)?;
let item_rel = format!("items/{}/{}.enc", collection, id.as_str());
crate::helpers::git_rm(&vault.root, &[item_rel], "org purge: git rm")?;
crate::org_session::org_git_run(&vault.root, &["add", "manifest.enc"], "org purge: git add manifest")?;
let commit_msg = format!(
"org purge: {} ({})\n\nRelicario-Actor: {} {}\nRelicario-Action: item-purge\nRelicario-Collection: {}\nRelicario-Item: {}",
crate::helpers::sanitize_for_commit(&title), id.as_str(),
caller.display_name, caller.member_id.as_str(), collection, id.as_str()
);
crate::org_session::org_git_run(&vault.root, &["commit", "-m", &commit_msg], "org purge: git commit")?;
println!("Purged: {title}");
Ok(())
}
Note:
git_rmispubincrate::helpers(helpers.rs:93, signaturegit_rm(repo: &Path, paths: &[String], context: &str)) and usesgit rm -rf --ignore-unmatch, so a missing blob is tolerated. The grant guard runs before any deletion.
- Step 3: Add
Rm/Restore/Purgevariants + dispatch (extends B14)
In main.rs OrgCommands:
/// Soft-delete an org item (reversible via `org restore`).
Rm { query: String },
/// Restore a soft-deleted org item.
Restore { query: String },
/// Permanently purge an org item (deletes the encrypted blob).
Purge { query: String },
Dispatch arms:
OrgCommands::Rm { query } => {
let d = crate::org_session::org_dir(dir_path)?;
commands::org::run_rm(&d, &query)?;
}
OrgCommands::Restore { query } => {
let d = crate::org_session::org_dir(dir_path)?;
commands::org::run_restore(&d, &query)?;
}
OrgCommands::Purge { query } => {
let d = crate::org_session::org_dir(dir_path)?;
commands::org::run_purge(&d, &query)?;
}
- Step 4: Run the trash test + the full org_items suite + workspace verify
cargo test -p relicario-cli --test org_items 2>&1 | tail -30
cargo test 2>&1 | tail -25
Expected: all org_items.rs tests PASS; green across all crates. No todo!/unimplemented! left in the org item path.
- Step 5: Commit
git add crates/relicario-cli/src/commands/org.rs crates/relicario-cli/src/main.rs crates/relicario-cli/tests/org_items.rs
git commit -m "feat(cli/org): org rm/restore/purge trash lifecycle (collection-scoped)"
Follow-up note (not a task):
org add/org editcover Login, SecureNote, and Identity (the non-interactive builders). Card / Key / Document / Totp parity is deferred because those builders read secrets viarpassword/stdin; wiring them needs--*-stdinflags or a test escape hatch and belongs in a follow-up parity task. The CLI/extension-parity philosophy applies to the extension of this surface (Dev-D), not to per-type coverage within this CLI-only plan.
[Dev-B] Task B14: Wire Commands::Org into main.rs (admin + item subcommands + lifecycle stubs)
Files:
- Modify:
crates/relicario-cli/src/main.rs
This is the integration task that defines the Commands::Org arm, the OrgCommands enum, and the dispatch block. Depends on every run_* command fn existing (B4–B13). The item subcommand variants (Add/Get/List/Edit/Rm/Restore/Purge) and their dispatch arms are added by B10–B13 extending this enum/block; this task lands the admin variants plus the dispatch skeleton, and adds minimal stubs for transfer-ownership and delete-org (which the spec lists under Admin Operations).
- Step 1: Read the current Commands enum
grep -n "Subcommand\|enum Commands\|match cli.command" crates/relicario-cli/src/main.rs | head -40
- Step 2: Add the
Orgarm to theCommandsenum
/// Manage a multi-user org vault.
Org {
/// Path to the org vault directory (overrides RELICARIO_ORG_DIR).
#[arg(long, global = true)]
dir: Option<PathBuf>,
#[command(subcommand)]
subcommand: OrgCommands,
},
- Step 3: Add the
OrgCommandsenum (admin variants + lifecycle stubs)
The item variants (Add/Get/List/Edit/Rm/Restore/Purge) are added by B10–B13. The admin + lifecycle variants:
#[derive(Subcommand)]
enum OrgCommands {
/// Create a new org vault.
Init {
#[arg(long)]
name: String,
},
/// Add a member to the org.
AddMember {
/// OpenSSH ed25519 public key of the new member.
#[arg(long)]
key: String,
/// Display name.
#[arg(long)]
name: String,
/// Role: owner, admin, or member.
#[arg(long, default_value = "member")]
role: String,
},
/// Remove a member from the org.
RemoveMember {
/// Member ID prefix.
member_id: String,
},
/// Change a member's role.
SetRole {
member_id: String,
role: String,
},
/// Create a collection.
CreateCollection {
slug: String,
#[arg(long)]
name: String,
},
/// Grant a member access to a collection.
Grant {
member_id: String,
collection: String,
},
/// Revoke a member's access to a collection.
Revoke {
member_id: String,
collection: String,
},
/// Rotate the org master key (run after removing a member).
RotateKey,
/// Transfer ownership to another member (owner only).
TransferOwnership {
member_id: String,
},
/// Delete the org (owner only; requires --confirm).
DeleteOrg {
#[arg(long)]
confirm: bool,
},
/// Show org members and collections.
Status,
/// Query the org audit log.
Audit {
#[arg(long)]
since: Option<String>,
#[arg(long)]
member: Option<String>,
#[arg(long)]
collection: Option<String>,
#[arg(long)]
action: Option<String>,
#[arg(long)]
json: bool,
},
// Item subcommands (Add/Get/List/Edit/Rm/Restore/Purge) are added by
// Tasks B10–B13, which extend this enum.
}
- Step 4: Add the dispatch block +
parse_org_rolehelper
Commands::Org { dir, subcommand } => {
let dir_path = dir.as_deref();
match subcommand {
OrgCommands::Init { name } => {
let d = dir_path.ok_or_else(|| anyhow::anyhow!("--dir required for org init"))?;
commands::org::run_init(d, &name)?;
}
OrgCommands::AddMember { key, name, role } => {
let d = crate::org_session::org_dir(dir_path)?;
let role = parse_org_role(&role)?;
commands::org::run_add_member(&d, &key, &name, role)?;
}
OrgCommands::RemoveMember { member_id } => {
let d = crate::org_session::org_dir(dir_path)?;
commands::org::run_remove_member(&d, &member_id)?;
}
OrgCommands::SetRole { member_id, role } => {
let d = crate::org_session::org_dir(dir_path)?;
let role = parse_org_role(&role)?;
commands::org::run_set_role(&d, &member_id, role)?;
}
OrgCommands::CreateCollection { slug, name } => {
let d = crate::org_session::org_dir(dir_path)?;
commands::org::run_create_collection(&d, &slug, &name)?;
}
OrgCommands::Grant { member_id, collection } => {
let d = crate::org_session::org_dir(dir_path)?;
commands::org::run_grant(&d, &member_id, &collection)?;
}
OrgCommands::Revoke { member_id, collection } => {
let d = crate::org_session::org_dir(dir_path)?;
commands::org::run_revoke(&d, &member_id, &collection)?;
}
OrgCommands::RotateKey => {
let d = crate::org_session::org_dir(dir_path)?;
commands::org::run_rotate_key(&d)?;
}
OrgCommands::TransferOwnership { member_id } => {
let d = crate::org_session::org_dir(dir_path)?;
commands::org::run_transfer_ownership(&d, &member_id)?;
}
OrgCommands::DeleteOrg { confirm } => {
let d = crate::org_session::org_dir(dir_path)?;
commands::org::run_delete_org(&d, confirm)?;
}
OrgCommands::Status => {
let d = crate::org_session::org_dir(dir_path)?;
commands::org::run_status(&d)?;
}
OrgCommands::Audit { since, member, collection, action, json } => {
let d = crate::org_session::org_dir(dir_path)?;
commands::org::run_audit(&d, since.as_deref(), member.as_deref(),
collection.as_deref(), action.as_deref(), json)?;
}
// Item dispatch arms (Add/Get/List/Edit/Rm/Restore/Purge) added by
// Tasks B10–B13.
}
}
fn parse_org_role(s: &str) -> anyhow::Result<relicario_core::OrgRole> {
match s {
"owner" => Ok(relicario_core::OrgRole::Owner),
"admin" => Ok(relicario_core::OrgRole::Admin),
"member" => Ok(relicario_core::OrgRole::Member),
other => anyhow::bail!("unknown role `{other}` — use owner, admin, or member"),
}
}
- Step 5: Implement
run_transfer_ownershipandrun_delete_orgincommands/org.rs
The spec lists both under Admin Operations (owner-only). transfer-ownership promotes the target to Owner (the caller may optionally self-demote to Admin); delete-org requires --confirm and removes the working tree's org files. Minimal correct implementations:
pub fn run_transfer_ownership(dir: &Path, member_id_prefix: &str) -> Result<()> {
let vault = crate::org_session::open_org_vault(Some(dir))?;
let caller = vault.current_member()?;
if !caller.role.can_manage_owners() {
anyhow::bail!("only an owner can transfer ownership");
}
let mut members = vault.load_members()?;
let target_id = resolve_member_id(&members, member_id_prefix)?;
if target_id == caller.member_id {
anyhow::bail!("you are already the owner");
}
{
let target = members.find_by_id_mut(&target_id)
.ok_or_else(|| anyhow::anyhow!("member not found"))?;
target.role = OrgRole::Owner;
}
vault.save_members(&members)?;
let commit_msg = format!(
"org: transfer ownership to {}\n\nRelicario-Actor: {} {}\nRelicario-Action: ownership-transfer\nRelicario-Member: {}",
target_id.as_str(), caller.display_name, caller.member_id.as_str(), target_id.as_str()
);
crate::org_session::org_git_run(&vault.root, &["add", "members.json"], "git add")?;
crate::org_session::org_git_run(&vault.root, &["commit", "-m", &commit_msg], "git commit")?;
println!("Ownership transferred to {}", target_id.as_str());
Ok(())
}
pub fn run_delete_org(dir: &Path, confirm: bool) -> Result<()> {
let vault = crate::org_session::open_org_vault(Some(dir))?;
let caller = vault.current_member()?;
if !caller.role.can_manage_owners() {
anyhow::bail!("only an owner can delete the org");
}
if !confirm {
anyhow::bail!("refusing to delete org without --confirm");
}
let commit_msg = format!(
"org: delete org\n\nRelicario-Actor: {} {}\nRelicario-Action: org-delete",
caller.display_name, caller.member_id.as_str()
);
// Remove org files (the git history is retained as the audit record).
for f in ["org.json", "members.json", "collections.json", "manifest.enc"] {
let _ = fs::remove_file(vault.root.join(f));
}
let _ = std::fs::remove_dir_all(vault.root.join("items"));
let _ = std::fs::remove_dir_all(vault.root.join("keys"));
crate::org_session::org_git_run(&vault.root, &["add", "-A"], "git add")?;
crate::org_session::org_git_run(&vault.root, &["commit", "-m", &commit_msg], "git commit")?;
println!("Org deleted (git history retained as audit record).");
Ok(())
}
Tracked gap:
delete-org's tombstone-commit semantics (whether the hook should allow a protected-file deletion by an owner) is a design corner the pre-receive hook (C1) currently REJECTs (enforce_schema_monotonicityforbids deleting protected JSON). This is an acknowledged interaction: for phase 1,delete-orgis a local operation; pushing a delete to a hook-protected remote is out of scope and tracked as a follow-up.transfer-ownershipis fully hook-compatible (it only mutatesmembers.jsonroles, signed by the owner).
- Step 6: Build and test help output
cargo build -p relicario-cli 2>&1 | tail -10
./target/debug/relicario org --help
./target/debug/relicario org init --help
Expected: clean build; help text for all org subcommands (admin + item).
- Step 7: Commit
git add crates/relicario-cli/src/main.rs crates/relicario-cli/src/commands/org.rs
git commit -m "feat(cli): wire Commands::Org (admin + item subcommands) + transfer-ownership/delete-org"
Dev-C — relicario-server pre-receive hook
Stream prerequisite: Tasks A2–A3 must be merged before Dev-C begins (the hook imports
relicario_core::org::{OrgMember, OrgMembers, OrgCollections}). No new Cargo deps beyond ensuringtempfileis a runtime dependency.Dependency on the corrected item layout:
classify_pathassumesitems/<slug>/<id>.enc. It must matchUnlockedOrgVault::item_path(collection_slug, id)from Task B1. If item commits ever used a flatitems/<id>.enc, every item commit would beRejected.
[Dev-C] Task C1: verify-org-commit — signature + path-scoped authorization
Files:
- Modify:
crates/relicario-server/Cargo.toml - Modify:
crates/relicario-server/src/main.rs - Create:
crates/relicario-server/src/lib.rs - Create:
crates/relicario-server/tests/org_hook.rs
The org pre-receive path mirrors the existing verify_commit (main.rs:37–153): build a temp allowed_signers from member ed25519 pubkeys, inject gpg.ssh.allowedSignersFile via GIT_CONFIG_*, run git verify-commit --raw, and parse the key (SHA256:...) fingerprint from stderr. We do not use %GF (it is empty without an allowed-signers file configured server-side). The verified signer's fingerprint maps to a current member via relicario_core::fingerprint; that member is the authority for every authorization check (trailers are advisory and ignored here).
Authorization rules enforced per commit:
- Every commit must carry a GOOD signature whose fingerprint maps to a current member. Unsigned / unknown-signer → REJECT.
- Protected paths (
members.json,collections.json,org.json): signer must haverole.can_manage_members()(Owner or Admin). - Item writes
items/<slug>/<id>.enc: the leading path segment<slug>must appear in the signing member'scollectionsgrant list. (Owners/Admins are not auto-granted — grants are explicit.) - Schema-version monotonicity for the three JSON files (Task C2).
- Root commit (no parent) → inspect full tree, allow as genesis. Merge commit (>1 parent) → REJECT.
- Step 1: Ensure
tempfileis a runtime dependency
Confirm tempfile is under [dependencies] (not only [dev-dependencies]) in crates/relicario-server/Cargo.toml. If the [dependencies] table lacks it, add:
tempfile = "3"
cargo build -p relicario-server 2>&1 | tail -5
Expected: clean build (the existing verify_commit already references tempfile, so it is almost certainly already present; this is a guard).
- Step 2: Write failing unit tests for path-segment parsing
Create crates/relicario-server/tests/org_hook.rs:
// Integration tests for relicario-server org-hook path classification.
use relicario_server::{classify_path, PathClass};
#[test]
fn protected_files_are_classified_protected() {
assert_eq!(classify_path("members.json"), PathClass::Protected);
assert_eq!(classify_path("collections.json"), PathClass::Protected);
assert_eq!(classify_path("org.json"), PathClass::Protected);
}
#[test]
fn item_write_yields_collection_slug() {
assert_eq!(
classify_path("items/prod/a1b2c3d4e5f6a1b2.enc"),
PathClass::Item { collection: "prod".to_string() }
);
}
#[test]
fn item_write_nested_slug_takes_leading_segment_only() {
// Slugs cannot contain '/', so a 4-segment path is malformed → Rejected.
assert_eq!(
classify_path("items/prod/sub/x.enc"),
PathClass::Rejected("items path must be items/<slug>/<id>.enc".to_string())
);
}
#[test]
fn key_blobs_and_manifest_are_unrestricted() {
// keys/<id>.enc and manifest.enc are written by org operations; the SIGNATURE
// check (every commit must be signed by a current member) is the gate for them.
assert_eq!(classify_path("keys/a1b2c3d4e5f6a1b2.enc"), PathClass::Unrestricted);
assert_eq!(classify_path("manifest.enc"), PathClass::Unrestricted);
}
#[test]
fn items_without_slug_segment_are_rejected() {
// Flat items/<id>.enc (the OLD, now-removed layout) is no longer valid.
assert_eq!(
classify_path("items/a1b2c3d4e5f6a1b2.enc"),
PathClass::Rejected("items path must be items/<slug>/<id>.enc".to_string())
);
}
#[test]
fn empty_slug_segment_is_rejected() {
assert_eq!(
classify_path("items//x.enc"),
PathClass::Rejected("empty collection slug in items path".to_string())
);
}
cargo test -p relicario-server --test org_hook 2>&1 | tail -20
Expected: FAIL to compile — classify_path / PathClass not defined and no library target yet.
- Step 3: Add a library target with the pure helpers
Create crates/relicario-server/src/lib.rs:
//! Library surface for relicario-server, exposing pure helpers used by the
//! pre-receive hooks so they can be unit-tested.
/// Classification of a single changed path inside an org repo.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PathClass {
/// `members.json`, `collections.json`, `org.json` — only Owner/Admin may write.
Protected,
/// `items/<slug>/<id>.enc` — writer must hold a grant for `<slug>`.
Item { collection: String },
/// `keys/<id>.enc`, `manifest.enc`, `.gitignore`, etc. — gated only by the
/// per-commit signature check (signer must be a current member).
Unrestricted,
/// Structurally invalid path; commit must be rejected.
Rejected(String),
}
/// Classify a repo-relative path. Pure; no I/O.
pub fn classify_path(path: &str) -> PathClass {
match path {
"members.json" | "collections.json" | "org.json" => return PathClass::Protected,
_ => {}
}
if let Some(rest) = path.strip_prefix("items/") {
// Expect exactly: <slug>/<id>.enc → two segments after the prefix.
let segments: Vec<&str> = rest.split('/').collect();
if segments.len() != 2 {
return PathClass::Rejected("items path must be items/<slug>/<id>.enc".to_string());
}
let slug = segments[0];
if slug.is_empty() {
return PathClass::Rejected("empty collection slug in items path".to_string());
}
return PathClass::Item { collection: slug.to_string() };
}
PathClass::Unrestricted
}
Wire the lib into Cargo.toml. In crates/relicario-server/Cargo.toml, add an explicit [lib] + [[bin]] pair:
[lib]
name = "relicario_server"
path = "src/lib.rs"
[[bin]]
name = "relicario-server"
path = "src/main.rs"
In crates/relicario-server/src/main.rs, import the helpers (after the existing use block):
use relicario_server::{classify_path, PathClass};
- Step 4: Run the path-classification tests
cargo test -p relicario-server --test org_hook 2>&1 | tail -20
Expected: all six org_hook tests pass.
- Step 5: Add the
VerifyOrgCommit/GenerateOrgHooksubcommands
In crates/relicario-server/src/main.rs, add to the Commands enum (after GenerateHook):
/// Verify a commit to an org vault: signature + role/path authorization.
VerifyOrgCommit {
/// The commit SHA to verify.
commit: String,
},
/// Generate an org pre-receive hook script.
GenerateOrgHook,
Add to the match cli.command block in main():
Commands::VerifyOrgCommit { commit } => verify_org_commit(&commit),
Commands::GenerateOrgHook => generate_org_hook(),
- Step 6: Implement the signer-resolution helper (mirrors
verify_commit)
use relicario_core::org::{OrgMember, OrgMembers};
/// Verify the SSH signature on `commit` against the given org members and return
/// the matching member. On any failure (unsigned, malformed, or unknown signer)
/// this prints REJECT and calls `std::process::exit(1)`; it only returns on success.
fn verify_org_signer(commit: &str, members: &OrgMembers) -> OrgMember {
// Build a temp allowed-signers file from every current member's pubkey.
let tmp = match tempfile::tempdir() {
Ok(t) => t,
Err(e) => {
eprintln!("REJECT: org commit {commit} — cannot create tempdir: {e}");
std::process::exit(1);
}
};
let allowed_path = tmp.path().join("allowed_signers");
let mut allowed_body = String::new();
for m in &members.members {
allowed_body.push_str("relicario ");
allowed_body.push_str(m.ed25519_pubkey.trim());
allowed_body.push('\n');
}
if let Err(e) = fs::write(&allowed_path, &allowed_body) {
eprintln!("REJECT: org commit {commit} — cannot write allowed_signers: {e}");
std::process::exit(1);
}
// Run git verify-commit --raw with the allowed-signers file injected.
let output = match Command::new("git")
.args(["verify-commit", "--raw", commit])
.env("GIT_CONFIG_COUNT", "1")
.env("GIT_CONFIG_KEY_0", "gpg.ssh.allowedSignersFile")
.env("GIT_CONFIG_VALUE_0", allowed_path.as_os_str())
.output()
{
Ok(o) => o,
Err(e) => {
eprintln!("REJECT: org commit {commit} — git verify-commit failed to run: {e}");
std::process::exit(1);
}
};
let stderr = String::from_utf8_lossy(&output.stderr);
// Parse the SHA-256 fingerprint from stderr (same regex as verify_commit).
let re = regex::Regex::new(r"key (SHA256:[A-Za-z0-9+/]+)").expect("static regex");
let signing_fp = match re.captures(&stderr).and_then(|c| c.get(1)) {
Some(m) => m.as_str().to_string(),
None => {
eprintln!(
"REJECT: org commit {commit} — no valid signature found (stderr: {})",
stderr.trim()
);
std::process::exit(1);
}
};
// Map fingerprint → member via relicario_core::fingerprint over each pubkey.
for m in &members.members {
if let Ok(fp) = relicario_core::fingerprint(&m.ed25519_pubkey) {
if fp == signing_fp {
return m.clone();
}
}
}
eprintln!(
"REJECT: org commit {commit} — signer (fingerprint {signing_fp}) is not a current org member"
);
std::process::exit(1);
}
- Step 7: Implement
verify_org_commit(parent/merge handling + per-path authorization)
enforce_schema_monotonicityis defined in Task C2. The two tasks land in the same file and share one commit boundary at the end of C2 — do not run the full build until C2 has added that function.
fn verify_org_commit(commit: &str) -> Result<()> {
// Determine parent count from %P (space-separated parent SHAs; empty = root).
let parents_out = Command::new("git")
.args(["show", "-s", "--format=%P", commit])
.output()
.context("git show parents")?;
let parents_line = String::from_utf8_lossy(&parents_out.stdout);
let parents: Vec<&str> = parents_line.split_whitespace().collect();
// Merge commits are rejected. Org repos are linear (CLI uses pull --rebase).
if parents.len() > 1 {
eprintln!(
"REJECT: org commit {commit} — merge commits are not allowed in org vaults \
({} parents); rebase instead",
parents.len()
);
std::process::exit(1);
}
let is_root = parents.is_empty();
// Load members.json AS OF THIS COMMIT so the genesis commit can authorize itself.
let members_json = match git_show(commit, "members.json") {
Ok(s) => s,
Err(_) => {
if is_root {
eprintln!("OK: org commit {commit} (root bootstrap - no members.json yet)");
return Ok(());
}
eprintln!("REJECT: org commit {commit} — members.json missing from non-root commit");
std::process::exit(1);
}
};
let members: OrgMembers =
serde_json::from_str(&members_json).context("parse members.json")?;
if members.members.is_empty() {
if is_root {
eprintln!("OK: org commit {commit} (root bootstrap - empty member list)");
return Ok(());
}
eprintln!("REJECT: org commit {commit} — members.json has no members");
std::process::exit(1);
}
members
.validate()
.map_err(|e| anyhow::anyhow!("members.json invalid: {e}"))?;
// Verify the signature and resolve the signing member (exits on failure).
let signer = verify_org_signer(commit, &members);
// Enumerate changed paths. Root has no parent to diff, so use ls-tree.
let changed_paths: Vec<String> = if is_root {
let out = Command::new("git")
.args(["ls-tree", "-r", "--name-only", commit])
.output()
.context("git ls-tree")?;
String::from_utf8_lossy(&out.stdout)
.lines()
.map(|l| l.trim().to_string())
.filter(|l| !l.is_empty())
.collect()
} else {
let out = Command::new("git")
.args(["diff-tree", "--no-commit-id", "-r", "--name-only", commit])
.output()
.context("git diff-tree")?;
String::from_utf8_lossy(&out.stdout)
.lines()
.map(|l| l.trim().to_string())
.filter(|l| !l.is_empty())
.collect()
};
// Authorize each changed path against the signing member's role/grants.
for path in &changed_paths {
match classify_path(path) {
PathClass::Rejected(why) => {
eprintln!("REJECT: org commit {commit} — invalid path `{path}`: {why}");
std::process::exit(1);
}
PathClass::Protected => {
if !signer.role.can_manage_members() {
eprintln!(
"REJECT: org commit {commit} — member '{}' (role {:?}) may not write protected file `{path}`",
signer.display_name, signer.role
);
std::process::exit(1);
}
}
PathClass::Item { collection } => {
if !signer.collections.iter().any(|c| c == &collection) {
eprintln!(
"REJECT: org commit {commit} — member '{}' lacks a grant for collection `{collection}` (path `{path}`)",
signer.display_name
);
std::process::exit(1);
}
}
PathClass::Unrestricted => {
// keys/<id>.enc, manifest.enc, etc. — signature check already passed.
}
}
}
// Schema-version monotonicity for the three JSON files (Task C2).
enforce_schema_monotonicity(commit, is_root, &changed_paths)?;
eprintln!(
"OK: org commit {commit} verified — signed by '{}' ({:?}), {} path(s) authorized",
signer.display_name,
signer.role,
changed_paths.len()
);
Ok(())
}
- Step 8: Implement
generate_org_hook
fn generate_org_hook() -> Result<()> {
print!(
r#"#!/bin/bash
# Relicario org pre-receive hook -- verify signatures + role/path authorization
while read oldrev newrev refname; do
[ "$newrev" = "0000000000000000000000000000000000000000" ] && continue
if [ "$oldrev" = "0000000000000000000000000000000000000000" ]; then
commits=$(git rev-list "$newrev")
else
commits=$(git rev-list "$oldrev..$newrev")
fi
for commit in $commits; do
relicario-server verify-org-commit "$commit" || exit 1
done
done
"#
);
Ok(())
}
- Step 9: Build (depends on Task C2's
enforce_schema_monotonicity)
Do not run the full build until Task C2 has added
enforce_schema_monotonicityto the same file. Proceed to C2, then return.
cargo build -p relicario-server 2>&1 | tail -15
- Step 10: Commit (combined with Task C2 — see C2 Step 4)
This task does not commit on its own; the signature/auth code and the schema-monotonicity code land together. Proceed to Task C2.
[Dev-C] Task C2: Schema-version monotonicity + JSON validation
Files:
- Modify:
crates/relicario-server/src/main.rs - Modify:
crates/relicario-server/src/lib.rs - Modify:
crates/relicario-server/tests/org_hook.rs
Every push that touches members.json, collections.json, or org.json must not decrease its schema_version (compared against {commit}^:file). On the root commit any starting version is accepted. We also re-validate collections.json here (members is validated in C1).
- Step 1: Write failing test for the version-extraction helper
Append to crates/relicario-server/tests/org_hook.rs:
use relicario_server::extract_schema_version;
#[test]
fn extract_schema_version_reads_field() {
let json = r#"{ "schema_version": 3, "members": [] }"#;
assert_eq!(extract_schema_version(json).unwrap(), 3);
}
#[test]
fn extract_schema_version_errors_on_missing_field() {
let json = r#"{ "members": [] }"#;
assert!(extract_schema_version(json).is_err());
}
#[test]
fn extract_schema_version_errors_on_garbage() {
assert!(extract_schema_version("not json").is_err());
}
cargo test -p relicario-server --test org_hook 2>&1 | tail -10
Expected: FAIL — extract_schema_version not defined.
- Step 2: Implement
extract_schema_versionin the lib
Add to crates/relicario-server/src/lib.rs:
/// Extract the `schema_version` field from any org JSON document.
/// Returns an error if the field is absent or not a u32.
pub fn extract_schema_version(json: &str) -> Result<u32, String> {
let value: serde_json::Value =
serde_json::from_str(json).map_err(|e| format!("parse json: {e}"))?;
value
.get("schema_version")
.and_then(|v| v.as_u64())
.map(|n| n as u32)
.ok_or_else(|| "missing or non-integer schema_version".to_string())
}
serde_json is already in [dependencies], so the lib target picks it up — no Cargo.toml change.
cargo test -p relicario-server --test org_hook extract_schema_version 2>&1 | tail -10
Expected: the three extract_schema_version tests pass.
- Step 3: Implement
enforce_schema_monotonicityin main.rs
use relicario_server::extract_schema_version;
/// For each protected JSON file changed in this commit, ensure schema_version did
/// not decrease vs the parent commit, and re-validate collections.json structure.
fn enforce_schema_monotonicity(
commit: &str,
is_root: bool,
changed_paths: &[String],
) -> Result<()> {
const VERSIONED: [&str; 3] = ["members.json", "collections.json", "org.json"];
for file in VERSIONED {
if !changed_paths.iter().any(|p| p == file) {
continue;
}
// A deletion of a protected file is not allowed.
let new_content = match git_show(commit, file) {
Ok(s) => s,
Err(_) => {
eprintln!(
"REJECT: org commit {commit} — protected file `{file}` was deleted; \
org vaults never delete {file}"
);
std::process::exit(1);
}
};
let new_version = match extract_schema_version(&new_content) {
Ok(v) => v,
Err(e) => {
eprintln!("REJECT: org commit {commit} — `{file}` invalid: {e}");
std::process::exit(1);
}
};
// collections.json structural validation.
if file == "collections.json" {
match serde_json::from_str::<relicario_core::org::OrgCollections>(&new_content) {
Ok(c) => {
if let Err(e) = c.validate() {
eprintln!("REJECT: org commit {commit} — collections.json invalid: {e}");
std::process::exit(1);
}
}
Err(e) => {
eprintln!("REJECT: org commit {commit} — collections.json parse error: {e}");
std::process::exit(1);
}
}
}
// On the root commit there is no parent baseline; any starting version is fine.
if is_root {
continue;
}
// Parent version: if the file did not exist in the parent (newly added),
// there is no prior version to regress against — accept.
if let Ok(old_content) = git_show_parent(commit, file) {
let old_version = match extract_schema_version(&old_content) {
Ok(v) => v,
Err(_) => {
continue;
}
};
if new_version < old_version {
eprintln!(
"REJECT: org commit {commit} — `{file}` schema_version decreased \
({old_version} -> {new_version})"
);
std::process::exit(1);
}
}
}
Ok(())
}
/// Read a file from a commit's FIRST PARENT tree: `git show {commit}^:{path}`.
fn git_show_parent(commit: &str, path: &str) -> Result<String> {
let output = Command::new("git")
.args(["show", &format!("{}^:{}", commit, path)])
.output()
.context("git show parent")?;
if !output.status.success() {
anyhow::bail!("git show {}^:{} failed", commit, path);
}
Ok(String::from_utf8(output.stdout)?)
}
- Step 4: Build the whole crate, run all server tests, then commit
cargo build -p relicario-server 2>&1 | tail -15
cargo test -p relicario-server 2>&1 | tail -25
Expected: clean build; all org_hook tests pass; existing verify_commit tests still pass.
cargo run -p relicario-server -- generate-org-hook | head -20
cargo run -p relicario-server -- verify-org-commit --help 2>&1 | head -10
Expected: the org hook script prints with the verify-org-commit loop; --help shows the subcommand.
git add crates/relicario-server/Cargo.toml crates/relicario-server/src/lib.rs crates/relicario-server/src/main.rs crates/relicario-server/tests/org_hook.rs
git commit -m "feat(server): verify-org-commit — signature + path-scoped role/grant auth + schema monotonicity"
Dev-D — extension (CLI/extension parity)
Stream prerequisite: Tasks A2–A3 must be merged before Dev-D begins (the WASM bindings call
relicario_core::{unwrap_org_key, decrypt_org_manifest, decrypt_item}). Independent of Dev-B/Dev-C.Scope: This cluster ships extension org support as read + switch only (list orgs, open an org, browse items, read a single item, switch back to Personal cleanly). Full extension-side org item creation/editing is explicitly a tracked follow-up (see the note at the end of Task D4) — org writes require signed, collection-scoped commits and the browser GitHosts write via the Contents REST API with no commit-signing path.
[Dev-D] Task D1: WASM org bindings (unwrap org key → SessionHandle, org manifest/item decrypt)
Why this task exists: The current WASM surface has no org functions. manifest_decrypt/item_decrypt are strictly typed (Manifest/Item) and keyed by an opaque SessionHandle whose master key never crosses into JS. An OrgManifest is a different serde schema, so it cannot reuse manifest_decrypt. These bindings add the org read path while preserving the "key never leaves WASM" invariant: the unwrapped org key is dropped straight into the existing session table and only a u32 handle is returned to JS.
Files:
-
Modify:
crates/relicario-wasm/src/lib.rs -
Modify:
crates/relicario-wasm/Cargo.toml(only ifbase64is not already a dependency) -
Rebuild artifact:
extension/wasm/relicario_wasm.js,relicario_wasm_bg.wasm,relicario_wasm.d.ts -
Step 1: Confirm the core org API the binding depends on exists
Dev-A's Tasks A2/A3 must be merged first. Verify:
cargo doc -p relicario-core --no-deps 2>/dev/null
grep -n "pub fn unwrap_org_key\|pub fn decrypt_org_manifest\|pub fn decrypt_item" \
crates/relicario-core/src/org.rs crates/relicario-core/src/vault.rs
Expected: unwrap_org_key(&[u8], &Zeroizing<[u8;32]>) -> Result<Zeroizing<[u8;32]>> in org.rs; decrypt_org_manifest(&[u8], &Zeroizing<[u8;32]>) -> Result<OrgManifest> in vault.rs; decrypt_item(&[u8], &Zeroizing<[u8;32]>) -> Result<Item> re-exported. If any signature differs, STOP and reconcile.
- Step 2: Confirm/add the
base64dependency
grep -n "^base64" crates/relicario-wasm/Cargo.toml
If absent, add under [dependencies] in crates/relicario-wasm/Cargo.toml:
base64 = "0.22"
(Confirm 0.22 matches the workspace: grep -A1 'name = "base64"' Cargo.lock. Use whatever major version is already locked; do not introduce a second version.)
- Step 3: Add a failing wasm-bindgen-test for the round trip
Add to the #[cfg(test)] mod tests block near the bottom of crates/relicario-wasm/src/lib.rs:
#[test]
fn org_open_unwraps_into_a_handle_and_decrypts_manifest() {
use ed25519_dalek::SigningKey;
use rand::rngs::OsRng;
use rand::RngCore;
use zeroize::Zeroizing;
// Build a device keypair and its OpenSSH PEM, exactly as the extension stores it.
let mut seed = [0u8; 32];
OsRng.fill_bytes(&mut seed);
let signing = SigningKey::from_bytes(&seed);
let ssh_priv = ssh_key::PrivateKey::from(signing.clone());
let pem: String = ssh_priv.to_openssh(ssh_key::LineEnding::LF).unwrap().to_string();
let pubkey_openssh = ssh_priv.public_key().to_openssh().unwrap();
// Org key wrapped to that pubkey, and an org manifest encrypted under the org key.
let org_key = relicario_core::generate_org_key();
let wrapped = relicario_core::wrap_org_key(&org_key, &pubkey_openssh).unwrap();
let mut manifest = relicario_core::OrgManifest::new();
manifest.entries.push(relicario_core::OrgManifestEntry {
id: relicario_core::ItemId::new(),
r#type: relicario_core::ItemType::SecureNote,
title: "secret".into(),
tags: vec![],
modified: 0,
trashed_at: None,
collection: "prod".into(),
});
let enc_manifest = relicario_core::encrypt_org_manifest(&manifest, &org_key).unwrap();
// The PEM is stored base64-wrapped by the SW; mirror that here.
use base64::Engine;
let pem_b64 = base64::engine::general_purpose::STANDARD.encode(pem.as_bytes());
// Exercise the internal helpers the wasm-bindgen exports wrap.
let handle = org_open_handle(&pem_b64, &wrapped).expect("org_open_handle");
let out = session::with(handle, |k| {
relicario_core::decrypt_org_manifest(&enc_manifest, k)
})
.unwrap()
.unwrap();
assert_eq!(out.entries.len(), 1);
assert_eq!(out.entries[0].collection, "prod");
session::remove(handle);
let _ = (seed, signing, org_key, Zeroizing::new([0u8; 0]));
}
cargo test -p relicario-wasm org_open_unwraps 2>&1 | tail -8
Expected: FAIL — org_open_handle undefined.
Note on the test's
PrivateKey::from(signing.clone()):relicario-wasmis a separate crate; this draft used the directFromform. If the installedssh-keyrejects it (same H7 issue as core), usessh_key::PrivateKey::from(ssh_key::private::Ed25519Keypair::from(&signing)). Verify against the wasm crate'sssh-keyversion before running.
- Step 4: Implement the bindings
Add to crates/relicario-wasm/src/lib.rs:
use base64::Engine as _;
/// Internal: parse a base64-wrapped OpenSSH ed25519 private-key PEM into its
/// 32-byte seed, unwrap the org key blob, and insert the org key into the
/// session table. Returns the raw u32 handle. The org key never crosses to JS.
fn org_open_handle(device_private_key_b64: &str, wrapped_key: &[u8]) -> Result<u32, JsError> {
use zeroize::Zeroizing;
let pem_bytes = base64::engine::general_purpose::STANDARD
.decode(device_private_key_b64.trim())
.map_err(|e| JsError::new(&format!("device key base64: {e}")))?;
let pem = String::from_utf8(pem_bytes)
.map_err(|e| JsError::new(&format!("device key utf8: {e}")))?;
let private = ssh_key::PrivateKey::from_openssh(&pem)
.map_err(|e| JsError::new(&format!("parse device key: {e}")))?;
let ed = private
.key_data()
.ed25519()
.ok_or_else(|| JsError::new("device key is not ed25519"))?;
let seed_slice: &[u8] = ed.private.as_ref();
if seed_slice.len() != 32 {
return Err(JsError::new("ed25519 seed has wrong length"));
}
let mut seed = Zeroizing::new([0u8; 32]);
seed.copy_from_slice(seed_slice);
let org_key = relicario_core::unwrap_org_key(wrapped_key, &seed)
.map_err(|e| JsError::new(&format!("unwrap org key: {e}")))?;
// image_secret is unused for org sessions — store zeros.
let handle = session::insert(org_key, Zeroizing::new([0u8; 32]));
Ok(handle)
}
/// Unwrap the caller's wrapped org-key blob with their device private key and
/// return an opaque SessionHandle bound to the org master key. The org key
/// stays inside WASM linear memory — JS receives only the handle.
#[wasm_bindgen]
pub fn org_open(device_private_key_b64: &str, wrapped_key: &[u8]) -> Result<SessionHandle, JsError> {
let handle = org_open_handle(device_private_key_b64, wrapped_key)?;
Ok(SessionHandle(handle))
}
/// Decrypt an org manifest ciphertext under an org SessionHandle.
#[wasm_bindgen]
pub fn org_manifest_decrypt(handle: &SessionHandle, encrypted: &[u8]) -> Result<JsValue, JsError> {
need_key(handle)?;
let out = session::with(handle.0, |k| relicario_core::decrypt_org_manifest(encrypted, k))
.unwrap()
.map_err(|e| JsError::new(&e.to_string()))?;
js_value_for(&out)
}
/// Decrypt an org item ciphertext under an org SessionHandle.
#[wasm_bindgen]
pub fn org_item_decrypt(handle: &SessionHandle, encrypted: &[u8]) -> Result<JsValue, JsError> {
need_key(handle)?;
let out = session::with(handle.0, |k| relicario_core::decrypt_item(encrypted, k))
.unwrap()
.map_err(|e| JsError::new(&e.to_string()))?;
js_value_for(&out)
}
Notes for the implementer:
SessionHandleis a tuple structSessionHandle(u32)andneed_key,session::with,session::insert,js_value_foralready exist in this file.SessionHandle.0is accessible because the binding code is in the same module.lock(&SessionHandle)already removes the session entry, so the existing JSwasm.lock(handle)path zeroes the org key too — no new teardown export needed.ssh_key,ed25519_dalek,zeroize,relicario_coreare already dependencies ofrelicario-wasm.
- Step 5: Run the unit test
cargo test -p relicario-wasm org_open_unwraps 2>&1 | tail -8
Expected: PASS.
- Step 6: Rebuild the WASM artifact the extension consumes
cd crates/relicario-wasm && wasm-pack build --target web --out-dir ../../extension/wasm 2>&1 | tail -15
Then confirm the new exports landed:
grep -n "export function org_open\|export function org_manifest_decrypt\|export function org_item_decrypt" \
extension/wasm/relicario_wasm.d.ts
Expected: all three present.
- Step 7: Commit
git add crates/relicario-wasm/src/lib.rs crates/relicario-wasm/Cargo.toml extension/wasm/
git commit -m "feat(wasm/org): org_open + org_manifest_decrypt + org_item_decrypt bindings"
[Dev-D] Task D2: SW org session module + handlers (list_orgs / open_org / get_org_items / get_org_item)
Files:
- Create:
extension/src/service-worker/org.ts - Modify:
extension/src/service-worker/router/popup-only.ts(add 4 handler arms) - Modify:
extension/src/shared/messages.ts(add 4 message types + responses + capability-set entries) - Modify:
extension/src/service-worker/index.ts(clear org session on session-expiry)
Storage model (matches existing extension conventions):
-
Org configs live in
chrome.storage.localunder keyorgConfigs: an arrayOrgConfigEntry[]. -
The unwrapped org master key lives only as a
SessionHandlein a module-scope variable inorg.ts— never written tochrome.storage.localor IndexedDB. -
Offline → read-only is derived, not stored: any
git.readFilerejection while an org session is open flips an in-memoryreadOnlyflag. -
Step 1: Add the message types and capability-set entries
In extension/src/shared/messages.ts, add to the PopupMessage union (after the get_vault_status arm):
| { type: 'list_orgs' }
| { type: 'open_org'; orgId: string }
| { type: 'get_org_items'; collection?: string }
| { type: 'get_org_item'; id: ItemId }
Add these typed responses near the other *Response interfaces:
export interface OrgSummary {
orgId: string;
label: string;
}
export interface ListOrgsResponse extends Extract<Response, { ok: true }> {
data: { orgs: OrgSummary[]; activeOrgId: string | null };
}
export interface OpenOrgResponse extends Extract<Response, { ok: true }> {
data: { orgId: string; label: string; readOnly: boolean };
}
export interface GetOrgItemsResponse extends Extract<Response, { ok: true }> {
data: {
items: Array<{
id: ItemId; type: string; title: string; tags: string[];
modified: number; trashed_at?: number; collection: string;
}>;
readOnly: boolean;
};
}
export interface GetOrgItemResponse extends Extract<Response, { ok: true }> {
data: { item: Item; readOnly: boolean };
}
Add the four types to POPUP_ONLY_TYPES:
'create_vault', 'attach_vault', 'get_vault_status',
'list_orgs', 'open_org', 'get_org_items', 'get_org_item',
- Step 2: Create the SW org session module
Create extension/src/service-worker/org.ts:
/// Org-vault session + read operations for the service worker.
///
/// Mirrors session.ts/vault.ts for the personal vault, but for org repos:
/// - Holds at most one unwrapped org SessionHandle in module memory. The org
/// master key lives inside WASM linear memory; JS only ever sees the opaque
/// handle. NEVER persisted.
/// - Org configs live in chrome.storage.local under `orgConfigs` — config only.
/// - Offline is derived: a failed git.readFile flips the active session's
/// readOnly flag.
import type { SessionHandle } from '../../wasm/relicario_wasm';
import type { GitHost } from './git-host';
import { createGitHost } from './git-host';
import type { Item } from '../shared/types';
export interface OrgConfigEntry {
orgId: string;
label: string;
hostType: 'gitea' | 'github';
hostUrl: string;
repoPath: string;
apiToken: string;
/// Path inside the org repo of THIS device's wrapped org-key blob,
/// e.g. "keys/<member-id>.enc".
wrappedKeyPath: string;
}
interface ActiveOrgSession {
orgId: string;
label: string;
handle: SessionHandle;
git: GitHost;
readOnly: boolean;
}
// Module-scope, single active org session. NEVER serialized.
let active: ActiveOrgSession | null = null;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let wasm: any = null;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function setWasm(w: any): void { wasm = w; }
function requireWasm(): any {
if (!wasm) throw new Error('WASM module not initialized');
return wasm;
}
export async function loadOrgConfigs(): Promise<OrgConfigEntry[]> {
const r = await chrome.storage.local.get('orgConfigs');
const raw = r.orgConfigs;
return Array.isArray(raw) ? (raw as OrgConfigEntry[]) : [];
}
export function getActiveOrgId(): string | null {
return active ? active.orgId : null;
}
export function isReadOnly(): boolean {
return active ? active.readOnly : false;
}
/// Tear down the active org session: lock (zeroes the org key in WASM) and drop.
export function clearActiveOrg(): void {
if (active) {
try { requireWasm().lock(active.handle); } catch { /* may already be gone */ }
try { (active.handle as unknown as { free?: () => void }).free?.(); } catch { /* idempotent */ }
active = null;
}
}
/// Read the device private key the SW persisted at setup/register time.
async function loadDevicePrivateKeyB64(): Promise<string> {
const r = await chrome.storage.local.get('device_private_key');
const key = r.device_private_key;
if (typeof key !== 'string' || key.length === 0) {
throw new Error('device_not_registered');
}
return key;
}
/// Open (unlock) an org by id: build its GitHost, fetch this device's wrapped
/// key blob, and unwrap into an org SessionHandle via WASM.
export async function openOrg(orgId: string): Promise<{ orgId: string; label: string; readOnly: boolean }> {
const w = requireWasm();
const configs = await loadOrgConfigs();
const cfg = configs.find((c) => c.orgId === orgId);
if (!cfg) throw new Error('org_not_found');
const devicePrivB64 = await loadDevicePrivateKeyB64();
const git = createGitHost(cfg.hostType, cfg.hostUrl, cfg.repoPath, cfg.apiToken);
let readOnly = false;
let wrapped: Uint8Array;
try {
wrapped = await git.readFile(cfg.wrappedKeyPath);
} catch {
throw new Error('org_offline');
}
const handle = w.org_open(devicePrivB64, wrapped) as SessionHandle;
clearActiveOrg();
active = { orgId: cfg.orgId, label: cfg.label, handle, git, readOnly };
return { orgId: cfg.orgId, label: cfg.label, readOnly };
}
interface OrgManifestEntryJson {
id: string; type: string; title: string; tags?: string[];
modified: number; trashed_at?: number; collection: string;
}
interface OrgManifestJson { schema_version: number; entries: OrgManifestEntryJson[]; }
/// Fetch + decrypt the active org's manifest, optionally filtered to one collection.
export async function getOrgItems(
collection?: string,
): Promise<{ items: OrgManifestEntryJson[]; readOnly: boolean }> {
const w = requireWasm();
if (!active) throw new Error('org_locked');
let ciphertext: Uint8Array;
try {
ciphertext = await active.git.readFile('manifest.enc');
} catch {
active.readOnly = true;
throw new Error('org_offline');
}
const manifest = w.org_manifest_decrypt(active.handle, ciphertext) as OrgManifestJson;
const all = (manifest.entries ?? []).filter((e) => e.trashed_at === undefined);
const slug = collection?.toLowerCase();
const items = slug ? all.filter((e) => e.collection.toLowerCase() === slug) : all;
return {
items: items.map((e) => ({ ...e, tags: e.tags ?? [] })),
readOnly: active.readOnly,
};
}
/// Fetch + decrypt a single org item. Items are collection-scoped on disk
/// (items/<slug>/<id>.enc), so we read the manifest entry first to learn the slug.
export async function getOrgItem(id: string): Promise<{ item: Item; readOnly: boolean }> {
const w = requireWasm();
if (!active) throw new Error('org_locked');
let manifestCt: Uint8Array;
try {
manifestCt = await active.git.readFile('manifest.enc');
} catch {
active.readOnly = true;
throw new Error('org_offline');
}
const manifest = w.org_manifest_decrypt(active.handle, manifestCt) as OrgManifestJson;
const entry = manifest.entries.find((e) => e.id === id);
if (!entry) throw new Error('item_not_found');
let itemCt: Uint8Array;
try {
itemCt = await active.git.readFile(`items/${entry.collection}/${id}.enc`);
} catch {
active.readOnly = true;
throw new Error('org_offline');
}
const item = w.org_item_decrypt(active.handle, itemCt) as Item;
return { item, readOnly: active.readOnly };
}
/// Build the list-orgs payload from config; the active id reflects the live
/// in-memory session, not anything persisted.
export async function listOrgs(): Promise<{
orgs: Array<{ orgId: string; label: string }>;
activeOrgId: string | null;
}> {
const configs = await loadOrgConfigs();
return {
orgs: configs.map((c) => ({ orgId: c.orgId, label: c.label })),
activeOrgId: getActiveOrgId(),
};
}
- Step 3: Wire
setWasm(orgModule)at SW init
In extension/src/service-worker/index.ts, import the org module:
import * as org from './org';
Inside initWasm(), right after vault.setWasm(wasmBindings);:
org.setWasm(wasmBindings);
And in the sessionTimer.onExpired(...) callback (after state.gitHost = null;), tear the org session down too:
org.clearActiveOrg();
- Step 4: Add the four handler arms
In extension/src/service-worker/router/popup-only.ts, add an import:
import * as org from '../org';
Add these arms inside the switch (msg.type) in handle(...), after the get_vault_status arm:
case 'list_orgs': {
const data = await org.listOrgs();
return { ok: true, data };
}
case 'open_org': {
try {
const data = await org.openOrg(msg.orgId);
return { ok: true, data };
} catch (err) {
return { ok: false, error: err instanceof Error ? err.message : String(err) };
}
}
case 'get_org_items': {
try {
const data = await org.getOrgItems(msg.collection);
return { ok: true, data };
} catch (err) {
return { ok: false, error: err instanceof Error ? err.message : String(err) };
}
}
case 'get_org_item': {
try {
const data = await org.getOrgItem(msg.id);
return { ok: true, data };
} catch (err) {
return { ok: false, error: err instanceof Error ? err.message : String(err) };
}
}
Recommended: also call
org.clearActiveOrg()in the existinglockarm for symmetric teardown.
- Step 5: Type-check the whole extension (NOT bare
tsc --noEmit)
cd extension && npm run build:all 2>&1 | tail -20
Expected: clean build. The new org_open/org_manifest_decrypt/org_item_decrypt resolve against the regenerated relicario_wasm.d.ts from Task D1.
- Step 6: Commit
git add extension/src/service-worker/org.ts extension/src/service-worker/index.ts \
extension/src/service-worker/router/popup-only.ts extension/src/shared/messages.ts
git commit -m "feat(ext/sw): org session module + list_orgs/open_org/get_org_items/get_org_item handlers"
[Dev-D] Task D3: Vault-tab org switcher (Personal + each configured org)
Files:
- Create:
extension/src/vault/vault-org-switcher.ts - Modify:
extension/src/vault/vault-sidebar.ts(mount the switcher) - Modify:
extension/src/vault/vault-context.ts,vault.ts(addactiveOrgIdstate) - Modify:
extension/src/vault/vault.css(switcher + read-only banner styling)
The switcher is a small <select> at the top of the vault-tab sidebar: "Personal" plus one option per configured org. Selecting an org sends open_org, then get_org_items, and renders the org's items. Selecting "Personal" restores the personal manifest. When the active org session is read-only (offline), a banner shows above the list.
- Step 1: Implement the switcher
Create extension/src/vault/vault-org-switcher.ts:
/// Vault-tab org context switcher: "Personal" + each configured org.
///
/// Switching to an org opens it through the SW (open_org) and replaces the
/// list pane's entries with the org manifest projection. Switching back to
/// Personal restores the personal manifest. The org master key never reaches
/// this module — it stays inside WASM behind the SW.
import { sendMessage } from '../shared/state';
import type { VaultController } from './vault-context';
import type {
ListOrgsResponse, OpenOrgResponse, GetOrgItemsResponse,
} from '../shared/messages';
const SWITCHER_ID = 'vault-org-switcher';
const BANNER_ID = 'vault-org-readonly-banner';
/// Render the switcher into `host`. Idempotent — clears and rebuilds.
export async function renderOrgSwitcher(ctx: VaultController, host: HTMLElement): Promise<void> {
const resp = await sendMessage({ type: 'list_orgs' });
if (!resp.ok) return;
const { orgs, activeOrgId } = (resp as ListOrgsResponse).data;
// No orgs configured → render nothing (keeps the personal-only UI clean).
if (orgs.length === 0) {
host.querySelector(`#${SWITCHER_ID}`)?.remove();
return;
}
let select = host.querySelector<HTMLSelectElement>(`#${SWITCHER_ID}`);
if (!select) {
select = document.createElement('select');
select.id = SWITCHER_ID;
select.className = 'org-switcher';
select.setAttribute('aria-label', 'Vault context');
host.prepend(select);
select.addEventListener('change', () => { void onSwitch(ctx, select!.value); });
}
const opts = ['<option value="personal">Personal</option>']
.concat(orgs.map((o) =>
`<option value="${o.orgId}">${ctx.state ? escapeOption(o.label) : o.label}</option>`));
select.innerHTML = opts.join('');
select.value = activeOrgId ?? 'personal';
}
function escapeOption(s: string): string {
return s.replace(/[&<>"]/g, (c) =>
({ '&': '&', '<': '<', '>': '>', '"': '"' }[c] ?? c));
}
/// Handle a switcher change. "personal" restores the personal manifest;
/// any org id opens that org and loads its items into the list pane.
async function onSwitch(ctx: VaultController, value: string): Promise<void> {
if (value === 'personal') {
setReadOnlyBanner(false);
ctx.state.activeOrgId = null;
await ctx.loadManifest(); // re-loads the personal manifest into state.entries
ctx.renderListPane();
return;
}
const opened = await sendMessage({ type: 'open_org', orgId: value });
if (!opened.ok) {
ctx.state.error = opened.error === 'org_offline'
? 'This org is unavailable offline.'
: `Could not open org: ${opened.error}`;
ctx.renderPane();
return;
}
const openData = (opened as OpenOrgResponse).data;
ctx.state.activeOrgId = openData.orgId;
const itemsResp = await sendMessage({ type: 'get_org_items' });
if (!itemsResp.ok) {
if (itemsResp.error === 'org_offline') {
setReadOnlyBanner(true);
} else {
ctx.state.error = `Could not load org items: ${itemsResp.error}`;
ctx.renderPane();
return;
}
} else {
const data = (itemsResp as GetOrgItemsResponse).data;
setReadOnlyBanner(data.readOnly);
// Project org entries into the same [id, entry] tuple shape the list pane
// consumes for the personal manifest.
ctx.state.entries = data.items.map((e) => [
e.id,
{
id: e.id, type: e.type, title: e.title, tags: e.tags,
favorite: false, modified: e.modified, trashed_at: e.trashed_at,
// org-specific projection field; harmless extra for the personal list renderer.
collection: e.collection,
},
]) as typeof ctx.state.entries;
}
ctx.state.selectedId = null;
ctx.state.selectedItem = null;
ctx.renderSidebarCategories();
ctx.renderListPane();
}
function setReadOnlyBanner(show: boolean): void {
const existing = document.getElementById(BANNER_ID);
if (!show) { existing?.remove(); return; }
if (existing) return;
const banner = document.createElement('div');
banner.id = BANNER_ID;
banner.className = 'org-readonly-banner';
banner.textContent = 'Read-only — org is unavailable offline.';
const pane = document.getElementById('vault-list') ?? document.body;
pane.prepend(banner);
}
Implementer notes:
ctx.loadManifest(),ctx.renderListPane(),ctx.renderSidebarCategories(),ctx.renderPane(), andctx.stateare all on the existingVaultController.VaultStateneeds a new optional fieldactiveOrgId: string | null. Add it to theVaultStateinterface invault-context.tsand initializeactiveOrgId: nullinvault.ts. If the list renderer strictly types its entries, add an optionalcollection?: stringto the entry projection type rather than casting.
- Step 2: Mount the switcher in the sidebar
In extension/src/vault/vault-sidebar.ts, import and call from wireSidebar:
import { renderOrgSwitcher } from './vault-org-switcher';
Inside wireSidebar(ctx), after the existing refreshStatus() call:
const sidebarNav = document.getElementById('vault-sidebar') ?? document.body;
void renderOrgSwitcher(ctx, sidebarNav as HTMLElement);
(Verify the actual sidebar container id in vault.html — use that element as the mount host.)
- Step 3: Add minimal styling
In extension/src/vault/vault.css, append:
.org-switcher {
width: 100%;
margin-bottom: 0.5rem;
background: var(--bg-elev, #1b1b1b);
color: var(--fg, #ddd);
border: 1px solid var(--border, #333);
font: inherit;
padding: 0.25rem;
}
.org-readonly-banner {
background: #4a3a00;
color: #ffd24a;
padding: 0.35rem 0.6rem;
font-size: 0.85em;
border-bottom: 1px solid #6a5400;
}
- Step 4: Type-check via build:all
cd extension && npm run build:all 2>&1 | tail -20
Expected: clean.
- Step 5: Commit
git add extension/src/vault/vault-org-switcher.ts extension/src/vault/vault-sidebar.ts \
extension/src/vault/vault-context.ts extension/src/vault/vault.ts extension/src/vault/vault.css
git commit -m "feat(ext/vault): org context switcher (Personal + configured orgs) with read-only banner"
[Dev-D] Task D4: The 3 spec-mandated vitest tests
Files:
- Create:
extension/src/service-worker/__tests__/org.test.ts - Modify:
extension/ARCHITECTURE.md(storage table + module map)
These follow the existing vitest style: vi.mock the SW's git-host module, a chrome.storage.local shim, and a fake wasm object — exactly as in vault.test.ts and router.test.ts.
- Step 1: Write the three tests
Create extension/src/service-worker/__tests__/org.test.ts:
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { GitHost } from '../git-host';
import * as gitHostMod from '../git-host';
// --- Mock git-host: org.ts builds its GitHost via createGitHost ---
function makeHostMock(opts: { offline?: boolean } = {}): GitHost {
const reader = vi.fn().mockImplementation(async (path: string) => {
if (opts.offline) throw new Error(`network: ${path}`);
if (path === 'keys/m0.enc') return new Uint8Array([0xaa, 0xbb]); // wrapped org key blob
if (path === 'manifest.enc') return new Uint8Array([0x01, 0x02]); // org manifest ct
if (path === 'items/prod/itemprod00000001.enc') return new Uint8Array([0x03]);
throw new Error(`404: ${path}`);
});
return {
readFile: reader,
writeFile: vi.fn(),
writeFileCreateOnly: vi.fn(),
deleteFile: vi.fn(),
listDir: vi.fn().mockResolvedValue([]),
lastCommit: vi.fn().mockResolvedValue(null),
putBlob: vi.fn(),
getBlob: vi.fn(),
deleteBlob: vi.fn(),
lastSyncAt: null,
ahead: 0,
behind: 0,
} as unknown as GitHost;
}
let offlineMode = false;
vi.mock('../git-host', async () => {
const actual = await vi.importActual<typeof import('../git-host')>('../git-host');
return {
...actual,
createGitHost: vi.fn(() => makeHostMock({ offline: offlineMode })),
};
});
import * as org from '../org';
// --- chrome.storage.local shim ---
function mockStorage(initial: Record<string, unknown>) {
const store = { ...initial };
// @ts-expect-error test harness
globalThis.chrome = {
storage: {
local: {
get: vi.fn((k: string | string[]) => {
const arr = Array.isArray(k) ? k : [k];
const out: Record<string, unknown> = {};
for (const key of arr) if (key in store) out[key] = store[key];
return Promise.resolve(out);
}),
set: vi.fn((kv: Record<string, unknown>) => { Object.assign(store, kv); return Promise.resolve(); }),
},
},
runtime: { id: 'relicario-test-id', getURL: (p: string) => `chrome-extension://relicario-test-id/${p}` },
};
return store;
}
const ORG_CONFIGS = [{
orgId: 'org1', label: 'Acme', hostType: 'gitea', hostUrl: 'https://g',
repoPath: 'acme/vault', apiToken: 't', wrappedKeyPath: 'keys/m0.enc',
}];
// --- Fake wasm: org_open returns an opaque handle, decrypt fns return JSON ---
const ORG_MANIFEST = {
schema_version: 1,
entries: [{
id: 'itemprod00000001', type: 'secure_note', title: 'Prod secret',
tags: [], modified: 100, collection: 'prod',
}],
};
function makeWasm() {
const handle = { __org: true, free: vi.fn() };
return {
_handle: handle,
org_open: vi.fn(() => handle),
org_manifest_decrypt: vi.fn(() => ORG_MANIFEST),
org_item_decrypt: vi.fn(() => ({
id: 'itemprod00000001', type: 'secure_note', title: 'Prod secret',
core: { type: 'secure_note', body: 'top secret' }, attachments: [],
tags: [], modified: 100, favorite: false,
})),
lock: vi.fn(),
};
}
describe('SW org context', () => {
let store: Record<string, unknown>;
let wasm: ReturnType<typeof makeWasm>;
beforeEach(() => {
offlineMode = false;
store = mockStorage({ orgConfigs: ORG_CONFIGS, device_private_key: 'ZmFrZS1wZW0=' });
wasm = makeWasm();
org.setWasm(wasm);
vi.mocked(gitHostMod.createGitHost).mockImplementation(() => makeHostMock({ offline: offlineMode }));
});
afterEach(() => {
org.clearActiveOrg();
vi.restoreAllMocks();
});
// 1) Org context switching replaces the personal manifest cleanly.
it('open_org + get_org_items returns only the org manifest entries', async () => {
const opened = await org.openOrg('org1');
expect(opened).toMatchObject({ orgId: 'org1', label: 'Acme', readOnly: false });
expect(wasm.org_open).toHaveBeenCalledWith('ZmFrZS1wZW0=', expect.any(Uint8Array));
const { items, readOnly } = await org.getOrgItems();
expect(readOnly).toBe(false);
expect(items).toHaveLength(1);
expect(items[0]).toMatchObject({ id: 'itemprod00000001', collection: 'prod', title: 'Prod secret' });
// Single org item read is collection-scoped: items/<slug>/<id>.enc.
const single = await org.getOrgItem('itemprod00000001');
expect(single.item).toMatchObject({ id: 'itemprod00000001' });
expect(wasm.org_item_decrypt).toHaveBeenCalled();
// Switching context away (back to Personal) tears the org session down.
org.clearActiveOrg();
expect(org.getActiveOrgId()).toBeNull();
});
// 2) Org master key is never persisted.
it('never writes the unwrapped org key to chrome.storage.local', async () => {
await org.openOrg('org1');
await org.getOrgItems();
const setFn = (globalThis as unknown as {
chrome: { storage: { local: { set: ReturnType<typeof vi.fn> } } };
}).chrome.storage.local.set;
expect(setFn).not.toHaveBeenCalled();
const handleReturn = wasm.org_open.mock.results[0].value;
expect(handleReturn).toBe(wasm._handle);
expect(handleReturn).not.toBeInstanceOf(Uint8Array);
org.clearActiveOrg();
expect(wasm.lock).toHaveBeenCalledWith(wasm._handle);
});
// 3) Offline read triggers read-only.
it('flips read-only / org_offline when the git host is unreachable', async () => {
await org.openOrg('org1');
expect(org.isReadOnly()).toBe(false);
offlineMode = true;
await expect(org.getOrgItems()).rejects.toThrow('org_offline');
expect(org.isReadOnly()).toBe(true);
});
it('open_org itself reports org_offline when the wrapped key cannot be fetched', async () => {
offlineMode = true;
await expect(org.openOrg('org1')).rejects.toThrow('org_offline');
expect(org.getActiveOrgId()).toBeNull();
});
});
- Step 2: Run the org tests
cd extension && npx vitest run src/service-worker/__tests__/org.test.ts 2>&1 | tail -25
Expected: all tests pass.
- Step 3: Run the full extension test + build verify
cd extension && npm test 2>&1 | tail -25
cd extension && npm run build:all 2>&1 | tail -20
Expected: full vitest suite green; clean build:all.
- Step 4: Update extension ARCHITECTURE.md + commit
Add orgConfigs to the chrome.storage.local table in extension/ARCHITECTURE.md ("array of OrgConfigEntry — org repo coordinates + this device's wrapped-key path; config only, no secrets"), add service-worker/org.ts and vault/vault-org-switcher.ts to the module map, and note the invariant: "the unwrapped org master key lives only as a SessionHandle in org.ts module memory — never persisted, zeroed on lock/timeout via wasm.lock."
git add extension/src/service-worker/__tests__/org.test.ts extension/ARCHITECTURE.md
git commit -m "test(ext/sw): org context switch, no-key-persistence, offline read-only + ARCHITECTURE docs"
Tracked follow-up (do NOT implement here): extension-side org writes —
add_org_item/update_org_item/delete_org_item, collection-scoped writes, manifest re-encrypt, and signed commits from the browser. The browser GitHosts write via the Contents REST API with no commit-signing path, while the corrected org design requires every org commit to be signed. File it as "Plan B-2: extension org writes" against the org-vault epic.
Dev-A — docs (final)
[Dev-A] Task A5: Living-docs update (final, after all code streams merge)
Files:
- Modify:
docs/FORMATS.md,docs/CRYPTO.md,DESIGN.md,docs/SECURITY.md,crates/relicario-core/ARCHITECTURE.md,crates/relicario-cli/ARCHITECTURE.md,extension/ARCHITECTURE.md,STATUS.md,ROADMAP.md
Per the spec's "Living-Docs Impact" section and CLAUDE.md living-docs discipline, this task records the new on-disk formats, the new crypto path, the new dependencies, and the honest limitations. It runs after A1–A4, all of Dev-B/C/D have merged. Concrete content per doc:
-
Step 1:
docs/FORMATS.md— add the org wire formats:- The four org JSON files:
org.json,members.json(incl.ed25519_pubkeyOpenSSH +collectionsgrant array +role),collections.json, with theirschema_versionfields. - The
keys/<member-id>.encwrapped-blob layout:ephemeral_x25519_pubkey(32) || version(1) || nonce(24) || ciphertext+tag; note the wrapping key isSHA-256(dh_shared || ephemeral_pubkey || recipient_pubkey). - Collection-scoped item path
items/<collection-slug>/<item-id>.enc(same.encformat as personal items; blob does not name its collection — the directory path does). manifest.encorg variant (each entry carries acollectionslug).
- The four org JSON files:
-
Step 2:
docs/CRYPTO.md— add the ECIES org-key path:- ed25519→X25519 conversion (SHA-512(seed)[:32] + RFC 7748 clamp for the scalar; birational Montgomery map for the point), domain-separated KDF, all secret intermediates in
Zeroizing. - Org-key wrap/unwrap diagram; note org crypto bypasses Argon2id (X25519-based, key directly used for XChaCha20-Poly1305).
- Key-rotation re-encryption: rotate-key generates a fresh org key, re-wraps for remaining members, AND re-encrypts every
items/<slug>/<id>.encblob + the manifest under the new key.
- ed25519→X25519 conversion (SHA-512(seed)[:32] + RFC 7748 clamp for the scalar; birational Montgomery map for the point), domain-separated KDF, all secret intermediates in
-
Step 3:
DESIGN.md— cross-codebase structure:- Add the org-master-key row to the secrets map (256-bit random, wrapped per-member to device key; never escrowed; owners can always re-grant).
- Add the
x25519-dalekdependency (core) andssh-key(cli) to the build/dep matrix. - Note
relicario-servergains an org mode (verify-org-commit/generate-org-hook) and the new[lib]target.
-
Step 4:
docs/SECURITY.md— threat model + honest limitations:- Org device-key auth (signer resolved by ed25519 fingerprint).
- The signature-verifying pre-receive hook: every org commit must carry a GOOD signature from a current member; writes authorized by role (protected files) or collection path segment (items); merge commits rejected; schema_version monotonic.
- The honest limitations verbatim from the spec: shared org master key — reads are not cryptographically scoped per collection (the hook scopes writes + the client filters the manifest, but one org key opens everything; for cryptographic separation use a separate org vault — per-collection subkeys are a phase-2 non-goal); no read audit (git records writes, not reads); no "hide value."
-
Step 5:
crates/relicario-core/ARCHITECTURE.md— add theorgmodule:- Module map entry for
org.rs(IDs, roles, members, collections, manifest, ECIES wrap/unwrap) + theencrypt_org_manifest/decrypt_org_manifestvault wrappers. - Invariant: KDF intermediates carrying the DH secret are held in
Zeroizing.
- Module map entry for
-
Step 6:
crates/relicario-cli/ARCHITECTURE.md— add the org command surface:org_session.rs(UnlockedOrgVault, collection-scopeditem_path, fingerprint member match, signedorg_git_run).commands/org.rs(admin + item commands); devicecurrent_device_seed/current_device_pubkeyhelpers; thessh-key/regex/tempfiledeps.- Note: org commits are SIGNED (
configure_git_signinginorg init;org_git_rundoes not forcecommit.gpgsign=falsethe wayhelpers::git_rundoes).
-
Step 7:
extension/ARCHITECTURE.md— confirm the storage table + module map entries added in Task D4 are present (orgConfigs,service-worker/org.ts,vault/vault-org-switcher.ts, the SessionHandle-only invariant). Add a short "org context" subsection describing the switch + read flow and the offline read-only derivation. -
Step 8:
STATUS.md/ROADMAP.md:STATUS.md: mark the org-vault track landed (CLI admin + item CRUD, signature-verifying hook, extension switch/read parity); list tracked follow-ups: Card/Key/Document/Totporg add/editparity, extension org writes (Plan B-2), and the phase-2 items (SSO/LDAP, read audit, per-collection subkeys, HTTP management plane).ROADMAP.md: move the org-vault milestone to shipped and scope the phase-2 follow-ups.
-
Step 9: Code-constant pinning check
Per CLAUDE.md discipline rule 2, any code constant cited in these docs (wrapped-blob byte layout, MANIFEST_SCHEMA_VERSION, the KDF construction) must cite the source file + line. Grep your new doc text for cited constants and confirm each has a file:line reference.
- Step 10: Commit
git add docs/FORMATS.md docs/CRYPTO.md DESIGN.md docs/SECURITY.md \
crates/relicario-core/ARCHITECTURE.md crates/relicario-cli/ARCHITECTURE.md \
extension/ARCHITECTURE.md STATUS.md ROADMAP.md
git commit -m "docs(org): living-docs sweep — org formats, ECIES crypto, signature-verifying hook, honest limitations"
Self-Review
Spec coverage check — every spec requirement maps to a task:
| Spec requirement | Covered by task(s) |
|---|---|
| Org master key (256-bit random, wrapped per-member ECIES/X25519) | A2, A4 |
org.json / members.json / collections.json data model |
A2, B4 |
keys/<member-id>.enc wrapped-blob layout |
A2, B4, B5 |
Collection-scoped item storage items/<slug>/<id>.enc |
B1, B9, B10 (write); C1 classify_path (authz); D2 (read) |
manifest.enc org variant (entry carries collection) |
A2, A3 |
| ed25519→X25519 ECIES wrap/unwrap; Zeroizing KDF intermediates (H6) | A2 |
| Roles owner/admin/member; admin cannot mint owner/admin (role-gating) | A2, B5 |
Collection grants in members.json |
A2, B6 |
| Manifest filtering per member grants (read) | A2, A4, B11 |
| Org item CRUD — add / get / list / edit / rm / restore / purge | B10, B11, B12, B13 |
org init (with git signing configured) |
B4 |
org add-member / remove-member / set-role |
B5 |
org create-collection / grant / revoke |
B6 |
org rotate-key — re-wrap + re-encrypt every item blob & manifest |
B7 |
| Rotate-key concurrent-rotation race abort (spec error string) | B7 |
org transfer-ownership / delete-org |
B14 (transfer fully; delete-org local, push tracked gap) |
org status (no decryption) |
B8 |
org audit — verified-signer attribution, TAMPERED flag, %cI + %x1e/%x1f framing, --format json |
B8 |
relicario org wired into main.rs (admin + item subcommands) |
B14 |
| Pre-receive hook: per-commit signature verification (signer→member by fingerprint) | C1 |
| Pre-receive hook: protected-file role authz (owner/admin) | C1 |
| Pre-receive hook: item path → collection-grant authorization | C1 |
| Pre-receive hook: genesis allowed, merge rejected | C1 |
| Pre-receive hook: schema_version monotonicity + JSON validation | C2 |
generate-org-hook script |
C1 |
Signature-verifying hook uses allowed-signers + git verify-commit (NOT %GF) |
C1 |
| Extension parity: WASM org bindings (key never leaves WASM) | D1 |
| Extension parity: SW org session + handlers; org key only in SessionHandle | D2 |
| Extension parity: vault-tab org switcher + offline read-only banner | D3 |
| Extension parity: 3 spec-mandated vitest tests (switch / no-persist / offline) | D4 |
| Offline → read-only (CLI + extension) | B7-adjacent (no-remote distinction), D2/D3 |
| Member device key lost → owner re-grants (no escrow) | B5/B6 (re-add + grant) |
| Living-docs sweep (FORMATS/CRYPTO/DESIGN/SECURITY/ARCHITECTUREs/STATUS/ROADMAP) | A5 |
| Honest limitations recorded (shared key, no read audit, no hide-value) | A5 (docs/SECURITY.md) |
| SSO/SAML/LDAP, read audit, per-collection subkeys, HTTP plane | Phase 2 — out of scope |
Placeholder scan: No TBD/TODO/"similar to Task N"/phantom-task references. The flat-items/ layout, the %GF fingerprint source, the unsigned git_run commit path, the device.key/RELICARIO_DEVICE_KEY storage model, and "items do not need re-encryption" are all removed.
Type consistency: MemberId, OrgRole, OrgMembers, OrgMember, OrgManifest, OrgManifestEntry, CollectionDef, OrgCollections, OrgMeta defined in A2, used consistently in B1–B14, C1–C2. wrap_org_key/unwrap_org_key/generate_org_key/encrypt_org_manifest/decrypt_org_manifest defined in A2–A3, used in B4–B13, C2, D1. UnlockedOrgVault::item_path(collection_slug, id) has exactly ONE definition (B1), consumed by B7–B13 and matched by C1's classify_path and D2's read path.