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 one rule
Section titled “The one rule”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.
Three cryptographic layers
Section titled “Three cryptographic layers”BrightBlur stacks three independent layers (full primitive tables in the user-facing crypto doc):
- 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.
- 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). - 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).
The per-group epoch key model
Section titled “The per-group epoch key model”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 keypersonal private key ──unwrap (wrapped_keys row for this group+generation)──▶ epoch private keyepoch private key ──unwrap (photo_circles.wrapped_content_key)──▶ content DEKcontent DEK ──decrypt──▶ the blob from R2Joint-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.
Key rotation and forward secrecy
Section titled “Key rotation and forward secrecy”Generations exist so membership changes can revoke access:
- Adding a member wraps the current epoch private key to their public key — a new
wrapped_keysrow. 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_keysrow 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 thecomplete-departure/ key-rotation flow and tracked inkey_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.
The KeyRing abstractions
Section titled “The KeyRing abstractions”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, andreEncrypt(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
KeyRingtreats epoch-public-key refresh as required, whilePublishKeyRing.refreshPublicKeyis 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 onunlock, acommitRotatedSeedto survive a key rotation across reloads, and a best-effortclear()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.
How a passkey login connects to all this
Section titled “How a passkey login connects to all this”Passkeys are both the authentication mechanism and the seed-unwrapping mechanism, which is why they sit next to the key model:
- 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.
- The same passkey’s PRF output unwraps the master seed locally.
- 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.
Where to look
Section titled “Where to look”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.