# Relic — Crypto Specification v1 Pinned parameters. Every client must implement exactly this; the format version gates any future change. Nothing here is negotiable at runtime. **Implementations (all wire-identical, byte-verified against each other with the pinned vectors in `crypto/test/relic_crypto_test.dart`):** 1. **Dart** — `crypto/lib/relic_crypto.dart`, the Apache-2.0 package the desktop and mobile apps depend on (the reference implementation in practice). 2. **JS/TS** — `crypto/js/vault-crypto.ts` (+ `vault-kek.worker.ts` for off-thread Argon2) — the web vault at relic.space/vault. Uses `hash-wasm` (Argon2id) and `@noble/ciphers` (XChaCha20-Poly1305). 3. **Rust** — `relic-core` (historical origin of the format; the crate remains the Rust reference). Change anything here and you must change all of them, plus this doc, together. ## Key hierarchy (wrapped master key) ``` passphrase ──Argon2id──▶ KEK (32B) ──unwraps──▶ MK (32B, random) ──encrypts──▶ relics & blobs recovery kit ───────────────────────── is ─────▶ MK (verbatim) ``` - **MK (master key):** 32 random bytes (`OsRng`), generated once on the first device at key setup. Encrypts all content, forever. - **KEK (key-encryption key):** derived from the user's encryption passphrase. Wraps MK. Changing the passphrase re-wraps MK — **no data re-encryption**. - **Recovery kit:** the raw MK, base32 (Crockford) in groups of 4, plus account email and `format: relic-mk-v1`. Printable/downloadable at setup; never stored by the operator. Holding the kit allows re-wrapping under a new passphrase (full recovery without the old passphrase). Rationale vs. deriving the data key directly from the passphrase (spec v0.4 and earlier): passphrase changes become cheap, and the recovery kit is a stable artifact that survives passphrase rotation. ## KDF — Argon2id | param | value | |---|---| | variant / version | Argon2id, v1.3 (0x13) | | memory | 64 MiB | | iterations | 3 | | parallelism | 4 | | salt | 16 random bytes, per account | | output | 32 bytes (KEK) | The salt is generated by the first device and stored (plaintext — salts are not secret) in the account's key-params record so every device derives the same KEK. **Test vector** (every client implementation must reproduce this): `Argon2id("correct horse battery staple", salt = 0x42×16, pinned params)` = `dde64ab2660a0dedd449fa0414587bd0407e68ebe4a3f1595f2c167f1f7ff3cd` ## AEAD — XChaCha20-Poly1305 24-byte random nonce per encryption (`OsRng`); collision risk negligible at any realistic volume. The nonce is stored alongside each ciphertext. Domain separation via AAD: | use | AAD | |---|---| | MK wrap | `relic.mkwrap.v1` | | relic payload | `relic.relic.v1:` | | blob | `relic.blob.v1:` | Binding `uid`/`blob_id` into the AAD prevents ciphertext swap/mix-and-match by a malicious or compromised server. The blob id is **client-generated** and IS the wire-protocol `blob_key` (bare); servers map it into their own namespace (`users//blob/` on R2) internally. ## Key-params record (server-side, per account) ```json { "v": 1, "salt": "", "argon2": { "m_kib": 65536, "t": 3, "p": 4 }, "mk_nonce": "", "wrapped_mk": "" // AEAD(KEK, mk_nonce, MK, aad=mkwrap) } ``` Stored via `PUT /keyparams` by the first device; fetched by every new device. Safe to store server-side: recovering MK from it requires the passphrase. **Unwrap failure = wrong passphrase** — this doubles as the key-check, so clients never need to trial-decrypt a real relic. ## Handling rules - MK and KEK live only in memory, in `zeroize`-d buffers; MK may additionally be cached in OS secure storage (Keychain/Keystore/Credential Manager) behind the unlock gate (SPEC §12). - Plaintext relic payloads are decrypted on demand and zeroized after use. - Passphrase input buffers are zeroized after KDF. - Blobs (≤100 MB) are encrypted as a single AEAD message in v1. A chunked streaming format is a future format-version bump (needed before any cap raise; chunking would use a per-blob subkey and counter nonces).