# 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/.enc`, `manifest.enc`, and **collection-scoped** items at `items//.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` with `features = ["static_secrets"]` (new, in `relicario-core`), `ed25519-dalek 2`, `sha2`, `chacha20poly1305 0.10`, `ssh-key 0.6` (new, in `relicario-cli` **and** `relicario-wasm`), `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::org` types/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-scoped `item_path` + session helpers), and **B14 depends on every `run_*` command fn existing** (it wires them into `main.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 = { version = "2", features = ["static_secrets"] }` | | 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_with_registered_device` / `org_manifest_decrypt` / `org_item_decrypt` bindings | | Modify | `crates/relicario-wasm/src/device.rs` | `unwrap_org_key` accessor — unwraps the org key using the in-memory `DEVICE_STATE` seed (never crosses to JS) | | 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: ```toml 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: ```rust //! 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): ```rust pub mod org; ``` Then add the `pub use` items in Task A2 once the symbols exist. - [ ] **Step 4: Verify it compiles** ```bash 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: ```bash cargo build -p relicario-wasm --target wasm32-unknown-unknown 2>&1 | tail -10 ``` Expected: clean build. - [ ] **Step 6: Commit** ```bash 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 for PrivateKey`). - [ ] **Step 1: Write failing test for MemberId format** ```rust #[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: ```bash 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: ```rust //! Org vault types, crypto, and schema for multi-user self-hosted deployments. use rand::{rngs::OsRng, RngCore}; use serde::{Deserialize, Serialize}; use zeroize::Zeroizing; use crate::error::{RelicarioError, Result}; use crate::ids::ItemId; use crate::item_types::ItemType; // ── IDs ────────────────────────────────────────────────────────────────────── #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(transparent)] pub struct OrgId(pub String); #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(transparent)] pub struct MemberId(pub String); impl OrgId { pub fn new() -> Self { let mut bytes = [0u8; 8]; OsRng.fill_bytes(&mut bytes); Self(hex::encode(bytes)) } pub fn as_str(&self) -> &str { &self.0 } } impl Default for OrgId { fn default() -> Self { Self::new() } } impl MemberId { pub fn new() -> Self { let mut bytes = [0u8; 8]; OsRng.fill_bytes(&mut bytes); Self(hex::encode(bytes)) } pub fn as_str(&self) -> &str { &self.0 } pub fn is_valid(&self) -> bool { self.0.len() == 16 && self.0.chars().all(|c| c.is_ascii_hexdigit()) } } impl Default for MemberId { fn default() -> Self { Self::new() } } // ── Roles ──────────────────────────────────────────────────────────────────── #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum OrgRole { Owner, Admin, Member, } impl OrgRole { pub fn can_manage_members(&self) -> bool { matches!(self, OrgRole::Owner | OrgRole::Admin) } pub fn can_manage_owners(&self) -> bool { matches!(self, OrgRole::Owner) } } // ── Members ────────────────────────────────────────────────────────────────── #[derive(Debug, Clone, Serialize, Deserialize)] pub struct OrgMember { pub member_id: MemberId, pub display_name: String, pub role: OrgRole, /// SSH public key string (openssh format: "ssh-ed25519 AAAA...") pub ed25519_pubkey: String, /// Collection slugs this member can access. #[serde(default)] pub collections: Vec, pub added_at: i64, pub added_by: MemberId, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct OrgMembers { pub schema_version: u32, pub members: Vec, } impl OrgMembers { pub fn new() -> Self { Self { schema_version: 1, members: Vec::new() } } pub fn find_by_id(&self, id: &MemberId) -> Option<&OrgMember> { self.members.iter().find(|m| &m.member_id == id) } pub fn find_by_id_mut(&mut self, id: &MemberId) -> Option<&mut OrgMember> { self.members.iter_mut().find(|m| &m.member_id == id) } pub fn validate(&self) -> Result<()> { for m in &self.members { if !m.member_id.is_valid() { return Err(RelicarioError::Format( format!("invalid member_id: {}", m.member_id.0) )); } } Ok(()) } } impl Default for OrgMembers { fn default() -> Self { Self::new() } } // ── Collections ─────────────────────────────────────────────────────────────── #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CollectionDef { pub slug: String, pub display_name: String, pub created_by: MemberId, pub created_at: i64, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct OrgCollections { pub schema_version: u32, pub collections: Vec, } impl OrgCollections { pub fn new() -> Self { Self { schema_version: 1, collections: Vec::new() } } pub fn contains_slug(&self, slug: &str) -> bool { self.collections.iter().any(|c| c.slug == slug) } pub fn validate(&self) -> Result<()> { for c in &self.collections { if c.slug.is_empty() || c.slug.contains('/') || c.slug.contains('.') { return Err(RelicarioError::Format( format!("invalid collection slug: {:?}", c.slug) )); } } Ok(()) } } impl Default for OrgCollections { fn default() -> Self { Self::new() } } // ── Org meta ───────────────────────────────────────────────────────────────── #[derive(Debug, Clone, Serialize, Deserialize)] pub struct OrgMeta { pub schema_version: u32, pub org_id: OrgId, pub display_name: String, pub created_at: i64, } impl OrgMeta { pub fn new(display_name: String) -> Self { Self { schema_version: 1, org_id: OrgId::new(), display_name, created_at: crate::time::now_unix(), } } } // ── Org manifest ───────────────────────────────────────────────────────────── #[derive(Debug, Clone, Serialize, Deserialize)] pub struct OrgManifestEntry { pub id: ItemId, pub r#type: ItemType, pub title: String, #[serde(default)] pub tags: Vec, pub modified: i64, #[serde(skip_serializing_if = "Option::is_none")] pub trashed_at: Option, /// Collection this item belongs to. pub collection: String, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct OrgManifest { pub schema_version: u32, pub entries: Vec, } impl OrgManifest { pub fn new() -> Self { Self { schema_version: 1, entries: Vec::new() } } /// Return only entries whose collection is in `member.collections`. pub fn filter_for_member(&self, member: &OrgMember) -> Self { let granted: std::collections::HashSet<&str> = member.collections.iter().map(|s| s.as_str()).collect(); Self { schema_version: self.schema_version, entries: self.entries.iter() .filter(|e| granted.contains(e.collection.as_str())) .cloned() .collect(), } } } impl Default for OrgManifest { fn default() -> Self { Self::new() } } // ── Key wrap / unwrap (ECIES: X25519 + XChaCha20-Poly1305) ─────────────────── /// Generate a random 256-bit org master key. pub fn generate_org_key() -> Zeroizing<[u8; 32]> { let mut key = Zeroizing::new([0u8; 32]); OsRng.fill_bytes(key.as_mut()); key } /// Derive an X25519 static secret from an ed25519 seed (standard RFC 7748 path). fn ed25519_seed_to_x25519_secret(seed: &[u8; 32]) -> x25519_dalek::StaticSecret { use sha2::{Digest, Sha512}; let h = Sha512::digest(seed.as_ref()); let mut scalar = [0u8; 32]; scalar.copy_from_slice(&h[..32]); // RFC 7748 clamping scalar[0] &= 248; scalar[31] &= 127; scalar[31] |= 64; x25519_dalek::StaticSecret::from(scalar) } /// Parse an OpenSSH ed25519 public key string and return its X25519 form. fn openssh_ed25519_to_x25519_pk(openssh: &str) -> Result { use ssh_key::PublicKey; let pk = PublicKey::from_openssh(openssh.trim()) .map_err(|e| RelicarioError::Format(format!("bad SSH pubkey: {e}")))?; let ed_bytes = pk.key_data().ed25519() .ok_or_else(|| RelicarioError::Format("expected ed25519 key".into()))? .0; let verifying = ed25519_dalek::VerifyingKey::from_bytes(&ed_bytes) .map_err(|e| RelicarioError::Format(format!("bad ed25519 pubkey: {e}")))?; Ok(x25519_dalek::PublicKey::from(verifying.to_montgomery().to_bytes())) } /// Wrap `org_key` for a recipient identified by their OpenSSH ed25519 public key. /// /// Output layout: `ephemeral_x25519_pk(32) || version(1) || nonce(24) || ciphertext+tag` pub fn wrap_org_key(org_key: &Zeroizing<[u8; 32]>, recipient_openssh_pubkey: &str) -> Result> { use sha2::{Digest, Sha256}; use x25519_dalek::EphemeralSecret; let recipient_pk = openssh_ed25519_to_x25519_pk(recipient_openssh_pubkey)?; let ephemeral_sk = EphemeralSecret::random_from_rng(OsRng); let ephemeral_pk = x25519_dalek::PublicKey::from(&ephemeral_sk); let shared = ephemeral_sk.diffie_hellman(&recipient_pk); // Domain-separated KDF. All intermediates carrying the DH secret are held in // Zeroizing so they are wiped on drop (H6). let mut kdf_input: Zeroizing> = Zeroizing::new(Vec::with_capacity(32 + 32 + 32)); kdf_input.extend_from_slice(shared.as_bytes()); kdf_input.extend_from_slice(ephemeral_pk.as_bytes()); kdf_input.extend_from_slice(recipient_pk.as_bytes()); // Copy the digest straight into a Zeroizing array. The GenericArray returned // by Sha256::digest is not Zeroize (generic-array's impl is feature-gated and // not enabled here), so we move the bytes into an owned [u8; 32] whose own // Zeroize impl wipes them on drop. let mut wrap_key: Zeroizing<[u8; 32]> = Zeroizing::new([0u8; 32]); wrap_key.copy_from_slice(&Sha256::digest(kdf_input.as_slice())); let encrypted = crate::crypto::encrypt(&wrap_key, org_key.as_ref())?; let mut out = Vec::with_capacity(32 + encrypted.len()); out.extend_from_slice(ephemeral_pk.as_bytes()); out.extend_from_slice(&encrypted); Ok(out) } /// Unwrap a key blob produced by `wrap_org_key` using the recipient's ed25519 seed. pub fn unwrap_org_key(wrapped: &[u8], ed25519_seed: &Zeroizing<[u8; 32]>) -> Result> { use sha2::{Digest, Sha256}; // Minimum: 32 (ephemeral_pk) + 41 (version+nonce+tag for 32-byte plaintext) if wrapped.len() < 32 + 41 { return Err(RelicarioError::Format("wrapped key blob too short".into())); } let ephemeral_pk = x25519_dalek::PublicKey::from( <[u8; 32]>::try_from(&wrapped[..32]).unwrap() ); let encrypted = &wrapped[32..]; let recipient_sk = ed25519_seed_to_x25519_secret(ed25519_seed.as_ref()); let recipient_pk = x25519_dalek::PublicKey::from(&recipient_sk); let shared = recipient_sk.diffie_hellman(&ephemeral_pk); let mut kdf_input: Zeroizing> = Zeroizing::new(Vec::with_capacity(32 + 32 + 32)); kdf_input.extend_from_slice(shared.as_bytes()); kdf_input.extend_from_slice(ephemeral_pk.as_bytes()); kdf_input.extend_from_slice(recipient_pk.as_bytes()); let mut wrap_key: Zeroizing<[u8; 32]> = Zeroizing::new([0u8; 32]); wrap_key.copy_from_slice(&Sha256::digest(kdf_input.as_slice())); let plaintext = Zeroizing::new(crate::crypto::decrypt(&wrap_key, encrypted)?); if plaintext.len() != 32 { return Err(RelicarioError::Format( format!("unwrapped key has wrong length: {}", plaintext.len()) )); } let mut key = Zeroizing::new([0u8; 32]); key.copy_from_slice(&plaintext); Ok(key) } // ── Tests ───────────────────────────────────────────────────────────────────── #[cfg(test)] mod tests { use super::*; #[test] fn member_id_is_16_hex_chars() { let id = MemberId::new(); assert_eq!(id.0.len(), 16); assert!(id.0.chars().all(|c| c.is_ascii_hexdigit())); } #[test] fn member_ids_are_unique() { let mut seen = std::collections::HashSet::new(); for _ in 0..1_000 { assert!(seen.insert(MemberId::new().0)); } } #[test] fn org_id_is_16_hex_chars() { let id = OrgId::new(); assert_eq!(id.0.len(), 16); assert!(id.0.chars().all(|c| c.is_ascii_hexdigit())); } #[test] fn org_role_can_manage_members() { assert!(OrgRole::Owner.can_manage_members()); assert!(OrgRole::Admin.can_manage_members()); assert!(!OrgRole::Member.can_manage_members()); } #[test] fn collection_slug_validation_rejects_slash() { let mut c = OrgCollections::new(); c.collections.push(CollectionDef { slug: "bad/slug".into(), display_name: "Bad".into(), created_by: MemberId::new(), created_at: 0, }); assert!(c.validate().is_err()); } #[test] fn filter_for_member_restricts_collections() { let mut manifest = OrgManifest::new(); manifest.entries.push(OrgManifestEntry { id: ItemId::new(), r#type: crate::item_types::ItemType::SecureNote, title: "A".into(), tags: vec![], modified: 0, trashed_at: None, collection: "prod".into(), }); manifest.entries.push(OrgManifestEntry { id: ItemId::new(), r#type: crate::item_types::ItemType::SecureNote, title: "B".into(), tags: vec![], modified: 0, trashed_at: None, collection: "dev".into(), }); let member = OrgMember { member_id: MemberId::new(), display_name: "Alice".into(), role: OrgRole::Member, ed25519_pubkey: String::new(), collections: vec!["prod".into()], added_at: 0, added_by: MemberId::new(), }; let filtered = manifest.filter_for_member(&member); assert_eq!(filtered.entries.len(), 1); assert_eq!(filtered.entries[0].collection, "prod"); } #[test] fn generate_org_key_is_32_bytes() { let key = generate_org_key(); assert_eq!(key.len(), 32); } /// Pinned RFC 8032 known-answer vector for the ed25519→X25519 map. The seed /// and expected X25519 public key are from ed25519-dalek's own reference /// test (`tests/x25519.rs`, section 7.1 vector A). The expected value is a /// HARD-CODED LITERAL — NOT recomputed by the production code path — so a /// correlated cross-crate-version regression in the birational map (where /// both our derivation and a naive re-derivation would drift together) is /// still caught. If this test ever fails after a dep bump, the wrap/unwrap /// keyspace changed and every existing `keys/.enc` blob is invalidated. #[test] fn ed25519_to_x25519_pinned_rfc8032_vector() { let seed: [u8; 32] = hex::decode("9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60") .unwrap() .try_into() .unwrap(); // Derive the X25519 *public* key the same way wrap/unwrap derives the // recipient's static secret from a seed. let secret = ed25519_seed_to_x25519_secret(&seed); let public = x25519_dalek::PublicKey::from(&secret); assert_eq!( hex::encode(public.as_bytes()), "d85e07ec22b0ad881537c2f44d662d1a143cf830c57aca4305d85c7a90f6b62e", ); } #[test] fn wrap_unwrap_round_trip() { // Generate an ed25519 keypair to act as the member's device key use ed25519_dalek::SigningKey; let mut seed = [0u8; 32]; OsRng.fill_bytes(&mut seed); let signing_key = SigningKey::from_bytes(&seed); let pubkey_openssh = ssh_key::PrivateKey::from( ssh_key::private::Ed25519Keypair::from(&signing_key), ) .public_key() .to_openssh() .expect("openssh"); let org_key = generate_org_key(); let wrapped = wrap_org_key(&org_key, &pubkey_openssh).expect("wrap"); let seed_zeroizing = Zeroizing::new(seed); let unwrapped = unwrap_org_key(&wrapped, &seed_zeroizing).expect("unwrap"); assert_eq!(*org_key, *unwrapped); } #[test] fn unwrap_with_wrong_seed_fails() { use ed25519_dalek::SigningKey; let mut seed = [0u8; 32]; OsRng.fill_bytes(&mut seed); let signing_key = SigningKey::from_bytes(&seed); let pubkey_openssh = ssh_key::PrivateKey::from( ssh_key::private::Ed25519Keypair::from(&signing_key), ) .public_key() .to_openssh() .expect("openssh"); let org_key = generate_org_key(); let wrapped = wrap_org_key(&org_key, &pubkey_openssh).expect("wrap"); let wrong_seed = Zeroizing::new([0xFFu8; 32]); let result = unwrap_org_key(&wrapped, &wrong_seed); assert!(result.is_err()); } } ``` - [ ] **Step 3: Run tests** ```bash 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: ```rust 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, }; ``` ```bash cargo check -p relicario-core ``` Expected: clean compile. - [ ] **Step 5: Commit** ```bash 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: ```rust #[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"); } ``` ```bash 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): ```rust use crate::org::OrgManifest; pub fn encrypt_org_manifest(manifest: &OrgManifest, org_key: &Zeroizing<[u8; 32]>) -> Result> { 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 { 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: ```rust 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** ```bash cargo test -p relicario-core vault::tests::org_manifest_round_trip cargo test -p relicario-core ``` Expected: all tests pass. - [ ] **Step 4: Commit** ```bash 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`: ```rust 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** ```bash 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** ```bash 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::org` types/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//.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`: ```rust //! 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//.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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 ") } /// Open an org vault: locate the root, read members.json to find the caller's /// member entry (by ed25519 fingerprint), then unwrap their keys/.enc to /// recover the org master key. pub fn open_org_vault(dir_flag: Option<&std::path::Path>) -> Result { 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 { 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> { 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 ` 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)`, and `load_signing_key(name)` are real accessors in `crates/relicario-cli/src/device.rs` (device.rs:32, :27, :95). `relicario_core::fingerprint` is re-exported at core `lib.rs:94`. The `ItemId(..)`/`MemberId(..)` tuple constructors in the tests are valid because both are `pub` tuple structs. If `item_path`/`save_item`/`load_item`/`remove_item`/`ensure_grant` warn 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: ```rust mod org_session; ``` - [ ] **Step 3: Run tests** ```bash cargo test -p relicario-cli org_session 2>&1 | tail -20 ``` Expected: all org_session tests pass. - [ ] **Step 4: Commit** ```bash 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//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.key` and **no** `RELICARIO_DEVICE_KEY` env var. Use the real accessors already in `crates/relicario-cli/src/device.rs`: `current_device() -> Result>` (device.rs:32), `device_dir(name) -> Result` (device.rs:27), `load_signing_key(name) -> Result>` (device.rs:95, returns the OpenSSH private PEM text). The seed-extraction path is verified against `ssh-key` 0.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 in `relicario-core/src/device.rs:64-69`. - [ ] **Step 1: Add `ssh-key` to 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: ```toml ssh-key = { version = "0.6", features = ["ed25519", "std"] } ``` The full `[dependencies]` tail should read: ```toml reqwest = { version = "0.12", features = ["blocking", "json"] } qrcode = { version = "0.14", features = ["svg"] } ssh-key = { version = "0.6", features = ["ed25519", "std"] } ``` > **Why these features:** `ed25519` enables the `Ed25519Keypair`/`Ed25519PrivateKey` accessors and the `key_data().ed25519()` path; `std` enables `PrivateKey::from_openssh` / `PublicKey::to_openssh`. This matches the feature set `relicario-core` already 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** ```bash 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_HOME` to redirect `dirs::config_dir()`; it serializes via a `Mutex` and restores the env afterwards. Requires the `tempfile` dev-dependency (already present in `[dev-dependencies]`). ```rust #[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-dalek` is **not** a dev-dependency of `relicario-cli`. Add it to `crates/relicario-cli/Cargo.toml` under `[dev-dependencies]` (already in the workspace lock at the version `relicario-core` uses, so no new lock entry): > > ```toml > ed25519-dalek = "2" > ``` Run it — it must **fail to compile** now (the two helper fns don't exist yet): ```bash 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()`. ```rust /// 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 { 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> { 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`, and `Zeroizing` are already imported at the top of `device.rs` (lines 13–17). `ssh_key` is referenced by fully-qualified path. `anyhow::anyhow!` / `anyhow::bail!` are used fully-qualified to match the existing style. > **Note on `current_device_seed` overlap:** `org_session.rs` (Task B1) defines its own private `current_device_seed`/`current_device_fingerprint` for 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 to `org_session`. Both reading the same on-disk key is intentional and correct. - [ ] **Step 5: Verify the test goes green + regression** ```bash 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** ```bash 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//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) " ``` --- ## [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** ```bash 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`: ```rust //! `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`: ```rust pub mod org; ``` - [ ] **Step 3: Verify compile** ```bash cargo check -p relicario-cli ``` Expected: clean (`todo!` is fine at compile time). - [ ] **Step 4: Commit** ```bash 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 through `git_command` (helpers.rs:46), which hard-codes `-c commit.gpgsign=false` (helpers.rs:51). Any commit made through `git_run` is **unsigned** — and the pre-receive hook (Dev-C) rejects/cannot-attribute unsigned commits. So `org init` must (1) call `configure_git_signing(dir, &device_name)` (device.rs:133) to set `gpg.format=ssh`, `user.signingkey=`, `commit.gpgsign=true`, and (2) make the bootstrap commit via `org_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`: ```rust 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(); // `--dir` is a subcommand-scoped global on `org` (B14), so it must come // AFTER `org init`, not before it (matches B10's OrgFixture). let out = run(&["org", "init", "--dir", path, "--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`: ```rust 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. `--dir` is a subcommand-scoped global on `org` // (B14), so it comes AFTER `org init` (matches B10's OrgFixture). let init = relicario( cfg.path(), &["org", "init", "--dir", org.path().to_str().unwrap(), "--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 --local` creates the device keys under `$XDG_CONFIG_HOME/relicario/devices//` and sets `current` without contacting Gitea. If the real `device add` surface differs (verify against `main.rs` `DeviceAction` and `commands::device`), adjust the flags — but the assertions (signed HEAD, `commit.gpgsign=true`, `verify-commit` success) are load-bearing and must stay. If `device add` cannot run offline in CI, lay out `devices//signing.{key,pub}` + `current` directly using `relicario_core::device::generate_keypair()` (same layout as Task B2 Step 3). ```bash 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`: ```rust 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 uses `Relicario-Actor: ` (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 audit `TAMPERED` cross-check. - [ ] **Step 4: Verify both tests + build** ```bash 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** ```bash 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`: ```rust #[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); } } ``` ```bash 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`: ```rust 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: OrgRole` is the existing parameter; `OrgRole` derives `Copy`, so `matches!(role, ...)` does not move it. - [ ] **Step 3: Implement remove-member** ```rust 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** ```rust 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 { 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** ```bash 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`: ```rust #[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())); } ``` ```bash cargo test -p relicario-cli commands::org::tests 2>&1 | tail -5 ``` Expected: all pass. - [ ] **Step 2: Implement create-collection** ```rust 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** ```rust 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** ```rust 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** ```bash 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//.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: ```rust #[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); } ``` ```bash cargo test -p relicario-cli commands::org::tests::new_key_differs 2>&1 | tail -5 ``` Expected: PASS. - [ ] **Step 2: Implement rotate-key** ```rust 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 = 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//.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` and `encrypt_item(&Item, &Zeroizing<[u8;32]>) -> Result>` (re-exported at core lib.rs:81–82). `vault.key()` returns the OLD key; `vault.root()` returns `&PathBuf`. The `git add` step stages `staged_paths`, now including every `items//.enc`. - [ ] **Step 3: Build + commit** ```bash 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` (ensure `regex` + `tempfile` in `[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_signer` need `regex` and `tempfile` available to `relicario-cli` at runtime (not just dev). Add to `crates/relicario-cli/Cargo.toml` `[dependencies]` if absent: `regex = "1"` and `tempfile = "3"` (both already in the workspace lock via relicario-server). - [ ] **Step 1: Implement status** ```rust 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: ```rust #[test] fn parse_trailers_extracts_relicario_fields() { // Contract trailer shape: "Relicario-Actor: ". 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); } ``` ```bash 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)** ```rust #[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, pub actor_id: Option, /// Actor id as CLAIMED by the commit trailer (advisory; for tamper-checking). pub trailer_actor_id: Option, pub action: Option, pub collection: Option, pub item_id: Option, pub device_id: Option, /// 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: " " (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>, format: &str, ) -> Result<()> { // Spec surface is `--format ` (default table). Accept only those. let json = match format { "json" => true, "table" => false, other => anyhow::bail!("unknown --format `{other}` — use table or json"), }; 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 = 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 = 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(""), if ev.tampered { "TAMPERED" } else { "" }, ); } } Ok(()) } ``` > **Verified:** mirrors `relicario-server`'s `verify_commit` exactly (temp allowed_signers prefixed `relicario `, `GIT_CONFIG_COUNT/KEY_0/VALUE_0 = gpg.ssh.allowedSignersFile`, `git verify-commit --raw`, regex `key (SHA256:[A-Za-z0-9+/]+)` over stderr, fingerprint match via `relicario_core::fingerprint`). git 2.54.0 supports `%(trailers:only=true,unfold=true)` and `%x1e`/`%x1f`; `%x1e` prefixes each record so `split('\u{1e}')` yields a leading empty element (filtered by the empty-record guard). `%cI` emits strict ISO 8601 with offset. - [ ] **Step 4: Run the trailer test + build** ```bash 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** ```bash 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 duplicate `item_path` definition. This task is the checkpoint that those helpers exist with the exact signatures the item commands (B10–B13) consume. It adds **no new `item_path` definition** — there is exactly ONE, in B1. - [ ] **Step 1: Verify the helper surface exists** ```bash 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`, `load_item(&self, collection_slug, &ItemId) -> Result`, `remove_item(&self, collection_slug, &ItemId) -> Result<()>`, `ensure_grant(member: &OrgMember, slug: &str) -> Result<()>`. ```bash 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/.enc` call sites. - [ ] **Step 2: Compile check** ```bash 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`. ```rust 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}"); } ``` ```bash 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-keygen` on `PATH`. `mod common;` reuses `tests/common/mod.rs` for `Command::cargo_bin`-style ergonomics via `assert_cmd` (already a dev-dependency via `basic_flows.rs`). - [ ] **Step 2: Implement `run_add` and the three item builders in `commands/org.rs`** Append after the existing command fns: ```rust use relicario_core::{Item, ItemCore, ItemId}; /// Item kinds `org add` supports without interactive prompts. pub enum OrgAddKind { Login { title: String, username: Option, url: Option, password: Option, }, SecureNote { title: String, body: String, }, Identity { title: String, full_name: Option, email: Option, phone: Option, }, } fn build_org_item(kind: OrgAddKind, tags: Vec) -> Result { 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) -> 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 the `use relicario_core::{Item, ItemCore, ItemId};` line here. Do not import `encrypt_org_manifest` — `vault.save_manifest` already wraps it. - [ ] **Step 3: Add the `Add` variant to `OrgCommands` and dispatch (extends B14)** In `crates/relicario-cli/src/main.rs`, add to the `OrgCommands` enum: ```rust /// 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`: ```rust #[derive(Subcommand)] pub(crate) enum OrgAddKind { /// A login (username / url / password). Login { #[arg(long)] collection: String, #[arg(long)] title: String, #[arg(long)] username: Option, #[arg(long)] url: Option, #[arg(long)] password: Option, #[arg(long, value_delimiter = ',')] tags: Vec, }, /// A secure note. SecureNote { #[arg(long)] collection: String, #[arg(long)] title: String, #[arg(long)] body: String, #[arg(long, value_delimiter = ',')] tags: Vec, }, /// An identity record. Identity { #[arg(long)] collection: String, #[arg(long)] title: String, #[arg(long)] full_name: Option, #[arg(long)] email: Option, #[arg(long)] phone: Option, #[arg(long, value_delimiter = ',')] tags: Vec, }, } ``` In the `Commands::Org { dir, subcommand }` dispatch block, add an arm to the inner `match subcommand`: ```rust 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` (in `main.rs`, with `collection`/`tags` per-variant) and the handler-side `commands::org::OrgAddKind` (no `collection`/`tags`) are deliberately two distinct enums so the handler stays unaware of clap. Do not merge them. - [ ] **Step 4: Run the guard tests** ```bash 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** ```bash 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`: ```rust 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: ```rust 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> = 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` / `List` variants + dispatch (extends B14)** In `main.rs` `OrgCommands` enum, add: ```rust /// 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: ```rust 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** ```bash 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** ```bash 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//`, upserts the manifest, and commits with `Relicario-Action: item-update`. - [ ] **Step 1: Write a failing integration test (append to `tests/org_items.rs`)** ```rust #[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}"); } ``` ```bash 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`: ```rust pub fn run_edit( dir: &Path, query: &str, title: Option, username: Option, url: Option, password: Option, body: Option, email: Option, phone: Option, full_name: Option, ) -> 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 `Edit` variant + dispatch (extends B14)** In `main.rs` `OrgCommands`: ```rust /// 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, #[arg(long)] username: Option, #[arg(long)] url: Option, #[arg(long)] password: Option, #[arg(long)] body: Option, #[arg(long)] email: Option, #[arg(long)] phone: Option, #[arg(long)] full_name: Option, }, ``` Dispatch arm: ```rust 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** ```bash cargo test -p relicario-cli --test org_items org_edit 2>&1 | tail -20 ``` Expected: PASS. - [ ] **Step 5: Commit** ```bash 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` - Create: `crates/relicario-cli/tests/org_lifecycle.rs` (L6 spec-mandated lifecycle tests) Trash lifecycle mirrors `commands/trash.rs`: `rm` soft-deletes (sets `trashed_at`), `restore` clears it, `purge` permanently `git rm`s 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`)** ```rust #[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}"); } ``` ```bash 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`: ```rust /// 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-delete\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_rm` is `pub` in `crate::helpers` (helpers.rs:93, signature `git_rm(repo: &Path, paths: &[String], context: &str)`) and uses `git rm -rf --ignore-unmatch`, so a missing blob is tolerated. The grant guard runs *before* any deletion. - [ ] **Step 3: Add `Rm` / `Restore` / `Purge` variants + dispatch (extends B14)** In `main.rs` `OrgCommands`: ```rust /// 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: ```rust 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** ```bash 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 4b: Author the spec-required lifecycle integration tests (L6)** The spec's Testing Strategy (CLI section) mandates four scenarios not yet covered by `org_items.rs`. Create `crates/relicario-cli/tests/org_lifecycle.rs` with a self-contained two-device fixture (it must be standalone — integration test files are separate compilation units, so it cannot share `org_items.rs`'s `OrgFixture`). Tests: - (a) a **forged-trailer** commit is flagged `TAMPERED` by `org audit`; - (b) `org audit --format json` is valid JSON with the expected action values; - (c) a **concurrent `rotate-key`** aborts with the exact spec error string; - (d) **`remove-member → rotate-key`** → the removed member's old clone cannot decrypt an item while a remaining member can. ```rust use assert_cmd::cargo::CommandCargoExt as _; use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; use tempfile::TempDir; /// A device home + an org vault. A second device can be wired for multi-member. struct Dev { xdg: PathBuf, _config: TempDir, } impl Dev { fn new(name: &str) -> Self { let config = TempDir::new().unwrap(); let xdg = config.path().to_path_buf(); let devices = xdg.join("relicario").join("devices").join(name); std::fs::create_dir_all(&devices).unwrap(); let keyfile = devices.join("signing.key"); let st = 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!(st.success()); std::fs::rename(devices.join("signing.key.pub"), devices.join("signing.pub")).unwrap(); std::fs::write(xdg.join("relicario").join("devices").join("current"), format!("{name}\n")).unwrap(); Dev { xdg, _config: config } } fn pubkey(&self, name: &str) -> String { std::fs::read_to_string( self.xdg.join("relicario").join("devices").join(name).join("signing.pub"), ).unwrap().trim().to_string() } fn run(&self, vault: &Path, args: &[&str]) -> std::process::Output { let mut cmd = Command::cargo_bin("relicario").unwrap(); cmd.env("XDG_CONFIG_HOME", &self.xdg) .env("RELICARIO_ORG_DIR", vault) .args(args) .stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::piped()); cmd.output().unwrap() } } fn owner_member_id(vault: &Path) -> String { let s = std::fs::read_to_string(vault.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() } /// Set up an org with the owner granted `prod` and one login item in it. fn setup_with_item() -> (Dev, TempDir, String) { let dev = Dev::new("laptop"); let vault = TempDir::new().unwrap(); let v = vault.path(); assert!(dev.run(v, &["org", "init", "--dir", v.to_str().unwrap(), "--name", "Acme"]).status.success()); let owner = owner_member_id(v); assert!(dev.run(v, &["org", "create-collection", "prod", "--name", "Prod"]).status.success()); assert!(dev.run(v, &["org", "grant", &owner, "prod"]).status.success()); assert!(dev.run(v, &[ "org", "add", "login", "--collection", "prod", "--title", "GitHub", "--username", "alice", "--password", "hunter2", ]).status.success()); (dev, vault, owner) } // (b) audit --format json parses + has expected actions. #[test] fn audit_format_json_is_valid_and_has_actions() { let (dev, vault, _owner) = setup_with_item(); let out = dev.run(vault.path(), &["org", "audit", "--format", "json"]); assert!(out.status.success(), "audit json: {}", String::from_utf8_lossy(&out.stderr)); let stdout = String::from_utf8_lossy(&out.stdout); let events: serde_json::Value = serde_json::from_str(&stdout).expect("audit json must parse"); let arr = events.as_array().expect("array"); let actions: Vec<&str> = arr.iter() .filter_map(|e| e["action"].as_str()) .collect(); assert!(actions.contains(&"org-init"), "actions: {actions:?}"); assert!(actions.contains(&"collection-create"), "actions: {actions:?}"); assert!(actions.contains(&"item-create"), "actions: {actions:?}"); // Honest signer attribution: none of these should be TAMPERED (signer == trailer). assert!(arr.iter().all(|e| e["tampered"] == serde_json::Value::Bool(false))); } // (a) a forged-trailer commit is flagged TAMPERED. #[test] fn forged_trailer_commit_is_flagged_tampered() { let (dev, vault, owner) = setup_with_item(); let v = vault.path(); // Hand-craft a SIGNED commit whose trailer CLAIMS a different actor id than // the real signer. We reuse the org repo's own signing config (set by // `org init`), so the commit verifies — but the trailer lies. std::fs::write(v.join("decoy.txt"), "x").unwrap(); let git = |args: &[&str]| { Command::new("git").current_dir(v).args(args) .env("XDG_CONFIG_HOME", &dev.xdg) .output().unwrap() }; assert!(git(&["add", "decoy.txt"]).status.success()); let forged_msg = format!( "forged\n\nRelicario-Actor: impostor ffffffffffffffff\nRelicario-Action: item-update\nRelicario-Member: {owner}" ); // commit -S uses the repo's configured signing key (the real owner key). let c = git(&["commit", "-S", "-m", &forged_msg]); assert!(c.status.success(), "forged commit: {}", String::from_utf8_lossy(&c.stderr)); let out = dev.run(v, &["org", "audit", "--format", "json"]); let events: serde_json::Value = serde_json::from_str(&String::from_utf8_lossy(&out.stdout)).unwrap(); let forged = events.as_array().unwrap().iter() .find(|e| e["action"] == "item-update") .expect("forged item-update event present"); // Trailer claims ffff... but the verified signer is the owner → TAMPERED. assert_eq!(forged["tampered"], serde_json::Value::Bool(true)); assert_eq!(forged["actor_id"].as_str(), Some(owner.as_str())); } // (c) concurrent rotate-key aborts with the exact spec error string. #[test] fn concurrent_rotate_key_aborts_with_spec_string() { let (dev, vault, _owner) = setup_with_item(); let origin = TempDir::new().unwrap(); let v = vault.path(); let git = |args: &[&str]| Command::new("git").current_dir(v).args(args) .env("XDG_CONFIG_HOME", &dev.xdg).output().unwrap(); // Make a bare origin and push, so a divergent upstream can be simulated. assert!(Command::new("git").args(["init", "--bare", origin.path().to_str().unwrap()]) .output().unwrap().status.success()); assert!(git(&["remote", "add", "origin", origin.path().to_str().unwrap()]).status.success()); assert!(git(&["push", "-u", "origin", "HEAD"]).status.success()); // Diverge upstream: a second clone commits + pushes a non-fast-forward. let clone2 = TempDir::new().unwrap(); assert!(Command::new("git") .args(["clone", origin.path().to_str().unwrap(), clone2.path().to_str().unwrap()]) .output().unwrap().status.success()); std::fs::write(clone2.path().join("upstream.txt"), "u").unwrap(); for a in [&["add", "upstream.txt"][..], &["-c", "user.email=u@u", "-c", "user.name=u", "commit", "-m", "upstream"][..], &["push", "origin", "HEAD:master"][..], &["push", "origin", "HEAD:main"][..]] { let _ = Command::new("git").current_dir(clone2.path()).args(a).output(); } // Local also makes a commit so the histories truly diverge. std::fs::write(v.join("local.txt"), "l").unwrap(); assert!(git(&["add", "local.txt"]).status.success()); assert!(git(&["-c", "commit.gpgsign=false", "commit", "-m", "local"]).status.success()); let out = dev.run(v, &["org", "rotate-key"]); let stderr = String::from_utf8_lossy(&out.stderr); assert!(!out.status.success(), "rotate-key should abort on a concurrent rotation"); assert!( stderr.contains("Concurrent key rotation detected — pull and re-run org rotate-key."), "missing spec error string: {stderr}" ); } // (d) remove-member → rotate-key → old clone cannot decrypt; remaining member can. #[test] fn removed_member_clone_cannot_decrypt_after_rotation() { // Owner laptop sets up the org + a second member "bob". let (owner_dev, vault, _owner) = setup_with_item(); let v = vault.path(); let bob = Dev::new("bob-laptop"); let bob_pub = bob.pubkey("bob-laptop"); // Owner adds Bob and grants him prod. assert!(owner_dev.run(v, &["org", "add-member", "--key", &bob_pub, "--name", "Bob", "--role", "member"]).status.success()); let members = std::fs::read_to_string(v.join("members.json")).unwrap(); let mv: serde_json::Value = serde_json::from_str(&members).unwrap(); let bob_id = mv["members"].as_array().unwrap().iter() .find(|m| m["display_name"] == "Bob").unwrap()["member_id"].as_str().unwrap().to_string(); assert!(owner_dev.run(v, &["org", "grant", &bob_id, "prod"]).status.success()); // Bob clones the vault dir (his device, his key blob is present). let bob_clone = TempDir::new().unwrap(); let cp = Command::new("cp").args(["-r", v.to_str().unwrap(), bob_clone.path().to_str().unwrap()]).output().unwrap(); assert!(cp.status.success()); let bob_vault = bob_clone.path(); // Bob can read the item BEFORE removal. let pre = bob.run(bob_vault, &["org", "get", "GitHub", "--show"]); assert!(String::from_utf8_lossy(&pre.stdout).contains("hunter2"), "bob should read pre-removal"); // Owner removes Bob and rotates the key in the live vault. assert!(owner_dev.run(v, &["org", "remove-member", &bob_id]).status.success()); assert!(owner_dev.run(v, &["org", "rotate-key"]).status.success()); // Owner (remaining member) can still decrypt in the live vault. let owner_get = owner_dev.run(v, &["org", "get", "GitHub", "--show"]); assert!(String::from_utf8_lossy(&owner_get.stdout).contains("hunter2"), "owner must still read"); // Copy the rotated item + manifest into Bob's stale clone (simulating a // pull) — his OLD key blob can no longer unwrap the rotated org key. let _ = Command::new("cp").args(["-r", v.join("items").to_str().unwrap(), bob_vault.to_str().unwrap()]).output(); let _ = std::fs::copy(v.join("manifest.enc"), bob_vault.join("manifest.enc")); let post = bob.run(bob_vault, &["org", "get", "GitHub", "--show"]); assert!(!post.status.success() || !String::from_utf8_lossy(&post.stdout).contains("hunter2"), "removed member must NOT decrypt post-rotation: {}", String::from_utf8_lossy(&post.stdout)); } ``` ```bash cargo test -p relicario-cli --test org_lifecycle 2>&1 | tail -30 ``` Expected: all four lifecycle tests pass. Requires `ssh-keygen` + a signing-capable `git` on PATH. - [ ] **Step 5: Commit** ```bash git add crates/relicario-cli/src/commands/org.rs crates/relicario-cli/src/main.rs crates/relicario-cli/tests/org_items.rs crates/relicario-cli/tests/org_lifecycle.rs git commit -m "feat(cli/org): org rm/restore/purge trash lifecycle (collection-scoped)" ``` > **Follow-up note (not a task):** `org add`/`org edit` cover Login, SecureNote, and Identity (the non-interactive builders). Card / Key / Document / Totp parity is deferred because those builders read secrets via `rpassword`/stdin; wiring them needs `--*-stdin` flags 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** ```bash grep -n "Subcommand\|enum Commands\|match cli.command" crates/relicario-cli/src/main.rs | head -40 ``` - [ ] **Step 2: Add the `Org` arm to the `Commands` enum** ```rust /// Manage a multi-user org vault. Org { /// Path to the org vault directory (overrides RELICARIO_ORG_DIR). #[arg(long, global = true)] dir: Option, #[command(subcommand)] subcommand: OrgCommands, }, ``` - [ ] **Step 3: Add the `OrgCommands` enum (admin variants + lifecycle stubs)** The item variants (`Add`/`Get`/`List`/`Edit`/`Rm`/`Restore`/`Purge`) are added by B10–B13. The admin + lifecycle variants: ```rust #[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). By default the caller /// is demoted to admin; pass --keep-owner for explicit co-ownership. TransferOwnership { member_id: String, /// Keep the caller as an owner too (co-ownership) instead of demoting. #[arg(long)] keep_owner: bool, }, /// 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, #[arg(long)] member: Option, #[arg(long)] collection: Option, #[arg(long)] action: Option, /// Output format: `table` (default) or `json`. #[arg(long, default_value = "table")] format: String, }, // 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_role` helper** ```rust Commands::Org { dir, subcommand } => { let dir_path = dir.as_deref(); match subcommand { OrgCommands::Init { name } => { // Resolve via org_dir so `org init` honors RELICARIO_ORG_DIR too // (while still accepting --dir), like every other org arm (L1). let d = crate::org_session::org_dir(dir_path)?; 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, keep_owner } => { let d = crate::org_session::org_dir(dir_path)?; commands::org::run_transfer_ownership(&d, &member_id, keep_owner)?; } 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, format } => { 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(), &format)?; } // Item dispatch arms (Add/Get/List/Edit/Rm/Restore/Purge) added by // Tasks B10–B13. } } ``` ```rust fn parse_org_role(s: &str) -> anyhow::Result { 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_ownership` and `run_delete_org` in `commands/org.rs`** The spec lists both under Admin Operations (owner-only). `transfer-ownership` is a **real transfer**: it promotes the target to Owner AND demotes the caller to Admin, unless `--keep-owner` is passed (explicit co-ownership, leaving the caller an owner too). `delete-org` requires `--confirm` and removes the working tree's org files (a LOCAL tombstone — see the tracked gap below). Minimal correct implementations: ```rust pub fn run_transfer_ownership(dir: &Path, member_id_prefix: &str, keep_owner: 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 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"); } // Promote the target to Owner. { let target = members.find_by_id_mut(&target_id) .ok_or_else(|| anyhow::anyhow!("member not found"))?; target.role = OrgRole::Owner; } // Real transfer: also demote the CALLER to Admin, unless --keep-owner was // passed (explicit co-ownership). The spec says "owner → another member", // so demotion is the default. if !keep_owner { if let Some(me) = members.find_by_id_mut(&caller.member_id) { me.role = OrgRole::Admin; } } vault.save_members(&members)?; let mode = if keep_owner { "co-ownership (caller kept owner)" } else { "caller demoted to admin" }; let commit_msg = format!( "org: transfer ownership to {} ({mode})\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")?; if keep_owner { println!("Ownership shared with {} (you remain an owner).", target_id.as_str()); } else { println!("Ownership transferred to {} (you are now an admin).", 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_monotonicity` forbids deleting protected JSON). This is an acknowledged interaction: for phase 1, `delete-org` is a **local** operation; pushing a delete to a hook-protected remote is out of scope and tracked as a follow-up. `transfer-ownership` is fully hook-compatible (it only mutates `members.json` roles, signed by the owner). - [ ] **Step 6: Build and test help output** ```bash 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** ```bash 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 ensuring `tempfile` is a runtime dependency. > > **Dependency on the corrected item layout:** `classify_path` assumes `items//.enc`. It must match `UnlockedOrgVault::item_path(collection_slug, id)` from Task B1. If item commits ever used a flat `items/.enc`, every item commit would be `Rejected`. ## [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: 1. **Every** commit must carry a GOOD signature whose fingerprint maps to a current member. Unsigned / unknown-signer → REJECT. 2. **Protected paths** (`members.json`, `collections.json`, `org.json`): signer must have `role.can_manage_members()` (Owner or Admin). 3. **Item writes** `items//.enc`: the leading path segment `` must appear in the signing member's `collections` grant list. (Owners/Admins are **not** auto-granted — grants are explicit.) Additionally (**L5**), `` must exist in `collections.json` as of the commit — a write into a granted-but-deleted collection is rejected. 4. **Owner-only elevation** (`members.json`): only an Owner may introduce a new owner/admin or elevate an existing member to owner/admin. `can_manage_members()` (Owner OR Admin) is sufficient to *write* members.json, but minting/elevating owner/admin requires `can_manage_owners()` (Owner). (**H-C1**) 5. Schema-version monotonicity for the three JSON files (Task C2). 6. Root commit (no parent) → inspect full tree, allow as genesis. Merge commit (>1 parent) → REJECT. - [ ] **Step 1: Ensure `tempfile` is 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: ```toml tempfile = "3" ``` ```bash 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`: ```rust // 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//.enc".to_string()) ); } #[test] fn key_blobs_and_manifest_are_unrestricted() { // keys/.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/.enc (the OLD, now-removed layout) is no longer valid. assert_eq!( classify_path("items/a1b2c3d4e5f6a1b2.enc"), PathClass::Rejected("items path must be items//.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()) ); } ``` ```bash 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`: ```rust //! 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//.enc` — writer must hold a grant for ``. Item { collection: String }, /// `keys/.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: /.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//.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: ```toml [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): ```rust use relicario_server::{classify_path, PathClass}; ``` - [ ] **Step 4: Run the path-classification tests** ```bash cargo test -p relicario-server --test org_hook 2>&1 | tail -20 ``` Expected: all six `org_hook` tests pass. - [ ] **Step 5: Add the `VerifyOrgCommit` / `GenerateOrgHook` subcommands** In `crates/relicario-server/src/main.rs`, add to the `Commands` enum (after `GenerateHook`): ```rust /// 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()`: ```rust Commands::VerifyOrgCommit { commit } => verify_org_commit(&commit), Commands::GenerateOrgHook => generate_org_hook(), ``` - [ ] **Step 6: Implement the signer-resolution helper (mirrors `verify_commit`)** ```rust use relicario_core::org::{OrgCollections, 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_monotonicity` is 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. ```rust 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 = 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. // collections.json (as of this commit) is loaded lazily on the first item // path, for the L5 slug-existence check. let mut collection_slugs: Option> = None; 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); } // Privilege-escalation gate: only an Owner may INTRODUCE or // ELEVATE an owner/admin. An Admin may write members.json but // must not mint owners/admins server-side (spec §148/158/271). if path == "members.json" { enforce_owner_only_elevation(commit, is_root, &members, &signer); } } PathClass::Item { collection } => { // The signing member must hold an explicit grant for the slug. 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); } // Slug-existence (L5): the collection must exist in // collections.json AS OF THIS COMMIT. A write into a // granted-but-deleted (or never-created) collection is rejected. let known = collection_slugs.get_or_insert_with(|| { git_show(commit, "collections.json") .ok() .and_then(|s| serde_json::from_str::(&s).ok()) .map(|c| c.collections.into_iter().map(|d| d.slug).collect::>()) .unwrap_or_default() }); if !known.iter().any(|s| s == &collection) { eprintln!( "REJECT: org commit {commit} — item write to collection `{collection}` whose slug is absent from collections.json (path `{path}`)" ); std::process::exit(1); } } PathClass::Unrestricted => { // keys/.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(()) } /// Reject the commit unless every newly-introduced or elevated owner/admin is /// authorized: `can_manage_members()` alone is insufficient — an Admin must NOT /// be able to mint owners/admins server-side; only an Owner may. We diff the /// new members.json (already loaded) against the parent's members.json by /// member_id and flag any member that becomes Owner/Admin (new entry, or a /// role elevated up to Owner/Admin). On genesis (root), the sole bootstrap /// owner the commit introduces is allowed (it has no parent baseline). /// /// `git_show_parent` is defined in Task C2 (same file, same crate). fn enforce_owner_only_elevation( commit: &str, is_root: bool, new_members: &OrgMembers, signer: &OrgMember, ) { use relicario_core::org::OrgRole; let is_privileged = |r: OrgRole| matches!(r, OrgRole::Owner | OrgRole::Admin); // Genesis: the bootstrap commit introduces the sole owner; allow it. if is_root { return; } // Parent baseline. If members.json did not exist in the parent, every // privileged member here is "new" and must be owner-signed. let parent_members: Vec<(String, OrgRole)> = match git_show_parent(commit, "members.json") { Ok(s) => serde_json::from_str::(&s) .map(|m| { m.members .into_iter() .map(|m| (m.member_id.0, m.role)) .collect() }) .unwrap_or_default(), Err(_) => Vec::new(), }; let parent_role = |id: &str| -> Option { parent_members.iter().find(|(mid, _)| mid == id).map(|(_, r)| *r) }; for m in &new_members.members { if !is_privileged(m.role) { continue; } // Privileged now. Was it already privileged in the parent (no change)? let already = parent_role(m.member_id.as_str()) .map(is_privileged) .unwrap_or(false); if already { continue; // not an introduction or elevation } // A new owner/admin, or a member elevated to owner/admin → owner-only. if !signer.role.can_manage_owners() { eprintln!( "REJECT: org commit {commit} — member '{}' (role {:?}) may not introduce or \ elevate owner/admin '{}' to {:?}; only an owner may", signer.display_name, signer.role, m.display_name, m.role ); std::process::exit(1); } } } ``` - [ ] **Step 8: Implement `generate_org_hook`** ```rust 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_monotonicity` to the same file. Proceed to C2, then return. ```bash 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` - Create: `crates/relicario-server/tests/org_hook_signed.rs` (H-C1 owner-only-elevation signed-commit test) 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`: ```rust 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()); } ``` ```bash cargo test -p relicario-server --test org_hook 2>&1 | tail -10 ``` Expected: FAIL — `extract_schema_version` not defined. - [ ] **Step 2: Implement `extract_schema_version` in the lib** Add to `crates/relicario-server/src/lib.rs`: ```rust /// 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 { 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. ```bash 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_monotonicity` in main.rs** ```rust 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::(&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 { 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 3b: Add the signed-commit hook integration test (H-C1: owner-only elevation)** Create `crates/relicario-server/tests/org_hook_signed.rs`. This drives the full `verify-org-commit` against a real ssh-signed org repo, asserting the escalation gate: **an admin self-promoting to owner is REJECTED; an owner promoting an admin is ACCEPTED.** It reuses the signing/git helpers from the existing `verify_commit.rs` pattern. ```rust //! Integration tests for `relicario-server verify-org-commit` privilege gating. //! //! H-C1: only an Owner may introduce or elevate an owner/admin. An Admin who //! writes members.json must not be able to mint owners/admins. use std::fs; use std::path::{Path, PathBuf}; use std::process::Command; use assert_cmd::Command as AssertCommand; use predicates::prelude::*; use relicario_core::device::generate_keypair; use tempfile::TempDir; fn write_keypair(dir: &Path, name: &str) -> (PathBuf, String) { let (priv_pem, pub_line) = generate_keypair().expect("generate keypair"); let priv_path = dir.join(format!("{name}.key")); fs::write(&priv_path, priv_pem.as_str()).unwrap(); #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; fs::set_permissions(&priv_path, fs::Permissions::from_mode(0o600)).unwrap(); } (priv_path, pub_line) } fn git(repo: &Path, args: &[&str]) { let status = Command::new("git").current_dir(repo).args(args).status().unwrap(); assert!(status.success(), "git {args:?} failed"); } /// members.json content with two members; `member_id`s are fixed 16-hex. fn members_json(owner_pub: &str, admin_pub: &str, admin_role: &str) -> String { format!( r#"{{ "schema_version": 1, "members": [ {{ "member_id": "1111111111111111", "display_name": "Owner", "role": "owner", "ed25519_pubkey": "{}", "collections": [], "added_at": 0, "added_by": "1111111111111111" }}, {{ "member_id": "2222222222222222", "display_name": "Admin", "role": "{admin_role}", "ed25519_pubkey": "{}", "collections": [], "added_at": 0, "added_by": "1111111111111111" }} ] }}"#, owner_pub.trim(), admin_pub.trim() ) } /// Stage members.json, sign the commit with `signing_key`, return its SHA. fn signed_members_commit( repo: &Path, signing_key: &Path, allowed: &Path, msg: &str, content: &str, ) -> String { fs::write(repo.join("members.json"), content).unwrap(); git(repo, &["add", "members.json"]); let status = Command::new("git") .current_dir(repo) .args([ "-c", "gpg.format=ssh", "-c", &format!("user.signingkey={}", signing_key.display()), "-c", &format!("gpg.ssh.allowedSignersFile={}", allowed.display()), "commit", "-S", "-q", "-m", msg, ]) .status() .unwrap(); assert!(status.success()); let out = Command::new("git").current_dir(repo).args(["rev-parse", "HEAD"]).output().unwrap(); String::from_utf8(out.stdout).unwrap().trim().to_string() } /// Set up an org repo whose root commit (signed by the owner) registers an /// owner + an admin. Returns (repo tmp, owner priv, admin priv, allowed file). fn bootstrap() -> (TempDir, PathBuf, PathBuf, PathBuf) { let tmp = TempDir::new().unwrap(); let repo = tmp.path(); git(repo, &["init", "-q", "-b", "main"]); git(repo, &["config", "user.email", "t@t"]); git(repo, &["config", "user.name", "t"]); let (owner_priv, owner_pub) = write_keypair(repo, "owner"); let (admin_priv, admin_pub) = write_keypair(repo, "admin"); let allowed = repo.join("allowed_signers"); fs::write( &allowed, format!("relicario {}\nrelicario {}\n", owner_pub.trim(), admin_pub.trim()), ) .unwrap(); // Genesis: owner registers both members (admin starts as `admin`). let genesis = members_json(&owner_pub, &admin_pub, "admin"); signed_members_commit(repo, &owner_priv, &allowed, "org-init", &genesis); // also write org.json + collections.json so later commits are well-formed fs::write(repo.join("org.json"), r#"{"schema_version":1,"org_id":"abc0abc0abc0abc0","display_name":"Acme","created_at":0}"#).unwrap(); fs::write(repo.join("collections.json"), r#"{"schema_version":1,"collections":[]}"#).unwrap(); git(repo, &["add", "org.json", "collections.json"]); // sign this housekeeping commit with the owner too let _ = signed_members_commit(repo, &owner_priv, &allowed, "scaffold", &members_json(&owner_pub, &admin_pub, "admin")); (tmp, owner_priv, admin_priv, allowed) } #[test] fn admin_self_promote_to_owner_is_rejected() { let (tmp, owner_priv, admin_priv, allowed) = bootstrap(); let repo = tmp.path(); let owner_pub = fs::read_to_string(repo.join("allowed_signers")).unwrap(); // Reconstruct pubkeys from the allowed_signers file (two "relicario " lines). let lines: Vec = owner_pub.lines() .map(|l| l.trim_start_matches("relicario ").to_string()).collect(); let (op, ap) = (lines[0].clone(), lines[1].clone()); let _ = owner_priv; // Admin signs a members.json that elevates THEMSELVES to owner. let escalated = members_json(&op, &ap, "owner"); let sha = signed_members_commit(repo, &admin_priv, &allowed, "self-promote", &escalated); AssertCommand::cargo_bin("relicario-server") .unwrap() .current_dir(repo) .args(["verify-org-commit", &sha]) .assert() .failure() .stderr(predicate::str::contains("only an owner")); } #[test] fn owner_promoting_an_admin_is_accepted() { let (tmp, owner_priv, _admin_priv, allowed) = bootstrap(); let repo = tmp.path(); let allowed_body = fs::read_to_string(repo.join("allowed_signers")).unwrap(); let lines: Vec = allowed_body.lines() .map(|l| l.trim_start_matches("relicario ").to_string()).collect(); let (op, ap) = (lines[0].clone(), lines[1].clone()); // Owner signs a members.json that elevates the admin to owner — allowed. let promoted = members_json(&op, &ap, "owner"); let sha = signed_members_commit(repo, &owner_priv, &allowed, "promote-admin", &promoted); AssertCommand::cargo_bin("relicario-server") .unwrap() .current_dir(repo) .args(["verify-org-commit", &sha]) .assert() .success(); } ``` ```bash cargo test -p relicario-server --test org_hook_signed 2>&1 | tail -20 ``` Expected: both tests pass once the full hook (C1 + C2) is built. Requires `git` with ssh-signing support on PATH. - [ ] **Step 4: Build the whole crate, run all server tests, then commit** ```bash cargo build -p relicario-server 2>&1 | tail -15 cargo test -p relicario-server 2>&1 | tail -25 ``` Expected: clean build; all `org_hook` + `org_hook_signed` tests pass; existing `verify_commit` tests still pass. ```bash 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. ```bash 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 crates/relicario-server/tests/org_hook_signed.rs git commit -m "feat(server): verify-org-commit — signature + path-scoped role/grant auth + owner-only elevation + 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/src/device.rs` (add the `unwrap_org_key` accessor reading DEVICE_STATE) - Modify: `crates/relicario-wasm/Cargo.toml` (add `ssh-key`; `base64` already present) - 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: ```bash 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>` in `org.rs`; `decrypt_org_manifest(&[u8], &Zeroizing<[u8;32]>) -> Result` in `vault.rs`; `decrypt_item(&[u8], &Zeroizing<[u8;32]>) -> Result` re-exported. If any signature differs, STOP and reconcile. - [ ] **Step 2: Confirm/add the `base64` and `ssh-key` dependencies** ```bash grep -n "^base64\|^ssh-key" crates/relicario-wasm/Cargo.toml ``` `base64` is already a dependency of `relicario-wasm` (`base64 = "0.22"`); confirm it matches the workspace lock (`grep -A1 'name = "base64"' Cargo.lock` → `0.22.1`) and do **not** add a second version. `ssh-key` is **NOT** currently a dependency of `relicario-wasm` (verified: no `ssh-key` line in `crates/relicario-wasm/Cargo.toml`). The new `org_open_with_registered_device` binding parses the device PEM held in WASM `DEVICE_STATE` via `ssh_key::PrivateKey::from_openssh`, so add under `[dependencies]` in `crates/relicario-wasm/Cargo.toml`: ```toml ssh-key = { version = "0.6", features = ["ed25519", "std"] } ``` `ssh-key` is already in the workspace lock at **0.6.7** (pulled in by `relicario-core`), and the `ed25519` + `std` feature set matches what core already relies on, so resolution stays at 0.6.7 — no new lockfile entry. Confirm: ```bash cargo tree -p relicario-wasm -i ssh-key 2>&1 | head -3 grep -A1 'name = "ssh-key"' Cargo.lock | head -3 ``` Expected: `ssh-key v0.6.7` for `relicario-wasm`; `Cargo.lock` still pins a single `version = "0.6.7"`. - [ ] **Step 3: Add a failing wasm-bindgen-test for the round trip** The org key is unwrapped INSIDE WASM using the device seed held in `DEVICE_STATE` (the same in-memory state `sign_for_git` reads). The device private key NEVER crosses to JS, so the test registers a device via `register_device(...)` first, then drives `org_open_handle(&wrapped)` (which reads `DEVICE_STATE` internally). Add to the `#[cfg(test)] mod tests` block near the bottom of `crates/relicario-wasm/src/lib.rs`: ```rust #[test] fn org_open_unwraps_into_a_handle_and_decrypts_manifest() { use zeroize::Zeroizing; // Register a device in DEVICE_STATE — this is the only place the private // key lives, and org_open reads it from there (never from JS). device::clear_device(); let (signing_pub, _deploy_pub) = device::register_device("test-dev").unwrap(); // Org key wrapped to that device's public key, 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, &signing_pub).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(); // Exercise the internal helper the wasm-bindgen export wraps. It unwraps // using the registered DEVICE_STATE seed — no JS-side private key. let handle = org_open_handle(&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); device::clear_device(); let _ = Zeroizing::new([0u8; 0]); } #[test] fn org_open_without_registered_device_errors() { device::clear_device(); let org_key = relicario_core::generate_org_key(); // We need *some* wrapped blob; wrap to a throwaway device, then clear state. let (pub_key, _d) = device::register_device("throwaway").unwrap(); let wrapped = relicario_core::wrap_org_key(&org_key, &pub_key).unwrap(); device::clear_device(); assert!(org_open_handle(&wrapped).is_err()); } ``` ```bash cargo test -p relicario-wasm org_open 2>&1 | tail -8 ``` Expected: FAIL — `org_open_handle` undefined. - [ ] **Step 4: Add the `unwrap_org_key` accessor to the WASM device module** The device private key lives ONLY in `DEVICE_STATE` (a `Zeroizing` OpenSSH PEM) inside WASM linear memory; `sign_for_git` is the existing reader. Add a sibling that unwraps an org-key blob using that same in-memory seed and returns the org master key — the seed never leaves WASM. Append to `crates/relicario-wasm/src/device.rs`: ```rust use relicario_core::unwrap_org_key as core_unwrap_org_key; /// Unwrap an org-key blob (`keys/.enc`) using the registered /// device's ed25519 seed, held in DEVICE_STATE. The seed never crosses to JS. /// Errors if no device has been registered in this session. pub fn unwrap_org_key(wrapped: &[u8]) -> Result, String> { let guard = DEVICE_STATE.lock().unwrap(); let state = guard .as_ref() .ok_or_else(|| "no device registered".to_string())?; // Extract the 32-byte ed25519 seed from the stored OpenSSH private PEM, // mirroring relicario-core's sign() / current_device_seed parsing chain. let private = ssh_key::PrivateKey::from_openssh(state.signing_private.as_str()) .map_err(|e| format!("parse device key: {e}"))?; let ed = private .key_data() .ed25519() .ok_or_else(|| "device key is not ed25519".to_string())?; let seed_slice: &[u8] = ed.private.as_ref(); if seed_slice.len() != 32 { return Err("ed25519 seed has wrong length".to_string()); } let mut seed = Zeroizing::new([0u8; 32]); seed.copy_from_slice(seed_slice); core_unwrap_org_key(wrapped, &seed).map_err(|e| e.to_string()) } ``` > **DEVICE_STATE is session-only (explicit prerequisite, not an assumption):** > `DEVICE_STATE` is in-memory and is populated by `register_device(...)`; it is > cleared by `clear_device()` and on SW eviction. There is NO persisted device > private key. Therefore **org-open requires the device to be registered/unlocked > in the current SW session** — the same lifecycle as a personal-vault unlock. The > SW must `register_device` (or restore device state) before `org_open` can > succeed; this is a stated precondition wired in D2, not a silent assumption. - [ ] **Step 5: Implement the lib bindings** Add to `crates/relicario-wasm/src/lib.rs`: ```rust /// Internal: unwrap the org-key blob using the registered device seed held in /// WASM DEVICE_STATE, then insert the org key into the session table. Returns /// the raw u32 handle. The device seed and org key never cross to JS. fn org_open_handle(wrapped_key: &[u8]) -> Result { use zeroize::Zeroizing; let org_key = device::unwrap_org_key(wrapped_key) .map_err(|e| JsError::new(&format!("org open: {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 using the REGISTERED device key /// (held in WASM DEVICE_STATE) and return an opaque SessionHandle bound to the /// org master key. The device private key never crosses to JS, and the org key /// stays inside WASM linear memory — JS receives only the handle. Requires a /// device to have been registered in this session (see clear/register lifecycle). #[wasm_bindgen] pub fn org_open_with_registered_device(wrapped_key: &[u8]) -> Result { let handle = org_open_handle(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 { 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 { 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:** > - `SessionHandle` is a tuple struct `SessionHandle(u32)` and `need_key`, `session::with`, `session::insert`, `js_value_for` already exist in this file. `SessionHandle.0` is accessible because the binding code is in the same module. > - `lock(&SessionHandle)` already removes the session entry, so the existing JS `wasm.lock(handle)` path zeroes the org key too — no new teardown export needed. > - `ssh-key` is newly added in Step 2 (it was NOT previously a dependency of `relicario-wasm`). `ed25519_dalek`, `base64`, `zeroize`, `relicario_core` are already dependencies. - [ ] **Step 6: Run the unit tests** ```bash cargo test -p relicario-wasm org_open 2>&1 | tail -8 ``` Expected: both `org_open_*` tests PASS. - [ ] **Step 7: Rebuild the WASM artifact the extension consumes** ```bash cd crates/relicario-wasm && wasm-pack build --target web --out-dir ../../extension/wasm 2>&1 | tail -15 ``` Then confirm the new exports landed: ```bash grep -n "export function org_open_with_registered_device\|export function org_manifest_decrypt\|export function org_item_decrypt" \ extension/wasm/relicario_wasm.d.ts ``` Expected: all three present. - [ ] **Step 8: Commit** ```bash git add crates/relicario-wasm/src/lib.rs crates/relicario-wasm/src/device.rs crates/relicario-wasm/Cargo.toml extension/wasm/ git commit -m "feat(wasm/org): org_open_with_registered_device + org_manifest/item_decrypt bindings (seed stays in WASM)" ``` --- ## [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.local` under key `orgConfigs`: an array `OrgConfigEntry[]`. - The unwrapped org master key lives **only** as a `SessionHandle` in a module-scope variable in `org.ts` — **never** written to `chrome.storage.local` or IndexedDB. - Offline → read-only is derived, not stored: any `git.readFile` rejection while an org session is open flips an in-memory `readOnly` flag. - [ ] **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): ```typescript | { 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: ```typescript export interface OrgSummary { orgId: string; label: string; } export interface ListOrgsResponse extends Extract { data: { orgs: OrgSummary[]; activeOrgId: string | null }; } export interface OpenOrgResponse extends Extract { data: { orgId: string; label: string; readOnly: boolean }; } export interface GetOrgItemsResponse extends Extract { 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 { data: { item: Item; readOnly: boolean }; } ``` Add the four types to `POPUP_ONLY_TYPES`: ```typescript '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`: ```typescript /// 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/.enc". wrappedKeyPath: string; } interface ActiveOrgSession { orgId: string; label: string; handle: SessionHandle; git: GitHost; readOnly: boolean; /// This device's member id (the leading `` of `keys/.enc`, /// i.e. the SAME identity used to select the wrapped-key blob). Used to /// resolve the active member in members.json for grant-filtering. memberId: string; /// Collection slugs this member is granted, resolved from members.json at /// open time. getOrgItems/getOrgItem filter the manifest to these — the TS /// equivalent of core `OrgManifest::filter_for_member`. grants: string[]; } interface OrgMemberJson { member_id: string; display_name: string; role: string; ed25519_pubkey: string; collections?: string[]; } interface OrgMembersJson { schema_version: number; members: OrgMemberJson[]; } /// Extract `` from a `keys/.enc` wrapped-key path. function memberIdFromKeyPath(wrappedKeyPath: string): string { const base = wrappedKeyPath.split('/').pop() ?? ''; return base.replace(/\.enc$/, ''); } // 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 { 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; } } /// Open (unlock) an org by id: build its GitHost, fetch this device's wrapped /// key blob, and unwrap into an org SessionHandle via WASM. /// /// The device private key is NOT read from JS storage — it lives only inside /// WASM DEVICE_STATE (populated by register_device at unlock time). The binding /// `org_open_with_registered_device(wrapped)` unwraps the org key using that /// in-memory seed without ever exposing it. PRECONDITION: a device must be /// registered in this SW session (same lifecycle as personal-vault unlock); /// the WASM binding returns a `device_not_registered`-style error otherwise. 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 git = createGitHost(cfg.hostType, cfg.hostUrl, cfg.repoPath, cfg.apiToken); const readOnly = false; let wrapped: Uint8Array; try { wrapped = await git.readFile(cfg.wrappedKeyPath); } catch { throw new Error('org_offline'); } // Resolve this device's member entry + grants from members.json (public, // unencrypted). The member id is the SAME identity used to pick the wrapped // key blob (the `` segment of wrappedKeyPath). const memberId = memberIdFromKeyPath(cfg.wrappedKeyPath); let grants: string[] = []; try { const membersCt = await git.readFile('members.json'); const membersJson = JSON.parse( new TextDecoder().decode(membersCt), ) as OrgMembersJson; const me = (membersJson.members ?? []).find((m) => m.member_id === memberId); grants = me?.collections ?? []; } catch { throw new Error('org_offline'); } // Unwrap inside WASM using the registered device seed (never crosses to JS). const handle = w.org_open_with_registered_device(wrapped) as SessionHandle; clearActiveOrg(); active = { orgId: cfg.orgId, label: cfg.label, handle, git, readOnly, memberId, grants }; 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; // Grant-filter FIRST: a member only ever sees entries in their granted // collections (the TS mirror of core OrgManifest::filter_for_member). const granted = new Set(active.grants); const visible = (manifest.entries ?? []).filter((e) => granted.has(e.collection)); const all = visible.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//.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'); // Enforce the grant on read: refuse items in collections this member is not // granted, even if the id is known (mirrors core filter_for_member + the CLI // ensure_grant defense-in-depth). if (!active.grants.includes(entry.collection)) 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: ```typescript import * as org from './org'; ``` Inside `initWasm()`, right after `vault.setWasm(wasmBindings);`: ```typescript org.setWasm(wasmBindings); ``` And in the `sessionTimer.onExpired(...)` callback (after `state.gitHost = null;`), tear the org session down too: ```typescript org.clearActiveOrg(); ``` - [ ] **Step 4: Add the four handler arms** In `extension/src/service-worker/router/popup-only.ts`, add an import: ```typescript import * as org from '../org'; ``` Add these arms inside the `switch (msg.type)` in `handle(...)`, after the `get_vault_status` arm: ```typescript 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 existing `lock` arm for symmetric teardown. - [ ] **Step 5: Type-check the whole extension (NOT bare `tsc --noEmit`)** ```bash cd extension && npm run build:all 2>&1 | tail -20 ``` Expected: clean build. The new `org_open_with_registered_device`/`org_manifest_decrypt`/`org_item_decrypt` resolve against the regenerated `relicario_wasm.d.ts` from Task D1. - [ ] **Step 6: Commit** ```bash 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` (add `activeOrgId` state) - Modify: `extension/src/vault/vault.css` (switcher + read-only banner styling) The switcher is a small `