Skip to content

Encryption & Keys

This is the engineering-side companion to the user-facing Cryptography in Detail and Key Rotation pages. Those explain what the primitives are and how strong they are; this explains how the code is shaped so you can work on it safely. Read the user-facing crypto page first for the primitive choices — they are not repeated in full here.

The browser is trusted; the server is not. Plaintext images, face pixels, embeddings, master seeds, and private keys exist only on the client. The server stores ciphertext, public keys, and per-member wrapped private keys, and it enforces membership — it never holds anything it could decrypt with. Every design decision below follows from that.

BrightBlur stacks three independent layers (full primitive tables in the user-facing crypto doc):

  1. Content encryption — photos, face slices, embeddings, comments. A fresh random data key (DEK) per item encrypts the bytes with XSalsa20-Poly1305; the DEK is wrapped to each recipient with a hybrid scheme — X25519 and ML-KEM-768 shared secrets combined through HKDF-SHA-256. An attacker must break both the classical and the post-quantum half. The server sees only the ciphertext and the wrapped DEKs.
  2. Seed wrapping — the user’s 96-byte master seed is stored server-side, wrapped with AES-256-GCM under a key produced by the passkey’s WebAuthn PRF extension. The PRF secret never leaves the device; the server holds only the wrapped blob (user_encryption_keys.prf_wrapped_seed).
  3. Mnemonic recovery — a BIP39 12-word phrase, shown once and never stored, re-derives the master seed through Argon2id.

At the byte level, a hybrid envelope is ephemeralPub(32) ‖ mlkemCiphertext(1088) ‖ nonce(24) ‖ XSalsa20-Poly1305(plaintext) (minimum 1160 bytes), with HKDF info string brightblur-hybrid-v1; the PRF seed-wrap is IV(12) ‖ AES-256-GCM(seed) (124 bytes). Identity and epoch keypairs are composite — an X25519 pair (32/32) plus an ML-KEM-768 pair (1184/2400), so a public key is 1216 bytes and a private key 2432. All intermediate secrets are zeroised (sodium.memzero) in finally blocks.

Everything a user can decrypt ultimately descends from their master seed (layer 2/3), which unwraps their personal private key, which unwraps group keys (below), which unwrap content DEKs (layer 1).

This is the part most worth understanding before you touch sharing, membership, or recognition.

Every group — both circle (an audience) and person (an identity) — owns an asymmetric epoch keypair, versioned by a generation integer:

  • The public half of each generation lives in epoch_keys (group_id, generation).
  • The private half is never stored in the clear. Instead, for each member who may decrypt the group, it is wrapped to that member’s personal public key and stored in wrapped_keys (group_id, generation, user_did).

To share a photo with a circle, the uploader generates a content DEK, encrypts the blob, then wraps the DEK to the circle’s current epoch public key and stores that in photo_circles.wrapped_content_key — one row per audience. Face slices and embeddings do the same against the relevant person-group’s epoch key. Every stored ciphertext carries the epoch_generation it was encrypted under, so a decryptor knows which generation’s private key to fetch.

A viewer therefore decrypts in a chain:

master seed ──unwrap──▶ personal private key
personal private key ──unwrap (wrapped_keys row for this group+generation)──▶ epoch private key
epoch private key ──unwrap (photo_circles.wrapped_content_key)──▶ content DEK
content DEK ──decrypt──▶ the blob from R2

Joint-access content (a face that two groups must jointly unlock) uses the photo_face_intersections table with split-key halves (wrapped_half_a, wrapped_half_b) wrapped to each group.

Generations exist so membership changes can revoke access:

  • Adding a member wraps the current epoch private key to their public key — a new wrapped_keys row. They can decrypt everything from that generation onward (and historical generations only if those wrapped rows are issued to them too, which the add-member flow does for back-access).
  • Removing a member starts a new generation: a fresh epoch keypair whose private key is wrapped only to the remaining members. The removed member never receives a wrapped_keys row for the new generation, and the server refuses to serve blobs to non-members. Everything created after removal is encrypted to the new generation — they cannot read it. This is enforced through the complete-departure / key-rotation flow and tracked in key_rotation_requests.

The honest caveat (also stated in the user docs): a removed member who cached both the old epoch key and the ciphertext before removal keeps that historical content. You cannot un-show data someone already decrypted — that is inherent to E2E encryption, not a bug.

Client code does not poke at wrapped keys directly; it goes through a KeyRing, which encapsulates “given my session, get me the key material for this group/generation and decrypt this”:

  • decryptForGroup(ciphertext, groupId, generation) — the workhorse. It resolves the member’s wrapped private key for that (group, generation), unwraps it (via the master-seed-derived personal key), and decrypts. The photo page’s slice decryption and the recognition rebuild both go through this exact call.
  • epochPublicKey(groupId) / refreshEpochPublicKey(groupId) — fetch (and force-refresh) a group’s current epoch public key, used when encrypting new content.
  • Around those sit unwrapEpochKey, unwrapContentKey (the multi-group photo path), decryptMultiGroup, encryptForGroup, and reEncrypt (decrypt-then-re-encrypt across groups, used in rotation).
  • A separate PublishKeyRing narrows the surface to what the publish pipeline needs. Note a deliberate asymmetry: the read-side KeyRing treats epoch-public-key refresh as required, while PublishKeyRing.refreshPublicKey is optional (feature-detected) — don’t “tidy” that into symmetry.

Two behaviours inside the KeyRing are load-bearing. First, generation selection is exact-match only: selectWrappedKey looks up (groupId, generation) with no silent fallback to “latest”, throwing MissingWrappedKeyError (with .available/.target) if the row is absent — a content item must be decrypted with the precise generation it was sealed under. Second, when a ciphertext’s stamped generation is uncertain, decryptForGroup trials older generations only on an AEAD failure (isAeadDecryptFailure), and propagates any other error immediately. The ring caches unwrapped epoch private keys (keyed groupId:generation) and current public keys (keyed groupId); refreshEpochPublicKey is the only path that busts the public-key cache.

KeyRings are constructed per session (createKeyRing(viewerDid), or createKeyRingFrom(...) when the groups + private key are already loaded, e.g. in publish). The actual primitive operations run in a crypto Web Worker behind an RPC facade (in src/lib/crypto/): the worker exposes a small set of base operations (encrypt/decrypt/wrap/unwrap/derive and friends — about seven), and the facade binds the epoch-domain names (encrypt(epochPriv, recipPub) etc.) on top, with a main-thread fallback when workers are unavailable. Keep heavy crypto off the main thread by going through the facade, not by importing libsodium directly into UI code.

Master seed, recovery, and the trust-model decisions

Section titled “Master seed, recovery, and the trust-model decisions”

A user’s master seed is the root of everything. It is:

  • Generated at setup as BIP39 entropy → shown once as a 12-word phrase (the setup wizard’s mnemonic step) → never stored by the server.
  • Derived from the phrase with Argon2id (moderate ops/mem limits) on recovery.
  • Stored for everyday logins only in PRF-wrapped form (user_encryption_keys.prf_wrapped_seed), unwrappable solely by the user’s passkey on-device. Within a session the unwrapped PRF key and wrapped seed are held by the SeedVault (crypto/seed-vault.ts) — a two-layer in-memory + IndexedDB cache (prf_key, wrapped_seed) with in-flight de-duplication on unlock, a commitRotatedSeed to survive a key rotation across reloads, and a best-effort clear() on logout. The 96-byte seed deterministically regenerates the identity keypair (generateDeterministicKeypair: bytes 0–31 → X25519, 32–95 → ML-KEM-768).

Two trust-model decisions are deliberate and documented in DECISION_LOG.md — do not “improve” them without that context:

  • No password-wrapped seed. The seed is wrapped by the passkey PRF (and recoverable by mnemonic), never by a password. A password is too weak a root for a key that protects faces.
  • No escrow / no server-held recovery. There is no server-side key-recovery path. If a user loses both their passkeys and their mnemonic, their data is unrecoverable — that is the cost of the server being untrusted, and it is the intended behaviour.

The setup and recovery code lives in the setup/ route (the wizard split into step components + setup-flow.svelte.ts + a rune-free recovery.ts seam). Three P2 recovery features remain on the roadmap (recovery-kit download, multi-passkey enrolment, QR device-link) — all within this no-password/no-escrow model.

Passkeys are both the authentication mechanism and the seed-unwrapping mechanism, which is why they sit next to the key model:

  1. The WebAuthn ceremony authenticates the user (challenge issued, CAS-consumed before attestation to defeat replay, attestation verified — see API Contracts) and establishes the session cookies.
  2. The same passkey’s PRF output unwraps the master seed locally.
  3. The master seed seeds the KeyRing, which can now unwrap group keys and decrypt content.

So a logged-in browser holds live key material in memory for the session; the server, throughout, has only seen public keys, wrapped blobs, and a signed session token.

src/lib/crypto/: index.ts (hybrid encrypt/decrypt, keypair/mnemonic/Argon2id), keyring.ts (the KeyRing), seed-vault.ts (the session key cache), webcrypto.ts (PRF/AES-GCM seed wrap), select-wrapped-key.ts, intersection-split.ts (XOR split-key), photo-decryptor.ts + pipeline.ts (the decrypt pipeline — not to be confused with publish/pipeline.ts). Server side: server/api/groups.ts (epoch keys, wrapped-key upserts, membership), server/api/key-rotation.ts + the complete-departure flow (rotation), server/auth/session.ts (JWT + refresh rotation), server/env-schema.ts (JWT_SECRET strength), src/routes/setup/ (seed generation + recovery), schema.ts (epoch_keys, wrapped_keys, user_encryption_keys, sessions). Primitive choices and brute-force analysis: Cryptography in Detail.