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:
adlee-was-taken
2026-06-20 14:39:08 -04:00
parent 739279515a
commit 8bb1d779c4
7 changed files with 459 additions and 0 deletions

View File

@@ -71,6 +71,30 @@ under `src/commands/`. Each source file has one job.
hatches `RELICARIO_TEST_PASSPHRASE` (`session.rs:42`) and `RELICARIO_IMAGE`
(`session.rs:125`) that integration tests use to bypass the TTY.
- **`src/org_session.rs`** — `UnlockedOrgVault`, the org-vault analogue of
`session.rs`. Holds the org master key in `Zeroizing<[u8; 32]>` for one CLI
invocation, recovered by unwrapping `keys/<member-id>.enc` with the device
ed25519 seed (`relicario_core::unwrap_org_key`). Owns the **collection-scoped**
`item_path` (`items/<collection-slug>/<id>.enc` — the leading slug is what the
pre-receive hook authorizes against, never decrypting), fingerprint-based
member matching (`relicario_core::fingerprint`, tolerant of OpenSSH
whitespace/comment differences), `atomic_write`, and `org_git_run`. Note
`org_git_run` runs **bare git** — unlike `helpers::git_run` it does NOT inject
`commit.gpgsign=false`, because org commits MUST be signed (the hook verifies
every commit's signature); signing config is established by
`configure_git_signing` during `org init`.
- **`src/commands/org.rs`** — the `relicario org` subcommand surface. Merged:
`init`, `add-member` / `remove-member` / `set-role` (owner-only escalation
guard), `create-collection` / `grant` / `revoke`, `rotate-key`
(`run_rotate_key`, `commands/org.rs:332` — fresh key, re-wrap for all members,
re-encrypt every item blob + manifest under the new key, concurrent-rotation
abort), and `status` / `audit` (verified-signer attribution + `TAMPERED`
flag). **TODO (pending Dev-B B9B14):** the item-CRUD commands (`org add` /
`get` / `list` / `edit` / `rm` / `restore` / `purge`) and the final
`Commands::Org` wiring in `main.rs`. `device.rs` gains
`current_device_seed` / `current_device_pubkey` helpers for the ECIES unwrap.
- **`src/helpers.rs`** (`helpers.rs:1-101`) — pure, no-state plumbing:
`find_vault_dir_from` (`helpers.rs:14-28`) walks up parent directories
looking for a `.relicario/` marker; `vault_dir` and `relicario_dir` wrap it

View File

@@ -103,6 +103,26 @@ Pipeline" and "Crate Layout").
auth factor. Owns its own `YChannel`, `EmbedRegion`, 8×8 DCT/IDCT,
Quantization Index Modulation, and crop-recovery extractor. No other module
imports it; it is consumed only via the public re-export from `lib.rs`.
- **`org.rs`** — Org-vault data model and ECIES key-wrapping layer
(`crates/relicario-core/src/org.rs`). Types: `OrgId` (L15), `MemberId`
(L19; `is_valid` L41 — 16 lowercase hex), `OrgRole` (L54;
`can_manage_members` L61 = Owner | Admin, `can_manage_owners` L64 = Owner
only), `OrgMember` (L72; carries `ed25519_pubkey` in OpenSSH wire format,
`collections` grant list, `role`), `OrgMembers` (L86; `schema_version: 1`
L93; `validate` L104), `CollectionDef` (L123), `OrgCollections` (L131;
`schema_version: 1` L138; `validate` L145 rejects empty / `/` / `.` slugs),
`OrgMeta` (L164; `schema_version: 1` L174), `OrgManifestEntry` (L185;
carries `collection` slug plus id/type/title/tags/modified/trashed\_at),
`OrgManifest` (L199; `schema_version: 1` L206; `filter_for_member` L210
returns only entries whose collection slug appears in the member's grants).
All four JSON containers carry `schema_version: 1` — distinct from the
personal `Manifest` whose `MANIFEST_SCHEMA_VERSION = 2` (`manifest.rs:12`).
Crypto: `generate_org_key` (L230) → `Zeroizing<[u8;32]>` (256-bit
CSPRNG org master key); `wrap_org_key` (L265) / `unwrap_org_key` (L299) —
ECIES over X25519, described in detail under **Invariants & contracts**
below. `vault.rs` adds `encrypt_org_manifest` / `decrypt_org_manifest` typed
wrappers (JSON-serialize → `crypto::encrypt` under the org key, plaintext in
`Zeroizing`) consistent with the personal-vault pattern.
- **`backup.rs`** — `.relbak` v1 container format: `pack_backup` /
`unpack_backup` plus the `BackupInput` / `BackupOutput` / `BackupItem` /
`BackupAttachment` shapes. Wraps a zstd-compressed JSON envelope of vault
@@ -230,6 +250,28 @@ Pipeline" and "Crate Layout").
also used to derive the key for *unlock*, not just create).
- **`SymbolCharset::Custom` must be ASCII-only** (`generators.rs:46-52`).
Non-ASCII custom charsets are rejected with `RelicarioError::Format`.
- **ECIES wrap-blob layout is fixed** at
`ephemeral_x25519_pk(32) || version(1) || nonce(24) || ciphertext+tag`
(`org.rs:264`). The `version(1)` byte is the same `VERSION_BYTE = 0x02`
emitted by `crypto::encrypt`, which is what occupies that slot — the layout
merely names the regions for clarity.
- **KDF wrap key = `SHA-256(dh_shared || ephemeral_pk || recipient_pk)`**
(`org.rs:278-281`). The concatenation order is identical in `wrap_org_key`
and `unwrap_org_key`; a mismatch in either direction would produce a
different key and fail the AEAD open. The intermediate `kdf_input` buffer is
held in `Zeroizing<Vec<u8>>`; `org_key`, `wrap_key`, and the decrypted
`plaintext` from unwrap are also held in `Zeroizing`.
- **ed25519 → X25519 conversion** applies `SHA-512(seed)[..32]` then the
RFC 7748 scalar clamp
(`scalar[0] &= 248; scalar[31] &= 127; scalar[31] |= 64`) to derive the
private X25519 scalar (`org.rs:242`); the recipient public key is obtained
via `ed25519_dalek`'s `to_montgomery()`. This lets device ed25519 keys serve
double duty as X25519 recipients without storing a separate DH key.
- **Org crypto bypasses Argon2id.** The ECIES inner cipher delegates to
`crate::crypto::encrypt` / `decrypt` (XChaCha20-Poly1305, random 24-byte
nonce, `VERSION_BYTE = 0x02`) — no AEAD re-implementation. The X25519 KDF
output is used directly as the AEAD key; the Argon2id path in `crypto.rs`
is not invoked for org key wrapping.
## Key flows
@@ -315,6 +357,35 @@ when subsequent `decrypt_*` returns `RelicarioError::Decrypt`.
call `item.prune_history(&settings.field_history_retention, now_unix())`
when they want to enforce the policy.
### Org key wrap / unwrap
1. **Wrap** (`org.rs:265`): caller supplies a recipient's OpenSSH ed25519
public key string.
- Parse the OpenSSH wire format via `ssh-key` to recover the raw 32-byte
ed25519 public key bytes; apply `to_montgomery()` (ed25519-dalek) to
obtain the recipient's X25519 public key.
- Generate an ephemeral X25519 keypair from `OsRng`.
- `dh_shared = ephemeral_secret × recipient_x25519_pk` (X25519 DH).
- `wrap_key = SHA-256(dh_shared || ephemeral_pk || recipient_pk)`
(`org.rs:278-281`), intermediates in `Zeroizing`.
- `ct = crate::crypto::encrypt(&wrap_key, &org_key)` — yields the standard
`version(1) || nonce(24) || ciphertext+tag` blob.
- Return `ephemeral_x25519_pk(32) || ct` (`org.rs:264`).
2. **Unwrap** (`org.rs:299`): caller supplies the device ed25519 seed bytes
(from `current_device_seed` in the CLI layer, not from `relicario-core`).
- Derive X25519 private scalar from seed: `SHA-512(seed)[..32]` + RFC 7748
clamp (`org.rs:242`).
- Slice the first 32 bytes of the blob as `ephemeral_pk`; read recipient's
own X25519 public key via the same `to_montgomery()` path.
- `dh_shared = device_x25519_secret × ephemeral_pk`.
- Reconstruct `wrap_key` identically; `crypto::decrypt` recovers `org_key`
into `Zeroizing`.
Integration tests: `crates/relicario-core/tests/org.rs` (5 acceptance tests
covering wrap/unwrap round-trip, revoked-after-rotation, and manifest
`filter_for_member`). A pinned RFC 8032 ed25519→X25519 known-answer vector
lives in the `#[cfg(test)]` block inside `org.rs` itself.
### imgsecret embed
1. Caller passes a JPEG byte slice and a 32-byte secret to