CRYPTO DESIGN
Crypto design doc
Last updated: July 31, 2026
How Recivity wraps vault keys today, what the server stores, and what we plan to change. This describes the shipped implementation in src/lib/crypto.ts - not an aspirational model.
Scope and threat model
Recivity is a dead-man vault: encrypted content is stored until a check-in window fails, then beneficiaries can unlock. Two different trust stories apply.
- Owner path when no recovery question: the server stores ownerWrapSecret beside wrappedDekOwner, ownerSalt, and ciphertext. Database access can decrypt those vaults as the owner.
- Owner path when a recovery question is set: ownerWrapSecret is cleared and the owner wrap is sealed under a discarded key. Unlock requires the recovery answer in the browser - the same hard gate as beneficiaries. The site cannot decrypt without the answer.
- Recovery path: recovery answers are never sent to the server. After RELEASED, beneficiaries obtain wrappedDekRecovery (and related meta) and decrypt in-browser.
Primitives (as shipped)
- Content and key wrapping: AES-256-GCM via Web Crypto (crypto.subtle)
- KDF: PBKDF2 with SHA-256, 310,000 iterations (DEFAULT_CRYPTO_META)
- Salts: 16 random bytes for owner and recovery KDFs
- Wrap IVs: 12 random bytes per AES-GCM wrap
- cryptoMeta.version: 1 - reserved for future migrations
Key hierarchy
One vault DEK encrypts all documents and files for that vault. The DEK is wrapped twice:
Vault DEK (AES-256-GCM)
↓ wraps
Owner KEK ← PBKDF2(ownerWrapSecret)
→ wrappedDekOwner (stored)
Recovery KEK ← PBKDF2(answers)
→ wrappedDekRecovery (stored)
Documents / files: ciphertext + IV only
- Owner KEK = PBKDF2(ownerWrapSecret, ownerSalt) → wraps DEK → wrappedDekOwner + ownerWrapIv (usable only when no recovery question is set)
- Recovery KEK = PBKDF2(normalize(answers joined), recoverySalt) → wraps DEK → wrappedDekRecovery + recoveryWrapIv
- Each document/file stores ciphertext + content IV only; wrap material lives on the Vault row
Owner path (create and unlock)
- Create without a recovery question: browser generates ownerWrapSecret and vault DEK; wraps under owner and recovery KEKs; POSTs material including ownerWrapSecret
- Create or save with a recovery question: browser wraps for recovery answers only, clears ownerWrapSecret, and seals wrappedDekOwner under a discarded secret
- Unlock with no question: API returns ownerWrapSecret; browser auto-unwraps while signed in
- Unlock with a question: owner enters the answer (remembered in-tab per vault); unwrap via recovery path
Recovery path (release)
- Before RELEASED: release/shared APIs refuse to hand out recovery wrap material usable for decrypt (gated on vault status)
- After RELEASED: beneficiary receives wrappedDekRecovery, recoverySalt, cryptoMeta (including recoveryWrapIv) - never ownerWrapSecret or wrappedDekOwner
- Beneficiary enters recovery answers in-browser; answers are normalized/combined, KDF'd, used to unwrap DEK, then decrypt - answers are not POSTed
What the server stores
Aligned with the Privacy Policy data-security section:
- VaultDocument / VaultFile ciphertext and content IVs
- ownerWrapSecret (null when a recovery question gates the vault), wrappedDekOwner, wrappedDekRecovery
- ownerSalt, recoverySalt, cryptoMeta (algorithm, KDF, iterations, wrap IVs, version)
- Recovery question text; beneficiary contact fields; check-in and escalation metadata
- Not stored: document plaintext; recovery answer text
Planned changes
Planned: Argon2id + cryptoMeta migration
Replace PBKDF2 with Argon2id and bump cryptoMeta.version so existing vaults can rewrap safely.
Planned: export package
Encrypted export plus a standalone decryption page documented against this design.