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
434 lines
25 KiB
Markdown
434 lines
25 KiB
Markdown
# 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: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
|
||
|
||
```
|
||
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.
|