Files
relicario/docs/CRYPTO.md
adlee-was-taken ed50735e91 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
2026-06-20 14:39:08 -04:00

434 lines
25 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Relicario — Crypto Pipeline
> **Audience:** anyone evaluating or auditing the crypto. This doc owns Argon2id parameters and rationale, XChaCha20-Poly1305 rationale, vault creation/unlock flow diagrams, DCT-steganography embed and extract flows, and the high-level encrypted-file-format diagram. **Does NOT own:** byte-level schemas or JSON shapes (see [FORMATS.md](FORMATS.md)), attacker scenarios (see [SECURITY.md](SECURITY.md)), or per-module crypto implementation (see [../crates/relicario-core/ARCHITECTURE.md](../crates/relicario-core/ARCHITECTURE.md)).
## System Overview
```
┌──────────────────────────────────────────────────────────────────┐
│ CLIENT DEVICE (trusted) │
│ │
│ ┌─────────────┐ ┌──────────────┐ ┌──────────────────────┐ │
│ │ Reference │ │ Passphrase │ │ relicario-cli │ │
│ │ JPEG │ │ (typed) │ │ or browser ext │ │
│ │ (on disk) │ │ │ │ │ │
│ └──────┬───────┘ └──────┬───────┘ └──────────┬───────────┘ │
│ │ │ │ │
│ ▼ │ │ │
│ ┌──────────────┐ │ │ │
│ │ imgsecret │ │ │ │
│ │ ::extract() │ │ │ │
│ └──────┬───────┘ │ │ │
│ │ │ │ │
│ ▼ ▼ │ │
│ ┌──────────────────────────────┐ │ │
│ │ Argon2id KDF │ │ │
│ │ password = passphrase ‖ │ │ │
│ │ image_secret │ │ │
│ │ salt = vault_salt │ │ │
│ │ → master_key (32 bytes) │ │ │
│ └──────────────┬───────────────┘ │ │
│ │ │ │
│ ▼ │ │
│ ┌──────────────────────────────┐ │ │
│ │ XChaCha20-Poly1305 │◄──────────────────┘ │
│ │ encrypt / decrypt │ │
│ │ (192-bit nonce, 256-bit │ │
│ │ key, 128-bit auth tag) │ │
│ └──────────────┬───────────────┘ │
│ │ │
└─────────────────┼──────────────────────────────────────────────────┘
│ git push / pull (HTTPS or SSH)
┌──────────────────────────────────────────────────────────────────┐
│ GIT SERVER (untrusted) │
│ │
│ relicario-vault.git/ │
│ ├── manifest.enc ← opaque ciphertext │
│ ├── settings.enc ← opaque ciphertext │
│ ├── items/ │
│ │ ├── a1b2c3d4e5f6a7b8.enc ← opaque ciphertext │
│ │ └── … │
│ ├── attachments/ │
│ │ └── <item-id>/<aid>.enc ← opaque ciphertext │
│ └── .relicario/ │
│ ├── salt ← 32 bytes (not secret) │
│ ├── params.json ← KDF params (not secret) │
│ ├── devices.json ← device public keys (not secret) │
│ └── revoked.json ← revoked device records (not secret) │
│ │
│ The server sees NOTHING useful. No keys, no plaintext, │
│ no metadata about what's inside. │
└──────────────────────────────────────────────────────────────────┘
```
## Vault Creation Flow
```
User provides: System generates:
├── carrier JPEG (any photo) ├── image_secret (256-bit random)
└── passphrase (memorized) └── vault_salt (256-bit random)
┌──────────────────┐
carrier JPEG ──────►│ imgsecret │──────► reference.jpg
image_secret ──────►│ ::embed() │ (looks like a normal photo,
│ DCT stego │ carries hidden secret)
└──────────────────┘
┌──────────────────┐
passphrase ────────►│ │
│ Argon2id │──────► master_key (32 bytes)
image_secret ──────►│ (64 MiB, 3 it) │ (held in memory only)
vault_salt ────────►│ │
└──────────────────┘
┌──────────────────┐
master_key ────────►│ XChaCha20- │──────► manifest.enc
empty manifest ────►│ Poly1305 │ settings.enc
default settings ──►│ encrypt (×2) │ (parallel artifacts;
└──────────────────┘ independent nonces)
┌──────────────────┐
│ git init │──────► vault repo
│ git commit │ (ready to push)
└──────────────────┘
```
Item creation, the typed-item envelope (`Item` + per-type `ItemCore`),
attachment encryption, and field-history tracking are not shown above —
they are described in [`crates/relicario-core/ARCHITECTURE.md`](../crates/relicario-core/ARCHITECTURE.md).
The flow above covers only the crypto-pipeline shape that vault init
establishes; the per-item lifecycle reuses the same `master_key` +
XChaCha20-Poly1305 primitives against `items/<id>.enc` and
`attachments/<item-id>/<aid>.enc`.
## Unlock Flow (every vault operation)
```
┌──────────────────┐
reference.jpg ─────►│ imgsecret │──────► image_secret
│ ::extract() │ (256 bits)
└──────────────────┘
┌──────────────────┐
passphrase ────────►│ │
image_secret ──────►│ Argon2id │──────► master_key
vault_salt ────────►│ │
└──────────────────┘
┌──────────────────┐
master_key ────────►│ XChaCha20 │──────► plaintext entries
*.enc files ───────►│ ::decrypt() │ (in memory only)
└──────────────────┘
```
## 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:241242)
→ 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:278281)
│ ‖ 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 B9B14):** 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
```
Input JPEG
┌──────────────────┐
│ Decode to RGB │
│ Extract Y │
│ (luminance) │
└────────┬─────────┘
┌──────────────────────────────┐
│ Full Image │
│ ┌────────────────────────┐ │
│ │ 15% ┌────────────┐ │ │
│ │margin│ │15% │ │
│ │ │ Central │mar-│ │
│ │ │ 70% │gin │ │
│ │ │ EMBEDDING │ │ │
│ │ │ REGION │ │ │
│ │ └────────────┘ │ │
│ │ 15% margin │ │
│ └────────────────────────┘ │
└──────────────────────────────┘
┌──────────────────┐
│ Divide into │
│ 8×8 blocks │
│ Apply 2D DCT │
└────────┬─────────┘
┌──────────────────┐
│ For each │
│ selected block: │
│ │
│ QIM embed bits │
│ in zig-zag │
│ positions 6-17 │
│ (mid-frequency) │
│ │
│ Repeat secret │
│ MIN_COPIES (5) │
│ to 50 times, │
│ by capacity │
└────────┬─────────┘
┌──────────────────┐
│ Inverse DCT │
│ Reconstruct RGB │
│ Save JPEG (Q92) │
└──────────────────┘
Output: reference.jpg
(visually identical,
carries 256-bit secret)
```
The redundancy count is chosen at embed time based on available DCT capacity: `num_copies = (total_blocks / BLOCKS_PER_COPY).min(50)`, with `BLOCKS_PER_COPY = 22` and a floor of `MIN_COPIES = 5` (`crates/relicario-core/src/imgsecret.rs:78,530-537`). Images that cannot fit at least 5 copies are rejected before embed. Majority voting across these copies at extract time requires ≥ 60 % confidence per bit.
## Extraction (with crop recovery)
```
Input JPEG (possibly re-encoded or cropped)
┌─────────────────────────┐
│ Try canonical alignment │──── Success ──► image_secret
│ (offset 0,0) │
└─────────┬───────────────┘
│ Fail
┌─────────────────────────┐
│ Search crop offsets │
│ dx, dy: -15% to +15% │
│ step: 8 pixels │
│ (~16,800 candidates) │──── Success ──► image_secret
│ │
│ For each: extract bits, │
│ majority vote, check │
│ confidence ≥ 60% │
└─────────┬───────────────┘
│ All fail
ExtractionFailed error
```
## Encrypted File Format
```
┌─────────┬────────────────────────┬──────────────────┬──────────────────┐
│ version │ nonce │ ciphertext │ auth tag │
│ 1 byte │ 24 bytes │ N bytes │ 16 bytes │
│ 0x02 │ random per write │ XChaCha20 stream │ Poly1305 MAC │
└─────────┴────────────────────────┴──────────────────┴──────────────────┘
```
`VERSION_BYTE = 0x02` (`crates/relicario-core/src/crypto.rs:59`). Blobs starting with any other byte are rejected with `UnsupportedFormatVersion { found, expected: 0x02 }`. The legacy `0x01` format from the pre-typed-items era is no longer supported.
## Crate Architecture
```
┌────────────────────────────────────────────────────────────┐
│ relicario-cli │
│ Filesystem, git (shelling out), terminal I/O, clipboard │
│ │
│ Depends on: relicario-core, clap, anyhow, rpassword, arboard │
└──────────────────────┬─────────────────────────────────────┘
│ uses
┌────────────────────────────────────────────────────────────┐
│ relicario-core │
│ Platform-agnostic: bytes in, bytes out │
│ No filesystem, no network, no git │
│ │
│ ┌──────────┐ ┌──────────┐ ┌─────────┐ ┌────────────┐ │
│ │ crypto │ │ imgsecret│ │ item + │ │ vault │ │
│ │ │ │ │ │ types │ │ │ │
│ │ KDF │ │ DCT │ │ Item │ │ encrypt_ │ │
│ │ encrypt │ │ embed │ │ Manifest│ │ item() │ │
│ │ decrypt │ │ extract │ │ Settings│ │ decrypt_ │ │
│ │ │ │ QIM │ │ Backup │ │ manifest() │ │
│ │ │ │ │ │ Device │ │ ... │ │
│ └──────────┘ └──────────┘ └─────────┘ └────────────┘ │
│ │
│ Consumed by: relicario-cli, relicario-wasm (extension), │
│ relicario-server (pre-receive hook). │
│ Future: JNI/Swift wrappers for Android/iOS. │
└────────────────────────────────────────────────────────────┘
```
## Entropy at Each Attack Scenario
```
Server breach only: ████████████████████████████ 256+ bits (infeasible)
passphrase + image_secret
Server + stolen image: ████░░░░░░░░░░░░░░░░░░░░░░ ~51 bits (4 diceware words)
passphrase through Argon2id ~7 million years
Shoulder-surfed passphrase: ████████████████████████████ 256 bits (infeasible)
image_secret
Stolen device: ████░░░░░░░░░░░░░░░░░░░░░░ ~51 bits (4 diceware words)
passphrase through Argon2id ~7 million years
Both factors compromised: game over (same as every password manager)
```
---
**Next:** [FORMATS.md](FORMATS.md) — the byte-level wire formats.