Files
relicario/docs/CRYPTO.md
adlee-was-taken 8bb1d779c4 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 15:23:27 -04:00

25 KiB
Raw Blame History

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: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_keyZeroizing<[u8; 32]> everywhere it is passed
  • kdf_inputZeroizing<Vec<u8>> (org.rs:278)
  • wrap_keyZeroizing<[u8; 32]>
  • decrypt plaintext in unwrap_org_keyZeroizing<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 — the byte-level wire formats.