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.

Back to Security overview