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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user