docs(org): pre-stage A5 living-docs for merged core+server+CLI-admin (item-CRUD/extension TODO)
Pre-stages the A5 living-docs sweep for the already-merged A (relicario-core org module) + C (relicario-server pre-receive hook) + CLI admin/rotate/status-audit work, so the final A5 sweep (after Dev-B B9-B14 merges) is fast. Adds org sections to docs/FORMATS.md (org repo wire formats + wrapped-key blob layout), docs/CRYPTO.md (ECIES X25519 wrap/unwrap, no-Argon2id contrast, rotate re-encryption), docs/SECURITY.md (signature-verifying hook, owner-only elevation, audit vocabulary, honest limitations), DESIGN.md (org-master-key secrets row + server org mode + deps), core/cli ARCHITECTURE.md (org module + org_session), and an Unreleased CHANGELOG entry. B item-CRUD (org add/get/list/edit/rm/restore/purge + main.rs wiring) and extension parity are left as explicit TODO. STATUS/ROADMAP mark-shipped and extension/ARCHITECTURE are deferred to the full A5 (track not yet landed; Dev-D deferred). All cited code constants pinned with file:line per living-docs discipline. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TJo44YM3UbBjro2fG6NrKy
This commit is contained in:
151
docs/CRYPTO.md
151
docs/CRYPTO.md
@@ -123,6 +123,157 @@ master_key ────────►│ XChaCha20 │──────
|
||||
└──────────────────┘
|
||||
```
|
||||
|
||||
## Org-key ECIES wrap/unwrap
|
||||
|
||||
Org vaults use a different key-derivation path than personal vaults. There is no
|
||||
passphrase, no reference JPEG, and no Argon2id involved. Instead, each org has a
|
||||
single random **org master key** that is wrapped per-member using X25519 ECIES and
|
||||
stored as an opaque blob in `keys/<member-id>.enc` inside the org repo.
|
||||
|
||||
### Org master key
|
||||
|
||||
```
|
||||
generate_org_key() (org.rs:230)
|
||||
→ OsRng → 256-bit random
|
||||
→ Zeroizing<[u8; 32]> (held in memory; never written in the clear)
|
||||
```
|
||||
|
||||
One org key per org. It is re-generated on every `org rotate-key` operation.
|
||||
|
||||
### ed25519 → X25519 conversion
|
||||
|
||||
Each Relicario device holds an ed25519 signing key. To participate in ECIES the
|
||||
ed25519 key pair must be mapped to X25519:
|
||||
|
||||
```
|
||||
Recipient public key (for wrap):
|
||||
ed25519 VerifyingKey
|
||||
→ .to_montgomery() (birational Montgomery map, ed25519_dalek)
|
||||
→ X25519 PublicKey
|
||||
|
||||
Recipient secret key (for unwrap):
|
||||
ed25519 seed (32 bytes)
|
||||
→ SHA-512(seed)[..32] (org.rs:241–242)
|
||||
→ RFC 7748 clamp:
|
||||
scalar[0] &= 248
|
||||
scalar[31] &= 127
|
||||
scalar[31] |= 64
|
||||
→ x25519_dalek::StaticSecret
|
||||
```
|
||||
|
||||
The RFC 7748 clamp and the `to_montgomery()` birational map are the standard
|
||||
construction; a pinned RFC 8032 known-answer vector is verified in the unit tests
|
||||
inside `org.rs`.
|
||||
|
||||
### Wrap flow (one blob per member)
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────┐
|
||||
│ wrap_org_key() │ (org.rs:265)
|
||||
│ │
|
||||
org_key ──────────►│ EphemeralSecret::random (OsRng) │
|
||||
│ ephemeral_pk = PublicKey::from(eph) │
|
||||
│ │
|
||||
recipient_pk ─────►│ DH: eph_sk.diffie_hellman(rec_pk) │
|
||||
│ → dh_shared (32 bytes) │
|
||||
│ │
|
||||
│ kdf_input = dh_shared │
|
||||
│ ‖ ephemeral_pk (32 B) │ (org.rs:278–281)
|
||||
│ ‖ recipient_pk (32 B) │
|
||||
│ wrap_key = SHA-256(kdf_input) │
|
||||
│ (kdf_input in Zeroizing<Vec<u8>>) │
|
||||
│ (wrap_key in Zeroizing<[u8;32]>) │
|
||||
│ │
|
||||
│ encrypted = crate::crypto::encrypt │
|
||||
│ (wrap_key, org_key) │
|
||||
│ → version(1) ‖ nonce(24) ‖ ct+tag │
|
||||
│ │
|
||||
│ output: ephemeral_pk(32) │ (org.rs:264)
|
||||
│ ‖ version(1) │
|
||||
│ ‖ nonce(24) │
|
||||
│ ‖ ciphertext + tag │
|
||||
└──────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
keys/<member-id>.enc (in org repo)
|
||||
```
|
||||
|
||||
### Unwrap flow
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────┐
|
||||
│ unwrap_org_key() │ (org.rs:299)
|
||||
│ │
|
||||
wrapped blob ─────►│ split: ephemeral_pk(32) + rest │
|
||||
│ │
|
||||
ed25519_seed ─────►│ ed25519_seed_to_x25519_secret() │
|
||||
│ → recipient_sk + recipient_pk │
|
||||
│ │
|
||||
│ DH: recipient_sk.diffie_hellman(eph)│
|
||||
│ → dh_shared │
|
||||
│ │
|
||||
│ kdf_input + SHA-256 → wrap_key │
|
||||
│ (same domain-separated KDF as wrap) │
|
||||
│ │
|
||||
│ plaintext = crate::crypto::decrypt │
|
||||
│ (wrap_key, rest) │
|
||||
│ → Zeroizing<[u8;32]> org_key │
|
||||
└──────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Key distinction: no Argon2id
|
||||
|
||||
Unlike the personal vault, **org crypto bypasses Argon2id entirely**:
|
||||
|
||||
| | Personal vault | Org vault |
|
||||
|---|---|---|
|
||||
| Key origin | Argon2id(passphrase ‖ image_secret, salt) | OsRng → 256-bit random |
|
||||
| Key transport | Embedded in reference JPEG (stego) | X25519 ECIES wrap blob |
|
||||
| AEAD primitive | XChaCha20-Poly1305 (`crate::crypto::encrypt`) | Same primitive (delegated) |
|
||||
| KDF for wrap key | Argon2id | SHA-256(DH ‖ eph_pk ‖ rec_pk) |
|
||||
|
||||
The inner AEAD (`crate::crypto::encrypt` / `decrypt`) is **not re-implemented** in
|
||||
the org module — it is called directly, so org item blobs share the identical
|
||||
`version(1) ‖ nonce(24) ‖ ct+tag` wire format (`VERSION_BYTE = 0x02`,
|
||||
`crates/relicario-core/src/crypto.rs:59`).
|
||||
|
||||
### Zeroize discipline
|
||||
|
||||
All intermediates that carry key material are dropped through `Zeroizing`:
|
||||
|
||||
- `org_key` — `Zeroizing<[u8; 32]>` everywhere it is passed
|
||||
- `kdf_input` — `Zeroizing<Vec<u8>>` (org.rs:278)
|
||||
- `wrap_key` — `Zeroizing<[u8; 32]>`
|
||||
- decrypt `plaintext` in `unwrap_org_key` — `Zeroizing<Vec<u8>>`
|
||||
|
||||
### Key rotation and re-encryption
|
||||
|
||||
`org rotate-key` (`crates/relicario-cli/src/commands/org.rs:332`) does more than
|
||||
generate a fresh org key:
|
||||
|
||||
```
|
||||
run_rotate_key()
|
||||
1. git pull --rebase (detect concurrent rotation → abort if non-fast-forward)
|
||||
2. generate_org_key() → new_org_key
|
||||
3. wrap_org_key(new_org_key, member_pk) for every current member
|
||||
→ overwrites keys/<member-id>.enc
|
||||
4. re-encrypt every items/<slug>/<id>.enc blob under new_org_key
|
||||
5. re-encrypt manifest.enc under new_org_key
|
||||
6. git add + git commit via org_git_run (signed; Relicario-Action: key-rotate)
|
||||
```
|
||||
|
||||
`rotate-key` pulls (`--rebase`) at the start to pick up concurrent changes and
|
||||
abort on a conflicting concurrent rotation, then commits locally; it does **not**
|
||||
push. Publishing the rotation to the remote is a separate step (the normal git
|
||||
sync path), the same way personal-vault mutations commit locally and sync later.
|
||||
|
||||
Re-encryption of every item blob (step 4) is deliberate: a removed member who holds
|
||||
a local clone of the repo cannot decrypt any item written after the rotation, because
|
||||
those blobs are sealed under a key they never received. Without re-encryption, all
|
||||
pre-rotation blobs would remain readable to the former member indefinitely.
|
||||
|
||||
> **TODO (pending Dev-B B9–B14):** item-CRUD commands (`org add`/`get`/`list`/`edit`/`rm`/`restore`/`purge`) and the final `Commands::Org` wiring in `main.rs` are not yet merged.
|
||||
|
||||
## imgsecret DCT Embedding
|
||||
|
||||
```
|
||||
|
||||
@@ -71,6 +71,60 @@ An empty array (`[]`) puts the pre-receive hook in bootstrap mode (all pushes ac
|
||||
|
||||
Commits by `public_key` at or after `revoked_at` (Unix seconds) are rejected by the pre-receive hook. Commits before `revoked_at` remain valid (they were authorized at the time).
|
||||
|
||||
## Org vault repo formats
|
||||
|
||||
The org vault is a **separate git repository** alongside the personal vault. It is not nested inside `.relicario/`. Its layout:
|
||||
|
||||
```
|
||||
org.json # OrgMeta (schema_version, org_id, display_name, created_at)
|
||||
members.json # PUBLIC/unencrypted member directory
|
||||
collections.json # collection definitions
|
||||
keys/<member-id>.enc # org master key wrapped to that member's device key
|
||||
manifest.enc # OrgManifest (schema_version 1, per-member-filtered)
|
||||
items/<collection-slug>/<item-id>.enc # collection-scoped item blobs
|
||||
```
|
||||
|
||||
### `org.json` — OrgMeta
|
||||
|
||||
Unencrypted JSON (`OrgMeta`, `org.rs:164`). `schema_version: 1` (`org.rs:174`). Fields: `schema_version`, `org_id`, `display_name`, `created_at` (Unix seconds).
|
||||
|
||||
### `members.json` — OrgMembers
|
||||
|
||||
Unencrypted JSON array of `OrgMember` records (`org.rs:72`); container type `OrgMembers` carries `schema_version: 1` (`org.rs:93`). Per-member fields: `member_id` (16 lowercase hex chars), `display_name`, `role` (one of `owner | admin | member`), `ed25519_pubkey` (OpenSSH wire string), `collections` (array of granted slug strings), `added_at`, `added_by`. Roles are not secrets — authorization to read this file is not required to verify signatures.
|
||||
|
||||
### `collections.json` — OrgCollections
|
||||
|
||||
Unencrypted JSON; `schema_version: 1` (`org.rs:138`). Contains a list of `CollectionDef` records (`org.rs:123`). Validation (`org.rs:145`) rejects slugs that are empty, contain `/`, or equal `.`.
|
||||
|
||||
### `keys/<member-id>.enc` — wrapped org master key
|
||||
|
||||
Binary blob; NOT a standard `.enc` blob. Layout (`org.rs:264`):
|
||||
|
||||
```
|
||||
┌──────────────────────────┬─────────┬────────┬──────────────────────┐
|
||||
│ ephemeral_x25519_pubkey │ version │ nonce │ ciphertext + tag │
|
||||
│ 32 bytes │ 1 byte │24 bytes│ N + 16 bytes │
|
||||
└──────────────────────────┴─────────┴────────┴──────────────────────┘
|
||||
```
|
||||
|
||||
- The wrapping key is `SHA-256(dh_shared || ephemeral_pubkey || recipient_pubkey)` (`org.rs:278–281`), held in `Zeroizing<Vec<u8>>`.
|
||||
- The inner AEAD (`version || nonce || ciphertext+tag`) is produced by `crate::crypto::encrypt` — the same XChaCha20-Poly1305 framing used for personal `.enc` blobs (see **Encrypted blob** above). `VERSION_BYTE = 0x02` applies here too.
|
||||
- The X25519 private scalar is derived from the device ed25519 seed via `SHA-512(seed)[..32]` with RFC 7748 clamping (`org.rs:242`). Argon2id is **not** involved — the wrapping key is derived entirely from the X25519 DH exchange.
|
||||
|
||||
### `manifest.enc` — OrgManifest
|
||||
|
||||
Encrypted with the org master key using `crypto::encrypt` (standard `.enc` framing). Decrypts to `OrgManifest` JSON (`org.rs:199`); `schema_version: 1` (`org.rs:206`). Each `OrgManifestEntry` (`org.rs:185`) carries: `id`, `type`, `title`, `tags`, `modified`, `trashed_at`, and a `collection` slug field. The `collection` field distinguishes this type from `ManifestEntry` in the personal vault.
|
||||
|
||||
Contrast with the personal vault manifest: `Manifest` uses `MANIFEST_SCHEMA_VERSION = 2` (`manifest.rs:12`) and `ManifestEntry` has no `collection` field. The two types are distinct and do not share a schema.
|
||||
|
||||
### `items/<collection-slug>/<item-id>.enc`
|
||||
|
||||
Standard `.enc` blob (see **Encrypted blob** above), encrypted under the org master key. The blob itself does **not** name its collection — the directory path segment carries the slug. This allows the pre-receive hook (`relicario-server`) to authorize a write by path segment without decrypting the blob.
|
||||
|
||||
**TODO (pending Dev-B B9–B14):** CLI commands for creating, reading, editing, and deleting org items (`org add` / `get` / `list` / `edit` / `rm` / `restore` / `purge`) are not yet wired in `main.rs`.
|
||||
|
||||
**TODO (extension follow-up):** extension UI for browsing and editing org vault items.
|
||||
|
||||
## Item IDs and Field IDs
|
||||
|
||||
| Kind | Length | Entropy | Source |
|
||||
|
||||
111
docs/SECURITY.md
111
docs/SECURITY.md
@@ -74,6 +74,117 @@ Without device authentication, access control is transport-layer only:
|
||||
|
||||
Device registration is optional but recommended for shared vaults.
|
||||
|
||||
## Org vault security
|
||||
|
||||
An org vault is a separate git repository alongside the personal vault. It
|
||||
uses ed25519 commit-signing and a server-side pre-receive hook to make
|
||||
least-privilege access control server-enforced, not advisory.
|
||||
|
||||
### Org device-key authentication
|
||||
|
||||
Every org member registers an ed25519 device key. The key appears in
|
||||
`members.json` as an OpenSSH public-key string alongside the member's role
|
||||
and collection grants. Fingerprint matching is done via
|
||||
`relicario_core::fingerprint`, which normalises the OpenSSH format so that
|
||||
whitespace and comment differences do not create phantom mismatches.
|
||||
|
||||
Org access requires two things at once: a wrapped key blob (`keys/<member-id>.enc`)
|
||||
and the device private key that can unwrap it. There is no org passphrase —
|
||||
removing a member's blob and rotating the org master key is sufficient to
|
||||
revoke access (see **Key rotation** below). Device keys are completely
|
||||
separate from the personal vault's KDF inputs; revoking org access does not
|
||||
affect the member's personal vault.
|
||||
|
||||
### Pre-receive hook enforcement
|
||||
|
||||
`relicario-server generate-org-hook` (`crates/relicario-server/src/main.rs:511`)
|
||||
emits a hook script that calls `relicario-server verify-org-commit` for
|
||||
every pushed commit. Unsigned or structurally invalid commits are rejected
|
||||
before they land.
|
||||
|
||||
`verify_org_commit` (`main.rs:286`) performs four checks in order:
|
||||
|
||||
1. **Signature verification** — a temporary `allowed_signers` file is
|
||||
constructed from the current `members.json`; `git verify-commit --raw`
|
||||
is run and the resulting SHA-256 fingerprint is matched back to a
|
||||
`members.json` entry. A commit not signed by a *current* member is
|
||||
rejected outright.
|
||||
|
||||
2. **Path-level write authorisation** — each modified path is classified by
|
||||
`classify_path` (`crates/relicario-server/src/lib.rs:19`) into
|
||||
`ProtectedJson` (owner/admin write only), `CollectionItem` (the
|
||||
`items/<slug>/…` prefix; write allowed only if the slug appears in the
|
||||
signer's `collections` grant array), or `Unrestricted`. The write is
|
||||
authorised if and only if the signer's role and grants satisfy the
|
||||
classification. Item blobs are authorised by the leading path segment
|
||||
alone — the ciphertext is never decrypted by the hook.
|
||||
|
||||
3. **Owner-only elevation guard** (`enforce_owner_only_elevation`,
|
||||
`main.rs:438`) — only a member whose *pre-commit* (parent) role is Owner
|
||||
may introduce a new member at Owner or Admin level, or promote an
|
||||
existing member to either. Checking the pre-commit role means an Admin
|
||||
cannot self-promote in the same commit that writes the escalated
|
||||
`members.json`; there is no epoch in which the transition is
|
||||
self-authorised.
|
||||
|
||||
4. **Schema monotonicity** (`enforce_schema_monotonicity`, `main.rs:521`)
|
||||
— `schema_version` values in org JSON containers may not decrease.
|
||||
Merge commits are rejected. A genesis commit (no parents) is allowed
|
||||
only when it is signed by the sole Owner it introduces.
|
||||
|
||||
### Key rotation
|
||||
|
||||
`relicario org rotate-key` generates a fresh 256-bit org master key,
|
||||
re-wraps it for every current member, and re-encrypts every
|
||||
`items/<slug>/<id>.enc` blob and the manifest under the new key in a single
|
||||
signed commit tagged `Relicario-Action: key-rotate`. A revoked member's
|
||||
wrapped blob is simply not written during rotation, so they hold a blob that
|
||||
decrypts to a stale key — they cannot read items encrypted under the new
|
||||
key.
|
||||
|
||||
### Audit action vocabulary
|
||||
|
||||
The `relicario org audit` command attributes actions to their verified
|
||||
signer (not to the commit author or trailer value). Each event records two
|
||||
actors: the **verified** actor resolved from the signing key (authoritative)
|
||||
and the actor **claimed** by the `Relicario-Actor` trailer (advisory). When the
|
||||
claimed actor disagrees with the verified signer, the event is flagged
|
||||
`TAMPERED`. Trailers are advisory metadata; the trustworthy actor is always
|
||||
the cryptographically verified signer.
|
||||
|
||||
Actions live in two groups:
|
||||
|
||||
- **Live (merged A + C streams):** `member-add`, `member-remove`,
|
||||
`member-role-change`, `collection-create`, `collection-grant`,
|
||||
`collection-revoke`, `key-rotate`, `org-init`, `ownership-transfer`,
|
||||
`org-delete`.
|
||||
- **TODO (pending Dev-B B9–B13):** `item-create`, `item-update`,
|
||||
`item-delete`, `item-restore`, `item-purge` — the emitter code lands with
|
||||
the item-CRUD command stream.
|
||||
|
||||
### Honest limitations
|
||||
|
||||
The following are deliberate design boundaries, not oversights:
|
||||
|
||||
- **Shared org master key — reads are not cryptographically scoped per
|
||||
collection.** The pre-receive hook scopes *writes* by collection path
|
||||
and the CLI filters the manifest to each member's grants, but a single
|
||||
org key opens all collection blobs. A member with any grant can, outside
|
||||
the CLI, decrypt items from collections they are not granted. For true
|
||||
cryptographic separation, use a separate org vault per access boundary.
|
||||
Per-collection subkeys are a phase-2 non-goal.
|
||||
|
||||
- **No read audit.** Git records writes only. A member who reads blobs
|
||||
directly leaves no server-visible trace.
|
||||
|
||||
- **No "hide value."** There is no mechanism to show a member that an item
|
||||
exists without revealing its field values on decrypt.
|
||||
|
||||
- **`delete-org` is a local tombstone in phase 1.** The schema-monotonicity
|
||||
check causes the hook to reject protected-file deletion, so an
|
||||
`org-delete` action cannot be pushed to a hook-protected remote. The
|
||||
deletion is recorded locally only until a future phase addresses it.
|
||||
|
||||
## Configuration env vars
|
||||
|
||||
Relicario reads the following environment variables. Each is a trust
|
||||
|
||||
Reference in New Issue
Block a user