Files
relicario/docs/superpowers/specs/2026-06-06-relicario-enterprise-org-vault-design.md
adlee-was-taken 519e503cbd docs(plan,spec): align enforce_owner_only_elevation to shipped parent-role authority
The plan's pre-receive-hook pseudocode judged owner-elevation authority on the
post-change `signer.role` (so a self-promoting Admin reads as Owner in the same
commit and self-authorizes the promotion — the exact escalation the gate exists
to stop). f249395 had fixed only the skip-predicate, leaving this final check
vulnerable. Align the plan's `enforce_owner_only_elevation` to the SHIPPED fix
(relicario-server/src/main.rs, aace6f1): derive `signer_may_manage_owners` from
`signer_parent = parent_role(signer.member_id)` (the signer's PRE-commit role;
None -> reject; genesis allowed) and gate on that, never the post-change role.

The spec was already policy-correct in prose ("a member-role-change granting
owner/admin must be signed by an owner") and did NOT carry the vulnerable
implementation detail; strengthened it with an explicit pre-commit-role note so
the design record pins the property and no one re-derives the vulnerable form.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TJo44YM3UbBjro2fG6NrKy
2026-06-20 13:45:04 -04:00

23 KiB

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

{
  "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/.

{
  "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

{
  "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 schemaschema_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