# Relicario Enterprise Org Vault — Design Spec

**Scope:** Multi-user organizational vault for security-conscious self-hosting shops. Covers the git-native org model, the per-member key-wrapping scheme, collection-scoped item storage, role-based access control, org item CRUD, the signature-verifying pre-receive hook, the audit trail, and extension parity. Does not cover SSO/SAML, live SIEM streaming, or the HTTP management plane (deferred to a later server-tier spec).

**Next:** `docs/superpowers/specs/2026-05-02-relay-server-design.md` (relay server — future phase 2 management plane)

> **Revision note (2026-06-19):** This spec was revised after an adversarial multi-agent review of the first draft + its implementation plan. The review confirmed the cryptographic wrap/unwrap scheme is correct but found that the original access-control design was unenforceable (flat item paths the hook could not authorize), the hook never actually verified signatures, the audit actor was read from spoofable commit trailers, and there was no item CRUD. This revision corrects all of those at the design level. See `docs/superpowers/plans/2026-06-06-enterprise-org-vault.md` for the implementation.

---

## Target Audience

Security-focused organizations that self-host their entire stack: infosec shops, security consultancies, law firms handling privileged client data, small financial firms. Key requirements:

- Full air-gap capability — no mandatory internet connectivity
- Cryptographically provenance-linked, **tamper-evident** audit trail
- Personal vaults remain isolated from org vault (separate cryptographic domains)
- Least-privilege blast-radius limiting via collections, **server-enforced** (not advisory)
- Member offboarding with clean key revocation that protects past secrets
- Deployable without an IdP, SSO provider, or cloud dependency

---

## Architecture Overview

An org vault is a second git repository alongside each member's personal vault. Personal and org vaults are cryptographically isolated — the personal vault's two-factor KDF (passphrase + image → Argon2id → master key) is completely untouched by org operations.

```
Personal:  ~/.config/relicario/personal/  → personal git repo  (passphrase + image → Argon2id → master key)
Org:       ~/.config/relicario/acme-org/  → org git repo       (org master key, wrapped per-member)
```

**Two cryptographic domains, one CLI and one extension.**

The org vault uses a random 256-bit **org master key** to encrypt all org items and the org manifest. Each authorized member receives a copy of the org master key wrapped (ECIES: X25519 + XChaCha20-Poly1305) to their existing ed25519 device public key — converted to X25519 for the Diffie-Hellman step. To open the org vault, a member uses their device private key to unwrap their copy of the org master key, then decrypts items exactly as today.

**Two enforcement boundaries, working together:**

1. **Cryptographic** — only holders of a wrapped key can decrypt the org master key, and only the org master key can decrypt items. Revocation + key rotation re-encrypts everything under a fresh key.
2. **Git pre-receive hook** — every commit is signature-verified against `members.json`, and writes are authorized by role (for management files) or by **collection path segment** (for item files). This is what makes least-privilege real rather than advisory, and what makes the audit trail tamper-evident.

**Key security properties:**

- Member departure = delete their `keys/<member-id>.enc`, then `rotate-key` re-wraps the org key for remaining members **and re-encrypts every item blob**. A removed member who kept the old key and a clone can decrypt nothing written or rotated after their removal.
- Every write to the org repo is a **signed** git commit; the hook rejects unsigned commits and commits from non-members. The git log is the audit log, and its actor attribution comes from the **verified signing key**, not from spoofable commit-message text.
- Fully air-gapped: the org repo is just git, push/pull over SSH.
- A compromised org master key does not expose personal vault items.

**Phase 2 (not in this spec):** live SIEM streaming, SSO/SAML, LDAP/IdP member sync, HTTP management plane via the `relicario-server` relay skeleton, server-mediated read audit, and "hide value" (autofill without revealing plaintext).

---

## Data Model

The org repo has a defined on-disk schema. The pre-receive hook rejects pushes that violate it.

```
acme-org/
├── org.json                       # org identity: name, org_id, created_at, schema_version
├── members.json                   # user directory (unencrypted — roles are not secrets)
├── collections.json               # collection definitions
├── keys/
│   └── <member-id>.enc            # org master key wrapped to each member's X25519 public key
├── manifest.enc                   # encrypted org manifest (item index + collection membership)
└── items/
    └── <collection-slug>/
        └── <item-id>.enc          # encrypted item, stored UNDER its collection directory
```

**Collection-scoped item storage is load-bearing.** Items live under `items/<collection-slug>/<item-id>.enc`, not in a flat `items/` directory. The leading path segment is the collection slug, in cleartext, so the pre-receive hook can authorize a write by comparing the path's collection against the signing member's grants — *without* decrypting anything. (The original flat layout made this impossible: the item→collection mapping existed only inside the encrypted manifest the server cannot read.)

### `org.json`

```json
{
  "schema_version": 1,
  "org_id": "<16-char hex>",
  "display_name": "Acme Security",
  "created_at": 1748000000
}
```

### `members.json`

Public and unencrypted — readable without the org master key. Roles are not secrets; the key material is in `keys/`.

```json
{
  "schema_version": 1,
  "members": [
    {
      "member_id": "<16-char hex>",
      "display_name": "Alice",
      "role": "owner",
      "ed25519_pubkey": "ssh-ed25519 AAAA... ",
      "collections": ["prod-infra", "shared-tools"],
      "added_at": 1748000000,
      "added_by": "<member-id>"
    }
  ]
}
```

`role` is one of `owner`, `admin`, `member`. `collections` is the list of collection slugs this member is granted. `member_id` is a 16-char lowercase hex string generated from 64 bits of `OsRng` entropy — the same convention as `ItemId`/`FieldId` in `relicario-core/src/ids.rs`. `ed25519_pubkey` is the member's device public key in OpenSSH format; the hook canonicalizes it to a SHA-256 fingerprint (via `relicario_core::fingerprint`) for matching, so whitespace/comment differences do not lock a member out.

### `collections.json`

```json
{
  "schema_version": 1,
  "collections": [
    {
      "slug": "prod-infra",
      "display_name": "Production Infrastructure",
      "created_by": "<member-id>",
      "created_at": 1748000000
    }
  ]
}
```

Slugs are validated: non-empty, no `/`, no `.` (so they are safe single path segments).

### `keys/<member-id>.enc`

The org master key (32 bytes) encrypted with ECIES to the member's device key. Wrapped-blob layout: `ephemeral_x25519_pubkey(32) || version(1) || nonce(24) || ciphertext+tag`. The wrapping key is `SHA-256(dh_shared || ephemeral_pubkey || recipient_pubkey)`; all secret intermediates (shared secret, derived wrap key) are held in `Zeroizing`. The ed25519→X25519 conversion (SHA-512(seed)[:32] + RFC 7748 clamp for the scalar; birational Montgomery map for the point) is the standard one; its correctness was verified against ed25519-dalek's own reference test vector.

### `manifest.enc`

Encrypted with the org master key. Same shape as the personal vault manifest but each entry carries a `collection` slug. The manifest is the authoritative item index; item blobs carry no metadata.

### `items/<collection-slug>/<item-id>.enc`

Identical `.enc` format to personal vault items (XChaCha20-Poly1305, random 24-byte nonce, org master key used directly — no Argon2id). Item IDs follow the 16-char hex convention. The blob does not name its collection; the directory path does.

---

## Access Control

### Roles

| Role | Permissions |
|---|---|
| **Owner** | All operations. Add/remove admins and owners. Create/delete collections. Rotate org key. Transfer ownership. Delete org. |
| **Admin** | Add/remove **members** (not owners/admins). Create/delete collections. Grant/revoke collection access. Read all collections. |
| **Member** | Read/write items in granted collections only. Cannot see or write items in other collections. |

Role gating is enforced both client-side (the CLI refuses) and server-side (the hook rejects). An admin cannot mint an owner or admin — only an owner can.

### Collection Access

Grants are stored in the member's `collections` array in `members.json`. No separate ACL file. An admin edits the member record and commits; the hook validates the committing member's role.

### Enforcement Layers

1. **Manifest filtering (read)** — the CLI and extension filter the decrypted manifest to entries whose `collection` is in the authenticated member's grant list. Members never see items for collections they are not granted.

2. **Pre-receive hook (write)** — for `items/<slug>/<id>.enc`, the hook requires `<slug>` to be in the signing member's grants. For `members.json` / `collections.json` / `org.json`, it requires owner/admin role. Every commit must additionally carry a **valid signature** from a current member. This makes both confidentiality *and integrity* of collections server-enforced.

### Known Limitations (honest)

- **Shared org master key — reads are not cryptographically scoped per collection.** Every member holds the *same* org master key (wrapped to their device key). The hook scopes *writes* by collection path and the client filters the *manifest* on read, but the cryptography itself does not partition reads: a member who obtains the raw ciphertext of an item in a collection they were not granted can still decrypt it, because the one org key opens everything. Collection grants are therefore an access-control boundary (enforced by the hook on write and by manifest filtering + optional git-host directory read-ACLs on fetch), not a cryptographic one. For *cryptographic* separation, put the sensitive material in a **separate org vault**. Per-collection subkeys are an explicit non-goal for this phase.
- **No read audit.** Git commits record writes, not reads. A member decrypting an item without writing leaves no git trace. Read audit needs a server that mediates fetch — phase 2.
- **No "hide value."** Autofill-without-revealing requires per-item subkeys or a mediating relay — phase 2.

---

## Org Items (CRUD)

The org vault stores secrets via `relicario org` item commands that mirror the personal-vault item model (`Item`, `ItemCore`, the typed builders) but operate on the org repo and enforce collection grants.

```
relicario org add  --collection <slug> <type> [type-specific flags]
relicario org get  --collection <slug> <query> [--show] [--copy]
relicario org list [--collection <slug>] [--type <t>]
relicario org edit --collection <slug> <query>
relicario org rm | restore | purge --collection <slug> <query>
```

Every item operation:

1. Requires the caller's `current_member()` to have `<slug>` in their grants, and `<slug>` to exist in `collections.json`.
2. Reads/writes `items/<slug>/<id>.enc` with the org master key.
3. Upserts/removes the `OrgManifestEntry` (with `collection = <slug>`) and re-encrypts `manifest.enc`.
4. Commits with the structured trailer block, emitting the matching `item-*` action.

`get`/`list` apply manifest filtering so a member only sees their granted collections; secret fields are masked unless `--show`. Trash uses `trashed_at` like the personal vault.

---

## Admin Operations

All org management uses `relicario org <subcommand>`. Command bodies live in `commands/org.rs` as `run_<verb>`.

```
relicario org init --name "Acme Security"          # create org repo, generate org key, add caller as owner, configure signing
relicario org add-member --key <openssh-pubkey> --name Alice --role member
relicario org remove-member <member-id>            # delete key blob; prompts to run rotate-key
relicario org set-role <member-id> admin|member
relicario org create-collection <slug> --name "..."
relicario org grant <member-id> <slug>
relicario org revoke <member-id> <slug>
relicario org rotate-key                           # new org key: re-wrap for members AND re-encrypt all items + manifest
relicario org transfer-ownership <member-id>       # owner → another member (owner only; caller demoted to admin unless --keep-owner)
relicario org delete-org                           # owner only; explicit confirmation; LOCAL tombstone only (see caveat below)
relicario org status                               # members, roles, collections — no decryption
relicario org audit [--since ..] [--member ..] [--collection ..] [--action ..] [--format json]
```

> **`delete-org` caveat (phase 1):** the pre-receive hook rejects deletion of the protected JSON files (`members.json` / `collections.json` / `org.json`) as part of schema-monotonicity enforcement. Therefore phase-1 `delete-org` is a **local tombstone only** — it removes the org files in the working tree and records a delete commit locally, but that commit **cannot be pushed to a hook-protected remote**. Pushing org teardown to a protected remote (a hook-side "owner may delete" exception) is a tracked phase-2 follow-up. `transfer-ownership` is fully hook-compatible (it only mutates `members.json` roles, owner-signed).

### Onboarding Flow

1. Alice runs `relicario device add`, exports her ed25519 public key (`signing.pub`).
2. Alice sends her public key to an admin out-of-band (Signal, email, printed QR — Relicario does not mediate key exchange).
3. Admin runs `org add-member --key <pubkey> --name Alice`. (An admin may add only `member` role; promoting to admin/owner requires an owner.)
4. Alice pulls the org repo. She can now open the org vault.

### Offboarding Flow

1. Admin runs `org remove-member <id>` (deletes the key blob, updates `members.json`).
2. Admin runs `org rotate-key` — generates a new org key, re-wraps it for remaining members, and **re-encrypts every item blob and the manifest** under the new key.
3. The former member, even with the old key and a clone, can decrypt nothing post-rotation.

### Signing

`org init` calls `configure_git_signing(org_root, device_name)` so the org repo signs commits with the device's ed25519 key. All org writes are signed; the hook rejects anything else.

### Extension — Org Context

The vault tab gains a top-level org switcher (Personal + each configured org). Switching loads the selected org's manifest through the service worker. The SW holds the unwrapped org master key in a `Zeroizing` session handle — identical to the personal master key. The org master key is **never** written to `localStorage`, `IndexedDB`, or any persistent browser storage. If the git remote is unreachable, the org context is read-only with an "org offline — writes disabled" indicator. Phase-1 extension scope is: org switching, browsing/reading org items (grant-filtered), and the parity acceptance tests; full in-extension org item editing may be a tracked follow-up if it balloons.

---

## Audit Trail

### Git Log as Tamper-Evident Audit Record

Every write is a signed git commit carrying structured trailers:

```
add item to prod-infra collection

Relicario-Actor: alice <a1b2c3d4e5f6a1b2>
Relicario-Action: item-create
Relicario-Collection: prod-infra
Relicario-Item: 9f8e7d6c5b4a3f2e
```

**Trailers are advisory, not authoritative.** A malicious committer can write any trailer text. The trustworthy actor identity is the **verified signing key**: `relicario org audit` resolves each commit's signature fingerprint to a `members.json` entry and reports that as the actor. Where the trailer's claimed actor disagrees with the verified signer, the commit is flagged `TAMPERED`. Timestamps use the committer date (`%cI`).

### Action Vocabulary

| `Relicario-Action` | Trigger |
|---|---|
| `item-create` / `item-update` / `item-delete` / `item-restore` / `item-purge` | org item add / edit / trash / restore / purge |
| `member-add` / `member-remove` / `member-role-change` | member management |
| `collection-create` / `collection-grant` / `collection-revoke` | collection management |
| `key-rotate` | org key rotation |
| `org-init` / `ownership-transfer` / `org-delete` | org lifecycle |

### `relicario org audit`

Parses `git log` (record separator `%x1e`, field separator `%x1f` to survive multi-line trailer values), resolves signer→member, applies `--since/--member/--collection/--action` filters, and emits a table or, with `--format json`, a JSON array ready for `… | <siem-ingest>` via cron. Each event includes the verified actor, action, collection, item, commit, committer timestamp, and a `tampered` flag.

---

## Pre-Receive Hook (`relicario-server verify-org-commit`)

`relicario-server` gains an org mode. For each pushed commit it:

1. **Verifies the signature** by building a temporary `allowed_signers` from `members.json` ed25519 keys, injecting `gpg.ssh.allowedSignersFile` via `GIT_CONFIG_*`, running `git verify-commit --raw`, and parsing the `SHA256:` fingerprint from stderr — the same mechanism the existing `verify-commit` uses. A commit with no good signature, or whose signer is not a current member, is rejected. (Bare `git %GF` is **not** used — it returns empty without an allowed-signers file.)
2. **Authorizes the change** by inspecting `git diff-tree` paths:
   - `members.json` / `collections.json` / `org.json` → signer must be owner/admin; a `member-role-change` granting owner/admin must be signed by an owner. The signer's authority here is judged on their role in the **parent** commit (their pre-change role), never the post-change role carried in the commit under verification — otherwise an Admin could self-promote to Owner in one commit and have the gate read the already-elevated role and self-authorize. A signer absent from the parent has no prior authority and is rejected. (Genesis is the sole exception — see §4 below.)
   - `items/<slug>/<id>.enc` → `<slug>` must be in the signing member's grants.
3. **Validates schema** — `schema_version` must not decrease for any of the three JSON files (compared against `{commit}^:<file>`), and `members.json`/`collections.json` must pass `validate()`.
4. **Handles genesis and merges** — the root commit (no parent) is the org-init genesis: it is allowed if signed by the sole owner it introduces. Merge commits are rejected (org history is linear) to avoid first-parent-only diff blind spots.

`relicario-server generate-org-hook` emits the wrapper script that runs `verify-org-commit` per pushed commit.

---

## Error Handling

### Key Rotation Race

`rotate-key` does `git pull --rebase` first. If the pull surfaces a non-fast-forward / conflict (a concurrent rotation), it aborts with `"Concurrent key rotation detected — pull and re-run org rotate-key."` A missing remote (local-only org) is distinguished and does not abort.

### Org Repo Schema Invalid

If `members.json`/`collections.json` fail validation on pull, the CLI refuses to open the org vault with a clear error. No silent degradation.

### Member Device Key Lost

If a member loses their device key before a backup device was added, an owner re-wraps the org key to a replacement device key the member generates. No master key escrow is needed — owners hold the org key and can always re-grant.

### Extension Offline

If the git remote is unreachable, the extension serves read-only from the last-pulled state and blocks writes with an indicator. Identical to personal vault offline behavior.

---

## Testing Strategy

### Unit Tests (`relicario-core`)

- Org key wrap/unwrap round-trip (ed25519→X25519 + XChaCha20-Poly1305), including a pinned RFC 8032 known-answer vector so a future crate-version regression in the birational map is caught.
- Manifest filtering by collection grant list.
- `members.json` / `collections.json` schema validation (valid + invalid).
- Secret intermediates are `Zeroizing` (compile-level).

### Integration Tests (`relicario-cli`)

- Full lifecycle against a local bare git repo: `org init → add-member → create-collection → grant → org add (item write) → audit` — verifying the item lands at `items/<slug>/<id>.enc` and the audit attributes the verified signer.
- `remove-member → rotate-key` → former member cannot decrypt a re-encrypted item; remaining member can.
- Grant enforcement: a member without a collection grant is refused `org add/get` for it.
- `org audit --format json` is valid JSON matching the action vocabulary; a forged-trailer commit is flagged `TAMPERED`.
- Concurrent `rotate-key` race aborts with the spec error string.

### Hook Tests (`relicario-server`)

- Unsigned commit rejected; commit signed by a non-member rejected.
- Item write to an ungranted collection path rejected; to a granted one accepted.
- Protected-file write by a member (non-admin) rejected.
- `schema_version` decrease rejected. Genesis commit accepted; merge commit rejected.

### Extension Tests (vitest)

- SW org context switching replaces the personal manifest cleanly (no cross-contamination).
- Org master key lives only in the Zeroizing session — never in `localStorage`/`IndexedDB`.
- Offline read-only mode triggers on a git network error.

Org crypto bypasses Argon2id (key wrapping is X25519-based), so the fast-Argon2id test-params convention is irrelevant to org tests; standard params apply only where shared fixtures touch the personal path.

---

## Living-Docs Impact

This feature introduces new on-disk formats, a new crypto path, and a new dependency, so the following docs must be updated as the work lands (per CLAUDE.md living-docs discipline):

- `docs/FORMATS.md` — the four org JSON files, the `keys/<id>.enc` wrapped-blob layout, and `items/<slug>/<id>.enc`.
- `docs/CRYPTO.md` — the ECIES org-key wrap/unwrap path and key-rotation re-encryption.
- `DESIGN.md` — org-master-key row in the secrets map; the `x25519-dalek` dependency; relicario-server org mode.
- `docs/SECURITY.md` — org device-key auth, the signature-verifying hook, and the honest limitations above.
- `crates/relicario-core/ARCHITECTURE.md` and `crates/relicario-cli/ARCHITECTURE.md` — the new `org` modules.
- `STATUS.md` / `ROADMAP.md` — the org-vault track and any tracked follow-ups (e.g. full extension org editing, SSO/LDAP).

---

## Phase Boundary

This spec covers phase 1 (git-native org, CLI + extension parity). Phase 2 adds:

- HTTP management plane via `relicario-server` (relay skeleton → org API)
- Live audit event streaming to SIEM (webhooks, not cron-poll)
- SSO/SAML assertion validation + LDAP/IdP member sync
- Server-mediated read audit
- "Hide value" autofill (per-item subkeys or server-mediated relay)
- Per-collection cryptographic isolation (subkeys — explicit non-goal for phase 1)
- Pushable `delete-org` org teardown (a hook-side "owner may delete protected files" exception); phase-1 `delete-org` is a local tombstone only
