349 lines
15 KiB
Markdown
349 lines
15 KiB
Markdown
# 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).
|
|
|
|
**Next:** `docs/superpowers/specs/2026-05-02-relay-server-design.md` (relay server — future phase 2 management plane)
|
|
|
|
---
|
|
|
|
## 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 audit trail
|
|
- Personal vaults remain isolated from org vault (separate cryptographic domains)
|
|
- Member offboarding with clean key revocation
|
|
- 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 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.
|
|
|
|
**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.
|
|
- 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.
|
|
|
|
---
|
|
|
|
## 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/
|
|
└── <item-id>.enc # encrypted items (identical format to personal vault items)
|
|
```
|
|
|
|
### `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": "<base64>",
|
|
"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 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`.
|
|
|
|
### `collections.json`
|
|
|
|
```json
|
|
{
|
|
"schema_version": 1,
|
|
"collections": [
|
|
{
|
|
"slug": "prod-infra",
|
|
"display_name": "Production Infrastructure",
|
|
"created_by": "<member-id>",
|
|
"created_at": 1748000000
|
|
}
|
|
]
|
|
}
|
|
```
|
|
|
|
### `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.
|
|
|
|
### `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.
|
|
|
|
### `items/<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.
|
|
|
|
---
|
|
|
|
## Access Control
|
|
|
|
### Roles
|
|
|
|
| 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. |
|
|
|
|
### 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.
|
|
|
|
### Enforcement Layers
|
|
|
|
Access control is enforced at two layers:
|
|
|
|
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** — rejects pushes where the committing device's member record does not have write access to the collections whose item files are modified.
|
|
|
|
### Known Limitation
|
|
|
|
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.
|
|
|
|
### "Hide value" (phase 2)
|
|
|
|
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.
|
|
|
|
---
|
|
|
|
## Admin Operations
|
|
|
|
All org management uses `relicario org <subcommand>`.
|
|
|
|
```
|
|
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 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.
|
|
```
|
|
|
|
### Onboarding Flow
|
|
|
|
1. Alice runs `relicario device add`, exports her ed25519 public key.
|
|
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`.
|
|
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.
|
|
|
|
### 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.
|
|
|
|
---
|
|
|
|
## Audit Trail
|
|
|
|
### Git Log as Audit Record
|
|
|
|
Every write to the org repo is a git commit. Commits use structured trailers for machine-readable audit:
|
|
|
|
```
|
|
add item to prod-infra collection
|
|
|
|
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`.
|
|
|
|
### 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` |
|
|
|
|
### `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.
|
|
|
|
---
|
|
|
|
## Pre-Receive Hook Policy Enforcement
|
|
|
|
`relicario-server` validates on every push to the org repo:
|
|
|
|
- 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.
|
|
|
|
---
|
|
|
|
## Error Handling
|
|
|
|
### 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.
|
|
|
|
### 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.
|
|
|
|
### 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.
|
|
|
|
### 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.
|
|
|
|
---
|
|
|
|
## Testing Strategy
|
|
|
|
### Unit Tests (`relicario-core`)
|
|
|
|
- Org master key wrap/unwrap round-trip: ed25519 → X25519 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.
|
|
|
|
### 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.
|
|
|
|
### 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.
|
|
|
|
---
|
|
|
|
## Phase Boundary
|
|
|
|
This spec covers phase 1 (git-native org). 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)
|
|
- "Hide value" autofill (per-item subkeys or server-mediated relay)
|
|
- Per-collection cryptographic isolation (subkeys — explicit non-goal for phase 1)
|