# 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/ │ │ │ └── /.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/.enc` and `attachments//.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/.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>) │ │ (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/.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>` (org.rs:278) - `wrap_key` — `Zeroizing<[u8; 32]>` - decrypt `plaintext` in `unwrap_org_key` — `Zeroizing>` ### 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/.enc 4. re-encrypt every items//.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.