docs(spec): revise org-vault design per adversarial review

Path-scoped collection storage (items/<slug>/<id>.enc) for hook-enforceable
writes; signature-verifying pre-receive hook on every commit; audit actor from
verified signer (trailers advisory + TAMPERED flag); org item CRUD in scope;
rotate-key re-encrypts all items; transfer/delete-org; extension parity in
phase 1; living-docs impact section.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
adlee-was-taken
2026-06-19 18:50:21 -04:00
parent ac6756e698
commit 21ed8d83b8

View File

@@ -1,9 +1,11 @@
# Relicario Enterprise Org Vault — Design Spec
**Scope:** Multi-user organizational vault for security-conscious self-hosting shops. Covers the git-native org model, cryptographic key-wrapping scheme, access control, admin CLI surface, audit trail, and testing strategy. Does not cover SSO/SAML, live SIEM streaming, or the HTTP management plane (deferred to a later server-tier spec).
**Scope:** Multi-user organizational vault for security-conscious self-hosting shops. Covers the git-native org model, the per-member key-wrapping scheme, collection-scoped item storage, role-based access control, org item CRUD, the signature-verifying pre-receive hook, the audit trail, and extension parity. Does not cover SSO/SAML, live SIEM streaming, or the HTTP management plane (deferred to a later server-tier spec).
**Next:** `docs/superpowers/specs/2026-05-02-relay-server-design.md` (relay server — future phase 2 management plane)
> **Revision note (2026-06-19):** This spec was revised after an adversarial multi-agent review of the first draft + its implementation plan. The review confirmed the cryptographic wrap/unwrap scheme is correct but found that the original access-control design was unenforceable (flat item paths the hook could not authorize), the hook never actually verified signatures, the audit actor was read from spoofable commit trailers, and there was no item CRUD. This revision corrects all of those at the design level. See `docs/superpowers/plans/2026-06-06-enterprise-org-vault.md` for the implementation.
---
## Target Audience
@@ -11,9 +13,10 @@
Security-focused organizations that self-host their entire stack: infosec shops, security consultancies, law firms handling privileged client data, small financial firms. Key requirements:
- Full air-gap capability — no mandatory internet connectivity
- Cryptographically provenance-linked audit trail
- Cryptographically provenance-linked, **tamper-evident** audit trail
- Personal vaults remain isolated from org vault (separate cryptographic domains)
- Member offboarding with clean key revocation
- Least-privilege blast-radius limiting via collections, **server-enforced** (not advisory)
- Member offboarding with clean key revocation that protects past secrets
- Deployable without an IdP, SSO provider, or cloud dependency
---
@@ -27,19 +30,23 @@ Personal: ~/.config/relicario/personal/ → personal git repo (passphrase + i
Org: ~/.config/relicario/acme-org/ → org git repo (org master key, wrapped per-member)
```
**Two cryptographic domains, one CLI and extension.**
**Two cryptographic domains, one CLI and one extension.**
The org vault uses a random 256-bit **org master key** to encrypt all org items. Each authorized member receives a copy of the org master key wrapped (XChaCha20-Poly1305) to their existing ed25519 device public key — converted to X25519 for the Diffie-Hellman step. To open the org vault, a member uses their device private key to unwrap their copy of the org master key, then decrypts items exactly as today.
The org vault uses a random 256-bit **org master key** to encrypt all org items and the org manifest. Each authorized member receives a copy of the org master key wrapped (ECIES: X25519 + XChaCha20-Poly1305) to their existing ed25519 device public key — converted to X25519 for the Diffie-Hellman step. To open the org vault, a member uses their device private key to unwrap their copy of the org master key, then decrypts items exactly as today.
**Two enforcement boundaries, working together:**
1. **Cryptographic** — only holders of a wrapped key can decrypt the org master key, and only the org master key can decrypt items. Revocation + key rotation re-encrypts everything under a fresh key.
2. **Git pre-receive hook** — every commit is signature-verified against `members.json`, and writes are authorized by role (for management files) or by **collection path segment** (for item files). This is what makes least-privilege real rather than advisory, and what makes the audit trail tamper-evident.
**Key security properties:**
- Member departure = delete their `keys/<member-id>.enc` + rotate the org master key. No passphrase change, no re-imaging.
- Every write to the org repo is a signed git commit the audit log is the repo history.
- The pre-receive hook in `relicario-server` is the policy enforcement point.
- Member departure = delete their `keys/<member-id>.enc`, then `rotate-key` re-wraps the org key for remaining members **and re-encrypts every item blob**. A removed member who kept the old key and a clone can decrypt nothing written or rotated after their removal.
- Every write to the org repo is a **signed** git commit; the hook rejects unsigned commits and commits from non-members. The git log is the audit log, and its actor attribution comes from the **verified signing key**, not from spoofable commit-message text.
- Fully air-gapped: the org repo is just git, push/pull over SSH.
- A compromised org master key does not expose personal vault items.
**Phase 2 (not in this spec):** live SIEM streaming, SSO/SAML, HTTP management plane via the `relicario-server` relay skeleton.
**Phase 2 (not in this spec):** live SIEM streaming, SSO/SAML, LDAP/IdP member sync, HTTP management plane via the `relicario-server` relay skeleton, server-mediated read audit, and "hide value" (autofill without revealing plaintext).
---
@@ -56,9 +63,12 @@ acme-org/
│ └── <member-id>.enc # org master key wrapped to each member's X25519 public key
├── manifest.enc # encrypted org manifest (item index + collection membership)
└── items/
└── <item-id>.enc # encrypted items (identical format to personal vault items)
└── <collection-slug>/
└── <item-id>.enc # encrypted item, stored UNDER its collection directory
```
**Collection-scoped item storage is load-bearing.** Items live under `items/<collection-slug>/<item-id>.enc`, not in a flat `items/` directory. The leading path segment is the collection slug, in cleartext, so the pre-receive hook can authorize a write by comparing the path's collection against the signing member's grants — *without* decrypting anything. (The original flat layout made this impossible: the item→collection mapping existed only inside the encrypted manifest the server cannot read.)
### `org.json`
```json
@@ -82,7 +92,7 @@ Public and unencrypted — readable without the org master key. Roles are not se
"member_id": "<16-char hex>",
"display_name": "Alice",
"role": "owner",
"ed25519_pubkey": "<base64>",
"ed25519_pubkey": "ssh-ed25519 AAAA... ",
"collections": ["prod-infra", "shared-tools"],
"added_at": 1748000000,
"added_by": "<member-id>"
@@ -91,7 +101,7 @@ Public and unencrypted — readable without the org master key. Roles are not se
}
```
`role` is one of `owner`, `admin`, `member`. `collections` is the list of collection slugs this member is granted access to. `member_id` is a 16-char lowercase hex string generated from 64 bits of `OsRng` entropy — the same convention as `ItemId` and `FieldId` in `relicario-core/src/ids.rs`.
`role` is one of `owner`, `admin`, `member`. `collections` is the list of collection slugs this member is granted. `member_id` is a 16-char lowercase hex string generated from 64 bits of `OsRng` entropy — the same convention as `ItemId`/`FieldId` in `relicario-core/src/ids.rs`. `ed25519_pubkey` is the member's device public key in OpenSSH format; the hook canonicalizes it to a SHA-256 fingerprint (via `relicario_core::fingerprint`) for matching, so whitespace/comment differences do not lock a member out.
### `collections.json`
@@ -109,17 +119,19 @@ Public and unencrypted — readable without the org master key. Roles are not se
}
```
Slugs are validated: non-empty, no `/`, no `.` (so they are safe single path segments).
### `keys/<member-id>.enc`
The org master key (32 bytes) encrypted with XChaCha20-Poly1305 using a key derived via X25519 from the member's ed25519 public key (converted to X25519 using the standard Ristretto/curve25519 mapping). One file per member. Deleting this file + rotating the org master key constitutes clean revocation.
The org master key (32 bytes) encrypted with ECIES to the member's device key. Wrapped-blob layout: `ephemeral_x25519_pubkey(32) || version(1) || nonce(24) || ciphertext+tag`. The wrapping key is `SHA-256(dh_shared || ephemeral_pubkey || recipient_pubkey)`; all secret intermediates (shared secret, derived wrap key) are held in `Zeroizing`. The ed25519→X25519 conversion (SHA-512(seed)[:32] + RFC 7748 clamp for the scalar; birational Montgomery map for the point) is the standard one; its correctness was verified against ed25519-dalek's own reference test vector.
### `manifest.enc`
Encrypted with the org master key. Same schema as the personal vault manifest but each item entry includes a `collection` field (the slug). The manifest is the only place collection membership is stored for items — item blobs carry no collection metadata.
Encrypted with the org master key. Same shape as the personal vault manifest but each entry carries a `collection` slug. The manifest is the authoritative item index; item blobs carry no metadata.
### `items/<item-id>.enc`
### `items/<collection-slug>/<item-id>.enc`
Identical `.enc` format to personal vault items (XChaCha20-Poly1305, random 24-byte nonce, no Argon2id — the org master key is used directly). Item IDs follow the same 16-char hex convention.
Identical `.enc` format to personal vault items (XChaCha20-Poly1305, random 24-byte nonce, org master key used directly — no Argon2id). Item IDs follow the 16-char hex convention. The blob does not name its collection; the directory path does.
---
@@ -129,96 +141,100 @@ Identical `.enc` format to personal vault items (XChaCha20-Poly1305, random 24-b
| Role | Permissions |
|---|---|
| **Owner** | All operations. Add/remove admins. Rotate org master key. Delete org. Transfer ownership. |
| **Admin** | Add/remove members. Create/delete collections. Grant/revoke collection access. Read all collections. Cannot remove owners or other admins. |
| **Member** | Read/write items in granted collections only. Cannot see items outside their grant list. |
| **Owner** | All operations. Add/remove admins and owners. Create/delete collections. Rotate org key. Transfer ownership. Delete org. |
| **Admin** | Add/remove **members** (not owners/admins). Create/delete collections. Grant/revoke collection access. Read all collections. |
| **Member** | Read/write items in granted collections only. Cannot see or write items in other collections. |
Role gating is enforced both client-side (the CLI refuses) and server-side (the hook rejects). An admin cannot mint an owner or admin — only an owner can.
### Collection Access
Access grants are stored in the member's `collections` array in `members.json`. No separate ACL file. An admin edits the member record and commits; the pre-receive hook validates the commit author has admin or owner role.
Grants are stored in the member's `collections` array in `members.json`. No separate ACL file. An admin edits the member record and commits; the hook validates the committing member's role.
### Enforcement Layers
Access control is enforced at two layers:
1. **Manifest filtering (read)** — the CLI and extension filter the decrypted manifest to entries whose `collection` is in the authenticated member's grant list. Members never see items for collections they are not granted.
1. **Manifest filtering** — the CLI and extension filter the decrypted manifest to items whose `collection` slug appears in the authenticated member's grant list. Members never receive item blobs for collections they are not granted.
2. **Pre-receive hook (write)** — for `items/<slug>/<id>.enc`, the hook requires `<slug>` to be in the signing member's grants. For `members.json` / `collections.json` / `org.json`, it requires owner/admin role. Every commit must additionally carry a **valid signature** from a current member. This makes both confidentiality *and integrity* of collections server-enforced.
2. **Pre-receive hook** — rejects pushes where the committing device's member record does not have write access to the collections whose item files are modified.
### Known Limitations (honest)
### Known Limitation
- **Shared org master key — reads are not cryptographically scoped per collection.** Every member holds the *same* org master key (wrapped to their device key). The hook scopes *writes* by collection path and the client filters the *manifest* on read, but the cryptography itself does not partition reads: a member who obtains the raw ciphertext of an item in a collection they were not granted can still decrypt it, because the one org key opens everything. Collection grants are therefore an access-control boundary (enforced by the hook on write and by manifest filtering + optional git-host directory read-ACLs on fetch), not a cryptographic one. For *cryptographic* separation, put the sensitive material in a **separate org vault**. Per-collection subkeys are an explicit non-goal for this phase.
- **No read audit.** Git commits record writes, not reads. A member decrypting an item without writing leaves no git trace. Read audit needs a server that mediates fetch — phase 2.
- **No "hide value."** Autofill-without-revealing requires per-item subkeys or a mediating relay — phase 2.
Every member holds the same org master key (wrapped to their device key). A member with a local copy of the org repo and their device key can technically decrypt any item blob — the org master key is not scoped per-collection. Access control is enforced by the CLI, extension, and pre-receive hook, not by cryptographic isolation between collections.
---
For items requiring true cryptographic separation within a security shop, place them in a separate org vault. Per-collection subkeys are an explicit non-goal for this phase.
## Org Items (CRUD)
### "Hide value" (phase 2)
The org vault stores secrets via `relicario org` item commands that mirror the personal-vault item model (`Item`, `ItemCore`, the typed builders) but operate on the org repo and enforce collection grants.
Bitwarden and LastPass support "autofill without revealing plaintext." This requires per-item subkeys or a relay that mediates autofill. Out of scope for phase 1 — documented as a known gap.
```
relicario org add --collection <slug> <type> [type-specific flags]
relicario org get --collection <slug> <query> [--show] [--copy]
relicario org list [--collection <slug>] [--type <t>]
relicario org edit --collection <slug> <query>
relicario org rm | restore | purge --collection <slug> <query>
```
Every item operation:
1. Requires the caller's `current_member()` to have `<slug>` in their grants, and `<slug>` to exist in `collections.json`.
2. Reads/writes `items/<slug>/<id>.enc` with the org master key.
3. Upserts/removes the `OrgManifestEntry` (with `collection = <slug>`) and re-encrypts `manifest.enc`.
4. Commits with the structured trailer block, emitting the matching `item-*` action.
`get`/`list` apply manifest filtering so a member only sees their granted collections; secret fields are masked unless `--show`. Trash uses `trashed_at` like the personal vault.
---
## Admin Operations
All org management uses `relicario org <subcommand>`.
All org management uses `relicario org <subcommand>`. Command bodies live in `commands/org.rs` as `run_<verb>`.
```
relicario org init --name "Acme Security"
Create org repo, generate org master key, add caller as owner.
relicario org add-member --key <base64-pubkey> --name Alice --role member
Wrap org master key to Alice's X25519 key. Write keys/<id>.enc + update members.json.
relicario org remove-member <member-id>
Delete keys/<id>.enc, update members.json. Prompts to run org rotate-key immediately.
relicario org init --name "Acme Security" # create org repo, generate org key, add caller as owner, configure signing
relicario org add-member --key <openssh-pubkey> --name Alice --role member
relicario org remove-member <member-id> # delete key blob; prompts to run rotate-key
relicario org set-role <member-id> admin|member
Update role in members.json. Owners only for admin promotion.
relicario org create-collection <slug> --name "Display Name"
Append to collections.json.
relicario org grant <member-id> <collection-slug>
Add slug to member's collections array in members.json.
relicario org revoke <member-id> <collection-slug>
Remove slug from member's collections array.
relicario org rotate-key
Generate new org master key. Re-wrap for all current members. Re-encrypt manifest.
Item blobs do not need re-encryption (only the manifest references them).
Does git pull --rebase before generating to detect concurrent rotations.
relicario org status
Print members, roles, and collection grants. No decryption required.
relicario org audit [--since <iso-date>] [--member <id>] [--collection <slug>] [--action <type>] [--format json]
Query git log for Relicario-* trailers. Output structured table or JSON array.
relicario org create-collection <slug> --name "..."
relicario org grant <member-id> <slug>
relicario org revoke <member-id> <slug>
relicario org rotate-key # new org key: re-wrap for members AND re-encrypt all items + manifest
relicario org transfer-ownership <member-id> # owner → another member (owner only)
relicario org delete-org # owner only; explicit confirmation
relicario org status # members, roles, collections — no decryption
relicario org audit [--since ..] [--member ..] [--collection ..] [--action ..] [--format json]
```
### Onboarding Flow
1. Alice runs `relicario device add`, exports her ed25519 public key.
1. Alice runs `relicario device add`, exports her ed25519 public key (`signing.pub`).
2. Alice sends her public key to an admin out-of-band (Signal, email, printed QR — Relicario does not mediate key exchange).
3. Admin runs `org add-member --key <pubkey> --name Alice`.
3. Admin runs `org add-member --key <pubkey> --name Alice`. (An admin may add only `member` role; promoting to admin/owner requires an owner.)
4. Alice pulls the org repo. She can now open the org vault.
### Offboarding Flow
1. Admin runs `org remove-member <id>`.
2. Admin immediately runs `org rotate-key`.
3. Former member's wrapped key is gone; even with the old key material they cannot decrypt items written after rotation.
1. Admin runs `org remove-member <id>` (deletes the key blob, updates `members.json`).
2. Admin runs `org rotate-key` — generates a new org key, re-wraps it for remaining members, and **re-encrypts every item blob and the manifest** under the new key.
3. The former member, even with the old key and a clone, can decrypt nothing post-rotation.
### Signing
`org init` calls `configure_git_signing(org_root, device_name)` so the org repo signs commits with the device's ed25519 key. All org writes are signed; the hook rejects anything else.
### Extension — Org Context
The vault tab grows a top-level org switcher (Personal + each configured org). Switching context loads the selected org's manifest through the service worker. The SW holds the unwrapped org master key in a `Zeroizing<[u8; 32]>` session handle — identical pattern to the personal master key. The org master key is never written to `localStorage`, `IndexedDB`, or any persistent browser storage.
The vault tab gains a top-level org switcher (Personal + each configured org). Switching loads the selected org's manifest through the service worker. The SW holds the unwrapped org master key in a `Zeroizing` session handle — identical to the personal master key. The org master key is **never** written to `localStorage`, `IndexedDB`, or any persistent browser storage. If the git remote is unreachable, the org context is read-only with an "org offline — writes disabled" indicator. Phase-1 extension scope is: org switching, browsing/reading org items (grant-filtered), and the parity acceptance tests; full in-extension org item editing may be a tracked follow-up if it balloons.
---
## Audit Trail
### Git Log as Audit Record
### Git Log as Tamper-Evident Audit Record
Every write to the org repo is a git commit. Commits use structured trailers for machine-readable audit:
Every write is a signed git commit carrying structured trailers:
```
add item to prod-infra collection
@@ -227,66 +243,38 @@ Relicario-Actor: alice <a1b2c3d4e5f6a1b2>
Relicario-Action: item-create
Relicario-Collection: prod-infra
Relicario-Item: 9f8e7d6c5b4a3f2e
Relicario-Device: d1c2b3a4e5f6d1c2
```
The trailer schema is versioned via `org.json`'s `schema_version`.
**Trailers are advisory, not authoritative.** A malicious committer can write any trailer text. The trustworthy actor identity is the **verified signing key**: `relicario org audit` resolves each commit's signature fingerprint to a `members.json` entry and reports that as the actor. Where the trailer's claimed actor disagrees with the verified signer, the commit is flagged `TAMPERED`. Timestamps use the committer date (`%cI`).
### Action Vocabulary
| `Relicario-Action` | Trigger |
|---|---|
| `item-create` | New item added to org vault |
| `item-update` | Existing org item edited |
| `item-delete` | Item moved to trash |
| `item-purge` | Trashed item permanently deleted |
| `member-add` | `org add-member` |
| `member-remove` | `org remove-member` |
| `member-role-change` | `org set-role` |
| `collection-create` | `org create-collection` |
| `collection-grant` | `org grant` |
| `collection-revoke` | `org revoke` |
| `key-rotate` | `org rotate-key` |
| `item-create` / `item-update` / `item-delete` / `item-purge` | org item add / edit / trash / purge |
| `member-add` / `member-remove` / `member-role-change` | member management |
| `collection-create` / `collection-grant` / `collection-revoke` | collection management |
| `key-rotate` | org key rotation |
| `org-init` / `ownership-transfer` / `org-delete` | org lifecycle |
### `relicario org audit`
A query layer over `git log --format`. Parses trailer lines, applies filters, emits structured output.
`--format json` emits a JSON array of event objects — pipe to any log shipper without a live webhook:
```json
[
{
"timestamp": 1748000000,
"actor_name": "alice",
"actor_id": "a1b2c3d4e5f6a1b2",
"action": "item-create",
"collection": "prod-infra",
"item_id": "9f8e7d6c5b4a3f2e",
"device_id": "d1c2b3a4e5f6d1c2",
"commit": "abc123def456"
}
]
```
A cron job running `relicario org audit --since <last-run> --format json | <siem-ingest>` is sufficient for Splunk/Elastic/Loki ingestion without any live server.
### Read Audit Gap
Git commits only record writes. Read access (a member decrypting an item without modifying it) is invisible to the git log. This is the same limitation as Bitwarden self-hosted's event log. Read audit requires a server that mediates item fetch — deferred to phase 2.
Parses `git log` (record separator `%x1e`, field separator `%x1f` to survive multi-line trailer values), resolves signer→member, applies `--since/--member/--collection/--action` filters, and emits a table or, with `--format json`, a JSON array ready for `… | <siem-ingest>` via cron. Each event includes the verified actor, action, collection, item, commit, committer timestamp, and a `tampered` flag.
---
## Pre-Receive Hook Policy Enforcement
## Pre-Receive Hook (`relicario-server verify-org-commit`)
`relicario-server` validates on every push to the org repo:
`relicario-server` gains an org mode. For each pushed commit it:
- Only owners/admins may push changes to `members.json`, `collections.json`, `org.json`.
- Members may only push item writes (`items/*.enc`) to collections in their grant list.
- `org.json` `schema_version` must not decrease.
- `members.json` and `collections.json` must pass schema validation.
- `keys/` path ACL (read-own-only) is delegated to git hosting ACLs (Gitea/Forgejo team permissions) — documented as a deployment note, not enforced by the hook.
- After `member-remove`, the hook emits a warning if `org rotate-key` has not been run since the removal but does not block further pushes. This allows an admin to batch multiple removals before a single rotation.
1. **Verifies the signature** by building a temporary `allowed_signers` from `members.json` ed25519 keys, injecting `gpg.ssh.allowedSignersFile` via `GIT_CONFIG_*`, running `git verify-commit --raw`, and parsing the `SHA256:` fingerprint from stderr — the same mechanism the existing `verify-commit` uses. A commit with no good signature, or whose signer is not a current member, is rejected. (Bare `git %GF` is **not** used — it returns empty without an allowed-signers file.)
2. **Authorizes the change** by inspecting `git diff-tree` paths:
- `members.json` / `collections.json` / `org.json` → signer must be owner/admin; a `member-role-change` granting owner/admin must be signed by an owner.
- `items/<slug>/<id>.enc``<slug>` must be in the signing member's grants.
3. **Validates schema**`schema_version` must not decrease for any of the three JSON files (compared against `{commit}^:<file>`), and `members.json`/`collections.json` must pass `validate()`.
4. **Handles genesis and merges** the root commit (no parent) is the org-init genesis: it is allowed if signed by the sole owner it introduces. Merge commits are rejected (org history is linear) to avoid first-parent-only diff blind spots.
`relicario-server generate-org-hook` emits the wrapper script that runs `verify-org-commit` per pushed commit.
---
@@ -294,19 +282,19 @@ Git commits only record writes. Read access (a member decrypting an item without
### Key Rotation Race
If two admins simultaneously remove different members and rotate the key, the second rotation's `git pull --rebase` detects a concurrent rotation commit and aborts with: `"Concurrent key rotation detected — pull and re-run org rotate-key."` Admin re-runs after pulling.
`rotate-key` does `git pull --rebase` first. If the pull surfaces a non-fast-forward / conflict (a concurrent rotation), it aborts with `"Concurrent key rotation detected — pull and re-run org rotate-key."` A missing remote (local-only org) is distinguished and does not abort.
### Org Repo Schema Invalid
If `members.json` or `collections.json` fails schema validation on pull, the CLI refuses to open the org vault: `"Org repo schema invalid at <path> — contact your org owner."` No silent degradation.
If `members.json`/`collections.json` fail validation on pull, the CLI refuses to open the org vault with a clear error. No silent degradation.
### Member Device Key Lost
If a member loses their device key before a backup device was added, an owner generates a new `keys/<member-id>.enc` for a replacement device key the member generates. No master key escrow is required — owners can always re-grant access because they hold the org master key.
If a member loses their device key before a backup device was added, an owner re-wraps the org key to a replacement device key the member generates. No master key escrow is needed — owners hold the org key and can always re-grant.
### Extension Offline
If the git remote is unreachable, the extension operates read-only from the last-pulled org repo state. Writes are blocked and the org context shows an "org offline — writes disabled" indicator. Identical behavior to personal vault offline mode.
If the git remote is unreachable, the extension serves read-only from the last-pulled state and blocks writes with an indicator. Identical to personal vault offline behavior.
---
@@ -314,35 +302,56 @@ If the git remote is unreachable, the extension operates read-only from the last
### Unit Tests (`relicario-core`)
- Org master key wrap/unwrap round-trip: ed25519X25519 conversion + XChaCha20-Poly1305 encrypt/decrypt.
- Manifest filtering by collection grant list: member with grants `["prod-infra"]` receives only items tagged `prod-infra`.
- `members.json` and `collections.json` schema validation: valid and invalid inputs.
Fast Argon2id params are irrelevant for org tests — the org crypto path bypasses Argon2id entirely (key wrapping is X25519-based). Standard test params apply to any personal vault operations in shared test fixtures.
- Org key wrap/unwrap round-trip (ed25519X25519 + XChaCha20-Poly1305), including a pinned RFC 8032 known-answer vector so a future crate-version regression in the birational map is caught.
- Manifest filtering by collection grant list.
- `members.json` / `collections.json` schema validation (valid + invalid).
- Secret intermediates are `Zeroizing` (compile-level).
### Integration Tests (`relicario-cli`)
- Full `org init``add-member``grant` → item write → audit output against a local bare git repo.
- `org remove-member``rotate-key` → former member cannot decrypt newly written items.
- `org audit --format json` output is valid JSON matching the action vocabulary.
- Pre-receive hook rejects a push where the committing member writes to an ungranated collection.
- Concurrent rotation race: two concurrent `rotate-key` operations, second one detects and aborts.
- Full lifecycle against a local bare git repo: `org init → add-member → create-collection → grant → org add (item write) → audit` — verifying the item lands at `items/<slug>/<id>.enc` and the audit attributes the verified signer.
- `remove-member → rotate-key` → former member cannot decrypt a re-encrypted item; remaining member can.
- Grant enforcement: a member without a collection grant is refused `org add/get` for it.
- `org audit --format json` is valid JSON matching the action vocabulary; a forged-trailer commit is flagged `TAMPERED`.
- Concurrent `rotate-key` race aborts with the spec error string.
### Hook Tests (`relicario-server`)
- Unsigned commit rejected; commit signed by a non-member rejected.
- Item write to an ungranted collection path rejected; to a granted one accepted.
- Protected-file write by a member (non-admin) rejected.
- `schema_version` decrease rejected. Genesis commit accepted; merge commit rejected.
### Extension Tests (vitest)
- SW org context switching: loading org manifest replaces personal manifest in session state without cross-contamination.
- Org master key is stored only in the Zeroizing session handle, never in `localStorage` or `IndexedDB`.
- Offline read-only mode: SW surfaces read-only flag when git remote returns a network error.
- SW org context switching replaces the personal manifest cleanly (no cross-contamination).
- Org master key lives only in the Zeroizing session never in `localStorage`/`IndexedDB`.
- Offline read-only mode triggers on a git network error.
Org crypto bypasses Argon2id (key wrapping is X25519-based), so the fast-Argon2id test-params convention is irrelevant to org tests; standard params apply only where shared fixtures touch the personal path.
---
## Living-Docs Impact
This feature introduces new on-disk formats, a new crypto path, and a new dependency, so the following docs must be updated as the work lands (per CLAUDE.md living-docs discipline):
- `docs/FORMATS.md` — the four org JSON files, the `keys/<id>.enc` wrapped-blob layout, and `items/<slug>/<id>.enc`.
- `docs/CRYPTO.md` — the ECIES org-key wrap/unwrap path and key-rotation re-encryption.
- `DESIGN.md` — org-master-key row in the secrets map; the `x25519-dalek` dependency; relicario-server org mode.
- `docs/SECURITY.md` — org device-key auth, the signature-verifying hook, and the honest limitations above.
- `crates/relicario-core/ARCHITECTURE.md` and `crates/relicario-cli/ARCHITECTURE.md` — the new `org` modules.
- `STATUS.md` / `ROADMAP.md` — the org-vault track and any tracked follow-ups (e.g. full extension org editing, SSO/LDAP).
---
## Phase Boundary
This spec covers phase 1 (git-native org). Phase 2 adds:
This spec covers phase 1 (git-native org, CLI + extension parity). Phase 2 adds:
- HTTP management plane via `relicario-server` (relay skeleton → org API)
- Live audit event streaming to SIEM (webhooks, not cron-poll)
- SSO/SAML assertion validation + LDAP/IdP member sync
- Read audit (server-mediated item fetch with event recording)
- 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)