Extends the A5 pre-stage now that dev-b's full B-stream (item CRUD + all 19 org subcommands) merged to main (7392795). Living docs: - FORMATS/CRYPTO/SECURITY/DESIGN: flip the item-CRUD "pending Dev-B" markers to shipped; SECURITY audit vocabulary moves item-* actions to live. - crates/relicario-cli/ARCHITECTURE.md: full 19-subcommand surface (12 admin + 7 item CRUD), accurate OrgAddKind scope (Login/SecureNote/Identity). - STATUS.md: enterprise-org-vault landed section (merged7392795) + tracked follow-ups + honest known-limitations; correct spec citation. - ROADMAP.md: backend-complete row + phase-2 follow-ups. - CHANGELOG.md: finalize the enterprise-org-vault Unreleased section (item CRUD into Added; Card/Key/Document/Totp + extension + phase-2 into Deferred). Code (PM-directed dead_code fixes): wire device::current_device_seed by removing the identical duplicate private fn in org_session.rs (de-dup); #[allow(dead_code)] + justification on org_session org_meta_path/load_meta (API completeness, no command consumes org.json yet). Also silence a 3rd pre-existing test-only warning (unused relicario() helper in tests/org_init_signing.rs). Honest deferrals kept explicit throughout: Card/Key/Document/Totp org add/edit parity, extension org switch/read (Dev-D) + writes, phase-2 (SSO/LDAP, read audit, per-collection subkeys, HTTP plane). Full workspace cargo test green, zero warnings. All cited code constants pinned file:line. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TJo44YM3UbBjro2fG6NrKy
25 KiB
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), attacker scenarios (see SECURITY.md), or per-module crypto implementation (see ../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.
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 passedkdf_input—Zeroizing<Vec<u8>>(org.rs:278)wrap_key—Zeroizing<[u8; 32]>- decrypt
plaintextinunwrap_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.
The item-CRUD commands (org add/get/list/edit/rm/restore/purge) that read and write these blobs are merged and wired into main.rs; each operates under the org master key recovered by unwrap_org_key.
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 — the byte-level wire formats.