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 with clear faces and keys; the server is not. Unredacted face pixels, readable embeddings, raw master seeds and private keys exist only on the client. The server can read the permanently mosaicked base image, thumbnail and service metadata needed to enforce access. It stores clear-face entities, embeddings, captions, comments and key material only as ciphertext/wraps it cannot open. Every design decision below follows from that split boundary.

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

  1. Entity and content encryption — clear face/intersection entities, embeddings, captions and comments. A fresh random data key (DEK) per protected 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 halves. The mosaicked base is deliberately plaintext and outside this layer.
  2. Seed wrapping — the browser wraps the user’s 96-byte master seed with AES-256-GCM under a key produced by the passkey’s WebAuthn PRF extension. Neither raw seed nor PRF secret leaves the device; the server holds only the resulting ciphertext (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 protected by E2EE ultimately descends from the user’s master seed (layer 2/3), which regenerates their personal key, unwraps group keys (below), and then unwraps entity/content DEKs (layer 1). The redacted scene needs no decryption.

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

Every group is a person-group (an individual’s access boundary — the type discriminator was dropped in migration 0049) and 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 stores the permanently mosaicked BBP carrier and uses the photo ACL to control who may fetch it. A content key wrapped in photo_circles.wrapped_content_key — one row per audience — protects caption/content metadata, not the base pixels. Each clear face entity has its own DEK and wrap to the relevant person-group’s epoch key; embeddings follow the same group boundary. Every group-protected ciphertext records the epoch_generation required to open it.

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 entity/content-key wrap──▶ item DEK
item DEK ──decrypt──▶ clear face entity, embedding, caption or comment

The mosaicked WebP base follows a different path: the authenticated server checks the photo ACL and serves the readable privacy-floor image without a content-key decrypt.

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 and shown once as a 12-word phrase. The raw seed and phrase are never stored by the server.
  • Derived from the phrase with Argon2id (moderate ops/mem limits) on recovery.
  • Wrapped on-device for everyday passkey logins; only the PRF-wrapped ciphertext is uploaded (user_encryption_keys.prf_wrapped_seed) and it is 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 sees public keys, wrapped blobs, the redacted scene, service metadata and a signed session token — never raw seed/private-key material or unredacted faces.

The hybrid encrypt/decrypt, keypair, BIP39 mnemonic and Argon2id primitives live in @brightblur/crypto (packages/crypto/) so the native mobile app shares them; src/lib/crypto/index.ts re-exports them. Web-side glue stays in src/lib/crypto/: 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.