From b655024320e414d1b9ae8fa85084981c60f9b412 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Fri, 19 Jun 2026 19:54:04 -0400 Subject: [PATCH] docs(plan,spec): apply re-verification fixes (5 high + 5 med + 6 low) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-verification gate cleared all original criticals; these close the residual defects the reassembly leaked back in: HIGH: - H-D1: add ssh-key dep to relicario-wasm; two-step PrivateKey::from; drop false note - H-D2: org_open_with_registered_device unwraps inside WASM (DEVICE_STATE seed, session-only); device private key never crosses to JS - H-D3: extension grant-filters the org manifest (members.json → member grants → filter_for_member) to honor the spec parity promise - H-C1: hook diffs {commit}^:members.json, rejects owner/admin escalation unless signer is Owner; adds signed-commit hook test - H-B4: reorder B4 tests to "org init --dir " (subcommand-scoped global) MEDIUM: trash=item-delete + item-restore vocabulary reconciled; real transfer-ownership (demote caller unless --keep-owner); delete-org local-only caveat in spec; pinned RFC8032 X25519 KAT. LOW: org init honors RELICARIO_ORG_DIR; D3 VaultEntry type pinned; static_secrets in File Map/Tech Stack; --format ; hook slug-in-collections check; spec-mandated integration tests (TAMPERED, audit JSON, rotate race, remove→rotate decrypt-denial). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../plans/2026-06-06-enterprise-org-vault.md | 932 +++++++++++++++--- ...6-relicario-enterprise-org-vault-design.md | 9 +- 2 files changed, 807 insertions(+), 134 deletions(-) diff --git a/docs/superpowers/plans/2026-06-06-enterprise-org-vault.md b/docs/superpowers/plans/2026-06-06-enterprise-org-vault.md index 0552715..549c097 100644 --- a/docs/superpowers/plans/2026-06-06-enterprise-org-vault.md +++ b/docs/superpowers/plans/2026-06-06-enterprise-org-vault.md @@ -6,7 +6,7 @@ **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` (new, in `relicario-core`), `ed25519-dalek 2`, `sha2`, `chacha20poly1305 0.10`, `ssh-key 0.6` (new, in `relicario-cli`), `regex`, `tempfile`, `serde_json`, `clap`, `anyhow`, `zeroize`; WASM bindings (`relicario-wasm`, `base64`) + extension TypeScript with **vitest** for the parity acceptance tests. +**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:** @@ -25,7 +25,7 @@ | Action | Path | Responsibility | |---|---|---| -| Modify | `crates/relicario-core/Cargo.toml` | Add `x25519-dalek = "2"` | +| 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 | @@ -40,7 +40,8 @@ | Create | `crates/relicario-server/src/lib.rs` | Pure path-classification + schema-version helpers (`classify_path`, `PathClass`, `extract_schema_version`) | | Modify | `crates/relicario-server/src/main.rs` | `verify-org-commit` (signature + path/role authz + schema monotonicity) + `generate-org-hook` | | Create | `crates/relicario-server/tests/org_hook.rs` | Hook path-classification + schema-version tests | -| Modify | `crates/relicario-wasm/src/lib.rs` | `org_open` / `org_manifest_decrypt` / `org_item_decrypt` bindings | +| Modify | `crates/relicario-wasm/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 | @@ -593,6 +594,31 @@ mod tests { 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 @@ -1544,7 +1570,9 @@ fn run(args: &[&str]) -> std::process::Output { fn org_init_creates_expected_files() { let dir = TempDir::new().unwrap(); let path = dir.path().to_str().unwrap(); - let out = run(&["--dir", path, "org", "init", "--name", "Test Org"]); + // `--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()); @@ -1593,10 +1621,11 @@ fn org_init_produces_a_signed_initial_commit() { String::from_utf8_lossy(&add.stderr) ); - // Initialize the org vault. + // 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(), - &["--dir", org.path().to_str().unwrap(), "org", "init", "--name", "Acme"], + &["org", "init", "--dir", org.path().to_str().unwrap(), "--name", "Acme"], ); assert!( init.status.success(), @@ -2459,8 +2488,14 @@ pub fn run_audit( member_filter: Option<&str>, collection_filter: Option<&str>, action_filter: Option<&str>, - json: bool, + 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. @@ -3383,6 +3418,7 @@ git commit -m "feat(cli/org): org edit — flag-driven field update for login/no **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. @@ -3468,7 +3504,7 @@ pub fn run_rm(dir: &Path, query: &str) -> Result<()> { vault.save_manifest(&manifest)?; let commit_msg = format!( - "org trash: {} ({})\n\nRelicario-Actor: {} {}\nRelicario-Action: item-trash\nRelicario-Collection: {}\nRelicario-Item: {}", + "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() ); @@ -3569,10 +3605,234 @@ 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 +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)" ``` @@ -3658,9 +3918,13 @@ enum OrgCommands { }, /// Rotate the org master key (run after removing a member). RotateKey, - /// Transfer ownership to another member (owner only). + /// 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 { @@ -3679,8 +3943,9 @@ enum OrgCommands { collection: Option, #[arg(long)] action: Option, - #[arg(long)] - json: bool, + /// 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. @@ -3694,8 +3959,10 @@ Commands::Org { dir, subcommand } => { let dir_path = dir.as_deref(); match subcommand { OrgCommands::Init { name } => { - let d = dir_path.ok_or_else(|| anyhow::anyhow!("--dir required for org init"))?; - commands::org::run_init(d, &name)?; + // 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)?; @@ -3727,9 +3994,9 @@ Commands::Org { dir, subcommand } => { let d = crate::org_session::org_dir(dir_path)?; commands::org::run_rotate_key(&d)?; } - OrgCommands::TransferOwnership { member_id } => { + OrgCommands::TransferOwnership { member_id, keep_owner } => { let d = crate::org_session::org_dir(dir_path)?; - commands::org::run_transfer_ownership(&d, &member_id)?; + commands::org::run_transfer_ownership(&d, &member_id, keep_owner)?; } OrgCommands::DeleteOrg { confirm } => { let d = crate::org_session::org_dir(dir_path)?; @@ -3739,10 +4006,10 @@ Commands::Org { dir, subcommand } => { let d = crate::org_session::org_dir(dir_path)?; commands::org::run_status(&d)?; } - OrgCommands::Audit { since, member, collection, action, json } => { + 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(), json)?; + collection.as_deref(), action.as_deref(), &format)?; } // Item dispatch arms (Add/Get/List/Edit/Rm/Restore/Purge) added by // Tasks B10–B13. @@ -3763,10 +4030,10 @@ fn parse_org_role(s: &str) -> anyhow::Result { - [ ] **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` promotes the target to Owner (the caller may optionally self-demote to Admin); `delete-org` requires `--confirm` and removes the working tree's org files. Minimal correct implementations: +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) -> Result<()> { +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() { @@ -3777,20 +4044,34 @@ pub fn run_transfer_ownership(dir: &Path, member_id_prefix: &str) -> Result<()> 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 {}\n\nRelicario-Actor: {} {}\nRelicario-Action: ownership-transfer\nRelicario-Member: {}", + "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")?; - println!("Ownership transferred to {}", target_id.as_str()); + 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(()) } @@ -3860,9 +4141,10 @@ The org pre-receive path mirrors the existing `verify_commit` (main.rs:37–153) 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.) -4. Schema-version monotonicity for the three JSON files (Task C2). -5. Root commit (no parent) → inspect full tree, allow as genesis. Merge commit (>1 parent) → REJECT. +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** @@ -4039,7 +4321,7 @@ Add to the `match cli.command` block in `main()`: - [ ] **Step 6: Implement the signer-resolution helper (mirrors `verify_commit`)** ```rust -use relicario_core::org::{OrgMember, OrgMembers}; +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) @@ -4188,6 +4470,9 @@ fn verify_org_commit(commit: &str) -> Result<()> { }; // 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) => { @@ -4202,8 +4487,15 @@ fn verify_org_commit(commit: &str) -> Result<()> { ); 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}`)", @@ -4211,6 +4503,22 @@ fn verify_org_commit(commit: &str) -> Result<()> { ); 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. @@ -4229,6 +4537,70 @@ fn verify_org_commit(commit: &str) -> Result<()> { ); 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`** @@ -4278,6 +4650,7 @@ This task does not commit on its own; the signature/auth code and the schema-mon - 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). @@ -4432,6 +4805,175 @@ fn git_show_parent(commit: &str, path: &str) -> Result { } ``` +- [ ] **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 @@ -4439,7 +4981,7 @@ cargo build -p relicario-server 2>&1 | tail -15 cargo test -p relicario-server 2>&1 | tail -25 ``` -Expected: clean build; all `org_hook` tests pass; existing `verify_commit` tests still pass. +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 @@ -4449,8 +4991,8 @@ 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 -git commit -m "feat(server): verify-org-commit — signature + path-scoped role/grant auth + schema monotonicity" +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" ``` --- @@ -4467,7 +5009,8 @@ git commit -m "feat(server): verify-org-commit — signature + path-scoped role/ **Files:** - Modify: `crates/relicario-wasm/src/lib.rs` -- Modify: `crates/relicario-wasm/Cargo.toml` (only if `base64` is not already a dependency) +- 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** @@ -4482,43 +5025,52 @@ grep -n "pub fn unwrap_org_key\|pub fn decrypt_org_manifest\|pub fn decrypt_item 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` dependency** +- [ ] **Step 2: Confirm/add the `base64` and `ssh-key` dependencies** ```bash -grep -n "^base64" crates/relicario-wasm/Cargo.toml +grep -n "^base64\|^ssh-key" crates/relicario-wasm/Cargo.toml ``` -If absent, add under `[dependencies]` in `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 -base64 = "0.22" +ssh-key = { version = "0.6", features = ["ed25519", "std"] } ``` -(Confirm `0.22` matches the workspace: `grep -A1 'name = "base64"' Cargo.lock`. Use whatever major version is already locked; do not introduce a second version.) +`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 ed25519_dalek::SigningKey; - use rand::rngs::OsRng; - use rand::RngCore; use zeroize::Zeroizing; - // Build a device keypair and its OpenSSH PEM, exactly as the extension stores it. - let mut seed = [0u8; 32]; - OsRng.fill_bytes(&mut seed); - let signing = SigningKey::from_bytes(&seed); - let ssh_priv = ssh_key::PrivateKey::from(signing.clone()); - let pem: String = ssh_priv.to_openssh(ssh_key::LineEnding::LF).unwrap().to_string(); - let pubkey_openssh = ssh_priv.public_key().to_openssh().unwrap(); + // 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 pubkey, and an org manifest encrypted under the org key. + // 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, &pubkey_openssh).unwrap(); + 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 { @@ -4532,12 +5084,9 @@ fn org_open_unwraps_into_a_handle_and_decrypts_manifest() { }); let enc_manifest = relicario_core::encrypt_org_manifest(&manifest, &org_key).unwrap(); - // The PEM is stored base64-wrapped by the SW; mirror that here. - use base64::Engine; - let pem_b64 = base64::engine::general_purpose::STANDARD.encode(pem.as_bytes()); - - // Exercise the internal helpers the wasm-bindgen exports wrap. - let handle = org_open_handle(&pem_b64, &wrapped).expect("org_open_handle"); + // 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) }) @@ -4546,65 +5095,103 @@ fn org_open_unwraps_into_a_handle_and_decrypts_manifest() { assert_eq!(out.entries.len(), 1); assert_eq!(out.entries[0].collection, "prod"); session::remove(handle); + device::clear_device(); - let _ = (seed, signing, org_key, Zeroizing::new([0u8; 0])); + 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_unwraps 2>&1 | tail -8 +cargo test -p relicario-wasm org_open 2>&1 | tail -8 ``` Expected: FAIL — `org_open_handle` undefined. -> **Note on the test's `PrivateKey::from(signing.clone())`:** `relicario-wasm` is a separate crate; this draft used the direct `From` form. If the installed `ssh-key` rejects it (same H7 issue as core), use `ssh_key::PrivateKey::from(ssh_key::private::Ed25519Keypair::from(&signing))`. Verify against the wasm crate's `ssh-key` version before running. +- [ ] **Step 4: Add the `unwrap_org_key` accessor to the WASM device module** -- [ ] **Step 4: Implement the bindings** - -Add to `crates/relicario-wasm/src/lib.rs`: +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 base64::Engine as _; +use relicario_core::unwrap_org_key as core_unwrap_org_key; -/// Internal: parse a base64-wrapped OpenSSH ed25519 private-key PEM into its -/// 32-byte seed, unwrap the org key blob, and insert the org key into the -/// session table. Returns the raw u32 handle. The org key never crosses to JS. -fn org_open_handle(device_private_key_b64: &str, wrapped_key: &[u8]) -> Result { - use zeroize::Zeroizing; +/// 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())?; - let pem_bytes = base64::engine::general_purpose::STANDARD - .decode(device_private_key_b64.trim()) - .map_err(|e| JsError::new(&format!("device key base64: {e}")))?; - let pem = String::from_utf8(pem_bytes) - .map_err(|e| JsError::new(&format!("device key utf8: {e}")))?; - - let private = ssh_key::PrivateKey::from_openssh(&pem) - .map_err(|e| JsError::new(&format!("parse device key: {e}")))?; + // 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(|| JsError::new("device key is not 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(JsError::new("ed25519 seed has wrong length")); + return Err("ed25519 seed has wrong length".to_string()); } let mut seed = Zeroizing::new([0u8; 32]); seed.copy_from_slice(seed_slice); - let org_key = relicario_core::unwrap_org_key(wrapped_key, &seed) - .map_err(|e| JsError::new(&format!("unwrap org key: {e}")))?; + 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 with their device private key and -/// return an opaque SessionHandle bound to the org master key. The org key -/// stays inside WASM linear memory — JS receives only the handle. +/// 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(device_private_key_b64: &str, wrapped_key: &[u8]) -> Result { - let handle = org_open_handle(device_private_key_b64, wrapped_key)?; +pub fn org_open_with_registered_device(wrapped_key: &[u8]) -> Result { + let handle = org_open_handle(wrapped_key)?; Ok(SessionHandle(handle)) } @@ -4632,17 +5219,17 @@ pub fn org_item_decrypt(handle: &SessionHandle, encrypted: &[u8]) -> Result **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`, `ed25519_dalek`, `zeroize`, `relicario_core` are already dependencies of `relicario-wasm`. +> - `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 5: Run the unit test** +- [ ] **Step 6: Run the unit tests** ```bash -cargo test -p relicario-wasm org_open_unwraps 2>&1 | tail -8 +cargo test -p relicario-wasm org_open 2>&1 | tail -8 ``` -Expected: PASS. +Expected: both `org_open_*` tests PASS. -- [ ] **Step 6: Rebuild the WASM artifact the extension consumes** +- [ ] **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 @@ -4651,17 +5238,17 @@ cd crates/relicario-wasm && wasm-pack build --target web --out-dir ../../extensi Then confirm the new exports landed: ```bash -grep -n "export function org_open\|export function org_manifest_decrypt\|export function org_item_decrypt" \ +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 7: Commit** +- [ ] **Step 8: Commit** ```bash -git add crates/relicario-wasm/src/lib.rs crates/relicario-wasm/Cargo.toml extension/wasm/ -git commit -m "feat(wasm/org): org_open + org_manifest_decrypt + org_item_decrypt bindings" +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)" ``` --- @@ -4766,6 +5353,29 @@ interface ActiveOrgSession { 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. @@ -4803,28 +5413,24 @@ export function clearActiveOrg(): void { } } -/// Read the device private key the SW persisted at setup/register time. -async function loadDevicePrivateKeyB64(): Promise { - const r = await chrome.storage.local.get('device_private_key'); - const key = r.device_private_key; - if (typeof key !== 'string' || key.length === 0) { - throw new Error('device_not_registered'); - } - return key; -} - /// Open (unlock) an org by id: build its GitHost, fetch this device's wrapped /// key blob, and unwrap into an org SessionHandle via WASM. +/// +/// 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 devicePrivB64 = await loadDevicePrivateKeyB64(); const git = createGitHost(cfg.hostType, cfg.hostUrl, cfg.repoPath, cfg.apiToken); - let readOnly = false; + const readOnly = false; let wrapped: Uint8Array; try { wrapped = await git.readFile(cfg.wrappedKeyPath); @@ -4832,10 +5438,27 @@ export async function openOrg(orgId: string): Promise<{ orgId: string; label: st throw new Error('org_offline'); } - const handle = w.org_open(devicePrivB64, wrapped) as SessionHandle; + // 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 }; + active = { orgId: cfg.orgId, label: cfg.label, handle, git, readOnly, memberId, grants }; return { orgId: cfg.orgId, label: cfg.label, readOnly }; } @@ -4859,7 +5482,11 @@ export async function getOrgItems( throw new Error('org_offline'); } const manifest = w.org_manifest_decrypt(active.handle, ciphertext) as OrgManifestJson; - const all = (manifest.entries ?? []).filter((e) => e.trashed_at === undefined); + // 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 { @@ -4884,6 +5511,10 @@ export async function getOrgItem(id: string): Promise<{ item: Item; readOnly: bo 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 { @@ -4982,7 +5613,7 @@ Add these arms inside the `switch (msg.type)` in `handle(...)`, after the `get_v cd extension && npm run build:all 2>&1 | tail -20 ``` -Expected: clean build. The new `org_open`/`org_manifest_decrypt`/`org_item_decrypt` resolve against the regenerated `relicario_wasm.d.ts` from Task D1. +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** @@ -5017,7 +5648,7 @@ Create `extension/src/vault/vault-org-switcher.ts`: /// this module — it stays inside WASM behind the SW. import { sendMessage } from '../shared/state'; -import type { VaultController } from './vault-context'; +import type { VaultController, VaultEntry } from './vault-context'; import type { ListOrgsResponse, OpenOrgResponse, GetOrgItemsResponse, } from '../shared/messages'; @@ -5094,16 +5725,18 @@ async function onSwitch(ctx: VaultController, value: string): Promise { const data = (itemsResp as GetOrgItemsResponse).data; setReadOnlyBanner(data.readOnly); // Project org entries into the same [id, entry] tuple shape the list pane - // consumes for the personal manifest. - ctx.state.entries = data.items.map((e) => [ - e.id, - { - id: e.id, type: e.type, title: e.title, tags: e.tags, - favorite: false, modified: e.modified, trashed_at: e.trashed_at, - // org-specific projection field; harmless extra for the personal list renderer. - collection: e.collection, - }, - ]) as typeof ctx.state.entries; + // consumes for the personal manifest. The entry value type is the pinned + // `VaultState.entries` element type (extended with an optional + // `collection?: string` — see the implementer note), so this assigns + // without a cast and the D2→D3 contract is compiler-checked. + const projected: VaultEntry[] = data.items.map((e) => ({ + id: e.id, type: e.type, title: e.title, tags: e.tags, + favorite: false, modified: e.modified, trashed_at: e.trashed_at, + // org-specific projection field; optional on VaultEntry, harmless for + // the personal list renderer. + collection: e.collection, + })); + ctx.state.entries = projected.map((entry) => [entry.id, entry]); } ctx.state.selectedId = null; ctx.state.selectedItem = null; @@ -5126,7 +5759,8 @@ function setReadOnlyBanner(show: boolean): void { > **Implementer notes:** > - `ctx.loadManifest()`, `ctx.renderListPane()`, `ctx.renderSidebarCategories()`, `ctx.renderPane()`, and `ctx.state` are all on the existing `VaultController`. -> - `VaultState` needs a new optional field `activeOrgId: string | null`. Add it to the `VaultState` interface in `vault-context.ts` and initialize `activeOrgId: null` in `vault.ts`. If the list renderer strictly types its entries, add an optional `collection?: string` to the entry projection type rather than casting. +> - `VaultState` needs a new optional field `activeOrgId: string | null`. Add it to the `VaultState` interface in `vault-context.ts` and initialize `activeOrgId: null` in `vault.ts`. +> - **Pin the entry element type (L2):** the personal manifest's entry value type must be a named, exported type — `VaultEntry` — exported from `vault-context.ts` and used as `entries: Array<[ItemId, VaultEntry]>` in `VaultState`. Add an optional `collection?: string` field to `VaultEntry`. The org switcher then imports `VaultEntry` and builds `VaultEntry[]` directly, so the D2→D3 projection is compiler-checked end-to-end instead of being silenced by a `as typeof ctx.state.entries` cast. If `VaultEntry` does not yet exist as a distinct type, extract it from the current inline entry-value type in `vault-context.ts` in this task. - [ ] **Step 2: Mount the switcher in the sidebar** @@ -5207,9 +5841,17 @@ import * as gitHostMod from '../git-host'; // --- Mock git-host: org.ts builds its GitHost via createGitHost --- function makeHostMock(opts: { offline?: boolean } = {}): GitHost { + const membersJson = JSON.stringify({ + schema_version: 1, + members: [{ + member_id: 'm0', display_name: 'Me', role: 'member', + ed25519_pubkey: 'ssh-ed25519 AAAA fake', collections: ['prod'], + }], + }); const reader = vi.fn().mockImplementation(async (path: string) => { if (opts.offline) throw new Error(`network: ${path}`); if (path === 'keys/m0.enc') return new Uint8Array([0xaa, 0xbb]); // wrapped org key blob + if (path === 'members.json') return new TextEncoder().encode(membersJson); // grant source if (path === 'manifest.enc') return new Uint8Array([0x01, 0x02]); // org manifest ct if (path === 'items/prod/itemprod00000001.enc') return new Uint8Array([0x03]); throw new Error(`404: ${path}`); @@ -5268,21 +5910,31 @@ const ORG_CONFIGS = [{ repoPath: 'acme/vault', apiToken: 't', wrappedKeyPath: 'keys/m0.enc', }]; -// --- Fake wasm: org_open returns an opaque handle, decrypt fns return JSON --- +// --- Fake wasm: org_open_with_registered_device returns an opaque handle, +// decrypt fns return JSON --- const ORG_MANIFEST = { schema_version: 1, - entries: [{ - id: 'itemprod00000001', type: 'secure_note', title: 'Prod secret', - tags: [], modified: 100, collection: 'prod', - }], + entries: [ + { + id: 'itemprod00000001', type: 'secure_note', title: 'Prod secret', + tags: [], modified: 100, collection: 'prod', + }, + // m0 is NOT granted `dev` — this entry must be filtered out on read. + { + id: 'itemdev000000001', type: 'secure_note', title: 'Dev secret', + tags: [], modified: 100, collection: 'dev', + }, + ], }; function makeWasm() { const handle = { __org: true, free: vi.fn() }; return { _handle: handle, - org_open: vi.fn(() => handle), + // Org open unwraps inside WASM using the registered device seed; the SW + // passes only the wrapped-key bytes — no device private key. + org_open_with_registered_device: vi.fn(() => handle), org_manifest_decrypt: vi.fn(() => ORG_MANIFEST), org_item_decrypt: vi.fn(() => ({ id: 'itemprod00000001', type: 'secure_note', title: 'Prod secret', @@ -5299,7 +5951,9 @@ describe('SW org context', () => { beforeEach(() => { offlineMode = false; - store = mockStorage({ orgConfigs: ORG_CONFIGS, device_private_key: 'ZmFrZS1wZW0=' }); + // No device_private_key in storage — the device seed lives only in WASM + // DEVICE_STATE; the SW never persists or reads it from chrome.storage. + store = mockStorage({ orgConfigs: ORG_CONFIGS }); wasm = makeWasm(); org.setWasm(wasm); vi.mocked(gitHostMod.createGitHost).mockImplementation(() => makeHostMock({ offline: offlineMode })); @@ -5314,7 +5968,8 @@ describe('SW org context', () => { it('open_org + get_org_items returns only the org manifest entries', async () => { const opened = await org.openOrg('org1'); expect(opened).toMatchObject({ orgId: 'org1', label: 'Acme', readOnly: false }); - expect(wasm.org_open).toHaveBeenCalledWith('ZmFrZS1wZW0=', expect.any(Uint8Array)); + // The binding takes ONLY the wrapped-key bytes — no device private key. + expect(wasm.org_open_with_registered_device).toHaveBeenCalledWith(expect.any(Uint8Array)); const { items, readOnly } = await org.getOrgItems(); expect(readOnly).toBe(false); @@ -5331,6 +5986,20 @@ describe('SW org context', () => { expect(org.getActiveOrgId()).toBeNull(); }); + // 1b) Grant-filtering: a member only sees entries in their granted + // collections. m0 holds `prod` but not `dev`, so the dev entry is hidden, + // and a direct get_org_item for it is refused. + it('filters org items to the member grant list', async () => { + await org.openOrg('org1'); + const { items } = await org.getOrgItems(); + expect(items).toHaveLength(1); + expect(items.map((i) => i.collection)).toEqual(['prod']); + expect(items.some((i) => i.id === 'itemdev000000001')).toBe(false); + + // Even by id, an ungranted item is not readable. + await expect(org.getOrgItem('itemdev000000001')).rejects.toThrow('item_not_found'); + }); + // 2) Org master key is never persisted. it('never writes the unwrapped org key to chrome.storage.local', async () => { await org.openOrg('org1'); @@ -5341,7 +6010,7 @@ describe('SW org context', () => { }).chrome.storage.local.set; expect(setFn).not.toHaveBeenCalled(); - const handleReturn = wasm.org_open.mock.results[0].value; + const handleReturn = wasm.org_open_with_registered_device.mock.results[0].value; expect(handleReturn).toBe(wasm._handle); expect(handleReturn).not.toBeInstanceOf(Uint8Array); @@ -5421,13 +6090,14 @@ Per the spec's "Living-Docs Impact" section and CLAUDE.md living-docs discipline - [ ] **Step 3: `DESIGN.md`** — cross-codebase structure: - Add the org-master-key row to the secrets map (256-bit random, wrapped per-member to device key; never escrowed; owners can always re-grant). - - Add the `x25519-dalek` dependency (core) and `ssh-key` (cli) to the build/dep matrix. + - Add the `x25519-dalek` (core, `features = ["static_secrets"]`) and `ssh-key` (cli **and** wasm) dependencies to the build/dep matrix. - Note `relicario-server` gains an org mode (`verify-org-commit` / `generate-org-hook`) and the new `[lib]` target. - [ ] **Step 4: `docs/SECURITY.md`** — threat model + honest limitations: - Org device-key auth (signer resolved by ed25519 fingerprint). - - The **signature-verifying** pre-receive hook: every org commit must carry a GOOD signature from a current member; writes authorized by role (protected files) or collection path segment (items); merge commits rejected; schema_version monotonic. - - The honest limitations verbatim from the spec: **shared org master key — reads are not cryptographically scoped per collection** (the hook scopes writes + the client filters the manifest, but one org key opens everything; for cryptographic separation use a separate org vault — per-collection subkeys are a phase-2 non-goal); **no read audit** (git records writes, not reads); **no "hide value."** + - The **signature-verifying** pre-receive hook: every org commit must carry a GOOD signature from a current member; writes authorized by role (protected files) or collection path segment (items); **only an owner may introduce or elevate an owner/admin** (admins cannot mint owners/admins server-side); merge commits rejected; schema_version monotonic. + - The **audit action vocabulary** matching the spec's Action Vocabulary table: `item-create` / `item-update` / `item-delete` (trash) / `item-restore` / `item-purge`; `member-add` / `member-remove` / `member-role-change`; `collection-create` / `collection-grant` / `collection-revoke`; `key-rotate`; `org-init` / `ownership-transfer` / `org-delete`. (Trash emits `item-delete`, restore emits `item-restore` — keep docs and the CLI emitters in B13 in lock-step.) + - The honest limitations verbatim from the spec: **shared org master key — reads are not cryptographically scoped per collection** (the hook scopes writes + the client filters the manifest, but one org key opens everything; for cryptographic separation use a separate org vault — per-collection subkeys are a phase-2 non-goal); **no read audit** (git records writes, not reads); **no "hide value."**; **`delete-org` is a LOCAL tombstone in phase 1** — the hook rejects protected-file deletion, so a delete cannot be pushed to a hook-protected remote. - [ ] **Step 5: `crates/relicario-core/ARCHITECTURE.md`** — add the `org` module: - Module map entry for `org.rs` (IDs, roles, members, collections, manifest, ECIES wrap/unwrap) + the `encrypt_org_manifest`/`decrypt_org_manifest` vault wrappers. diff --git a/docs/superpowers/specs/2026-06-06-relicario-enterprise-org-vault-design.md b/docs/superpowers/specs/2026-06-06-relicario-enterprise-org-vault-design.md index 511da52..4413ad4 100644 --- a/docs/superpowers/specs/2026-06-06-relicario-enterprise-org-vault-design.md +++ b/docs/superpowers/specs/2026-06-06-relicario-enterprise-org-vault-design.md @@ -201,12 +201,14 @@ relicario org create-collection --name "..." relicario org grant relicario org revoke relicario org rotate-key # new org key: re-wrap for members AND re-encrypt all items + manifest -relicario org transfer-ownership # owner → another member (owner only) -relicario org delete-org # owner only; explicit confirmation +relicario org transfer-ownership # owner → another member (owner only; caller demoted to admin unless --keep-owner) +relicario org delete-org # owner only; explicit confirmation; LOCAL tombstone only (see caveat below) relicario org status # members, roles, collections — no decryption relicario org audit [--since ..] [--member ..] [--collection ..] [--action ..] [--format json] ``` +> **`delete-org` caveat (phase 1):** the pre-receive hook rejects deletion of the protected JSON files (`members.json` / `collections.json` / `org.json`) as part of schema-monotonicity enforcement. Therefore phase-1 `delete-org` is a **local tombstone only** — it removes the org files in the working tree and records a delete commit locally, but that commit **cannot be pushed to a hook-protected remote**. Pushing org teardown to a protected remote (a hook-side "owner may delete" exception) is a tracked phase-2 follow-up. `transfer-ownership` is fully hook-compatible (it only mutates `members.json` roles, owner-signed). + ### Onboarding Flow 1. Alice runs `relicario device add`, exports her ed25519 public key (`signing.pub`). @@ -251,7 +253,7 @@ Relicario-Item: 9f8e7d6c5b4a3f2e | `Relicario-Action` | Trigger | |---|---| -| `item-create` / `item-update` / `item-delete` / `item-purge` | org item add / edit / trash / purge | +| `item-create` / `item-update` / `item-delete` / `item-restore` / `item-purge` | org item add / edit / trash / restore / purge | | `member-add` / `member-remove` / `member-role-change` | member management | | `collection-create` / `collection-grant` / `collection-revoke` | collection management | | `key-rotate` | org key rotation | @@ -355,3 +357,4 @@ This spec covers phase 1 (git-native org, CLI + extension parity). Phase 2 adds: - Server-mediated read audit - "Hide value" autofill (per-item subkeys or server-mediated relay) - Per-collection cryptographic isolation (subkeys — explicit non-goal for phase 1) +- Pushable `delete-org` org teardown (a hook-side "owner may delete protected files" exception); phase-1 `delete-org` is a local tombstone only