End-to-End Encryption Architecture
End-to-End Encryption Architecture
End-to-end encryption is the most architecturally distinctive thing about Surfc. This page consolidates the design — what it protects, how it works in detail, and what it deliberately does not protect — into one canonical doc. If you want to understand Surfc’s encryption story, read this page; do not reconstruct it from scattered comments and plan files.
Sibling docs. Component-level locations live in Components Registry — Crypto module. The hook that orchestrates the lifecycle is documented at React Hooks Registry —
useKeyManagement.
1. Design intent
Surfc encrypts note text end-to-end. Encryption keys are derived client-side from a WebAuthn credential, and the cleartext key never leaves the user’s device.
What E2EE protects against:
- Server-side database compromise. An attacker with read access to
Postgres sees only ciphertext in
notes.textand only wrapped key blobs inwrapped_key_blobs. Without the user’s PRF output, neither yields the Master Key (MK). - Hostile insider with database access. Same as above. A Surfc operator with full DB credentials cannot read user notes.
- Leaked database backup. A stolen or subpoenaed snapshot is just rows of ciphertext and wrapped blobs. Offline brute-force against AES-GCM-256 is infeasible.
What E2EE does NOT protect against:
- Compromised client device. Plaintext notes live in IndexedDB; the MK lives in JS memory. Malware on the device can read both.
- Screen recording or shoulder-surfing. Plaintext is on the user’s screen by definition.
- Malicious browser extension. Extensions can read the DOM and
IndexedDB. Cannot be mitigated cryptographically. - Hostile insider with code-push access. A malicious deploy could log PRF output before encryption. Mitigated only by code review and deploy controls. This is an explicit trust assumption.
- Loss of all enrolled devices without an active transfer code. The MK is not escrowed anywhere. If every passkey is lost simultaneously, notes are unrecoverable. This is a deliberate design choice — a recovery mechanism would weaken the threat model in §10.
Selective-encryption strategy. Only notes.text is encrypted client-side.
Note images, book metadata, tags, and source attribution stay plaintext on
the server. Section §11 explains why,
and the trade-off it entails.
2. Master Key (MK) architecture
The Master Key is the only thing that actually decrypts notes.
- Algorithm: AES-GCM-256.
- Generation: Once per user at first passkey enrolment, via
crypto.getRandomValues(new Uint8Array(32))(keyManager.js:73). - At rest: Always wrapped under a wrapping key. Never in plaintext on the server.
- In memory: Imported as a non-extractable
CryptoKey(extractable: false) and held in the module-level variable_encryptionKeyinkeyManager.js. Never in React state, IndexedDB,localStorage, or logs. - Raw bytes: The
ArrayBufferform of the MK exists transiently during three operations only: generation → immediate wrap; unwrap → immediate import as non-extractable; re-wrap for multi-device. After each, the raw buffer is dropped.
The wrapping key is derived from the PRF output of a WebAuthn passkey, using HKDF with two safety properties:
- Per-blob random salt (32 bytes) so the same PRF output produces different wrapping keys across blobs.
- Distinct HKDF info string —
surfc-master-key-wrap-v1— so the wrapping key is cryptographically independent from the legacy PRF-derived encryption key, which usedsurfc-note-encryption-v1. Even given the same PRF output, the two keys cannot collide (keyManager.js:79–104).
3. Key lifecycle
The unlock flow on page load (or fresh sign-in) takes the user from
navigator.credentials.get() to a usable in-memory MK.
flowchart TD A[User signs in or refreshes app] --> B[useKeyManagement runs] B --> C{surfc_prf in sessionStorage?} C -- yes --> G C -- no --> D[navigator.credentials.get with PRF extension] D --> E[Raw PRF bytes ArrayBuffer] E --> F[Cache PRF in sessionStorage as surfc_prf] F --> G[Fetch prf-v1 wrapped_key_blob row from Supabase or Dexie cache] G --> H[HKDF SHA-256 info=surfc-master-key-wrap-v1 → AES-GCM-256 wrapping key] H --> I[AES-GCM unwrap blob → raw MK bytes ArrayBuffer] I --> J[crypto.subtle.importKey extractable=false → keyManager._encryptionKey] J --> K[Ready: encryptText / decryptText callable]Key constants:
- PRF salt —
SHA-256("surfc-prf-eval-v1"), deterministic per-application (passkeyEnrollment.js:21–31). - HKDF info —
surfc-master-key-wrap-v1for MK wrapping;surfc-note-encryption-v1for the legacy direct-encryption path. - Relying-Party ID —
surfc.app(base domain), so passkeys survive thesurfc.app → app.surfc.appmigration (SUR-218) and any future subdomain. - Authenticator —
authenticatorAttachment: 'platform'(Touch ID, Face ID, Windows Hello, iCloud Keychain). Never phone QR. - Resident key —
residentKey: 'required'andallowCredentials: []on theget()call, so the browser shows its own picker; Surfc never stores acredentialId.
The eager-restore branch (surfc_prf already in sessionStorage) skips the
WebAuthn assertion and goes straight from PRF bytes → wrapping key → unwrap →
import. See §8.
4. Wire formats
Note ciphertext is stored in notes.text as a sentinel-prefixed string.
| Format | Sentinel | Wire shape | AAD | Status |
|---|---|---|---|---|
enc:v1 | enc:v1: | enc:v1:<base64(iv)>.<base64(ciphertext)> | none | Read-only legacy. New encrypts never produce v1. |
enc:v2 | enc:v2: | enc:v2:<base64(iv)>.<base64(ciphertext)> | TextEncoder.encode(noteId) | Current write format (SUR-118). |
Wire structure is identical between the two; the sentinel determines whether AAD is applied. Common properties:
- IV — 12-byte random per call, generated via
crypto.getRandomValues. - Ciphertext — AES-GCM ciphertext with the 16-byte auth tag folded in.
- Storage — base64 strings concatenated with a
.separator after the sentinel.
The v2 AAD binds the ciphertext to the specific note. Transplanting v2 ciphertext from note A into note B (same user, same key) fails auth-tag verification on decrypt — even though the cryptographic key would otherwise succeed.
isEncrypted(value) is a structural check (noteEncryption.js:70–74).
It validates the sentinel, parses the two base64 segments, and checks segment
lengths and base64 charset. It does not attempt decryption.
Migration story. Legacy enc:v1 rows decrypt on read (decrypt detects the
sentinel and omits AAD). On the next edit, encryptText is called with a
noteId argument, producing an enc:v2 ciphertext. There is no batch
migration; rows convert opportunistically as users edit them.
5. Components
| Component | File | Responsibility |
|---|---|---|
keyManager | src/crypto/keyManager.js | MK generation, wrap/unwrap (PRF and PIN), re-wrap for multi-device. Holds module-level non-extractable CryptoKey. |
noteEncryption | src/crypto/noteEncryption.js | Stateless AES-GCM-256 encrypt/decrypt for note text. v1/v2 wire format. isEncrypted() structural check. |
passkeyEnrollment | src/crypto/passkeyEnrollment.js | All navigator.credentials.create / get calls. Registers platform passkey with WebAuthn PRF extension. Returns PRF output as raw ArrayBuffer. |
deviceTransfer | src/crypto/deviceTransfer.js | PIN-based MK transfer between devices. Working device wraps with 6-digit PIN (PBKDF2 600 000 iterations); new device redeems. |
useKeyManagement | src/hooks/useKeyManagement.js | React orchestration of the full lifecycle: enrol, unlock, legacy migration, multi-device add, device transfer create/redeem, device list + removal, eager restore. |
LinkedDevicesModal | src/components/LinkedDevicesModal.jsx | UI surface for the device list. Active prf-v1 blobs with device label and date. Inline removal with last-device guard. |
deviceLabel | src/utils/deviceLabel.js | Pure UA parser → "Platform · Browser" string for human-readable device identification. Captured at enrolment time. |
Cross-references in Components Registry — Crypto module.
6. Multi-device
Each enrolled device adds one prf-v1 row to wrapped_key_blobs. The same
MK is wrapped under each device’s own PRF-derived wrapping key, so any
enrolled device can unlock independently — no device needs to contact
another at unlock time.
Schema (migration 0008, extended by 0014 and 0015):
| Column | Type | Notes |
|---|---|---|
id | text | PK. Client-generated. |
user_id | uuid | FK → auth.users. |
wrapper_type | text | 'prf-v1' (permanent, per-device) or 'transfer-v1' (ephemeral, device-to-device). |
wrapped_key | text | Base64. AES-GCM ciphertext of MK under wrapping key. |
iv | text | Base64. 12-byte AES-GCM IV. |
salt | text | Base64. 32-byte salt — HKDF for PRF, PBKDF2 for PIN. |
key_version | integer | Default 1. Reserved for format evolution. |
is_active | boolean | Default true. Soft-deactivation flag. |
created_at | bigint | Unix epoch ms. Server-stamped via DEFAULT ((extract(epoch from now()) * 1000)::bigint) since migration 0014. |
updated_at | bigint | Unix epoch ms. Default 0. Client-supplied. |
deleted | boolean | Default false. Soft-delete flag (separate from is_active). |
device_label | text (nullable) | "Platform · Browser" from getDeviceLabel. Captured at enrolment for prf-v1; never set for transfer-v1. |
RLS: users can read/write only their own rows
(auth.uid() = user_id).
Index: wrapped_key_blobs_user_active_idx on (user_id, is_active)
where deleted = false.
Persisted device tracking: the local surfc_device_blob_id value in
localStorage records which prf-v1 blob this browser owns, so the UI
can show “This device” and prevent removal of the current device or the
last remaining wrapper.
7. Device transfer (PIN flow)
Device transfer lets a user enrol a new device when only an existing working device is available. It uses a one-time 6-digit PIN as a short-lived secondary unlock.
On the working device:
- Generate a random 6-digit PIN via
generateTransferPin()—crypto.getRandomValues(4 bytes)interpreted asuint32 mod 1 000 000, zero-padded (deviceTransfer.js:30–34). - Unwrap the MK from the existing
prf-v1blob. - Re-wrap the raw MK bytes with a wrapping key derived from the PIN via PBKDF2-SHA-256, 600 000 iterations, with a fresh 32-byte salt (keyManager.js:191–215).
- Insert a new
wrapped_key_blobsrow withwrapper_type = 'transfer-v1'. - Display the PIN to the user with a 60-second visible countdown.
On the new device:
- Fetch a fresh
transfer-v1blob via theselect_fresh_transfer_blobRPC. The RPC isSECURITY DEFINER, readsauth.uid()directly inside the WHERE clause (not as a parameter — IDOR guard), and enforces the freshness bound server-side:created_at > now - 60 000 msANDcreated_at <= now + 5 000 ms. The lower bound is the TTL; the upper bound rejects pre-migration rows with skewed client-stamped timestamps. - Unwrap the MK with the user-entered PIN. If the PIN is wrong, this throws — fail-fast before any UI commitment.
- Register a new platform passkey via
registerEncryptionPasskey. - Wrap the MK under the new device’s PRF output and insert a
prf-v1row. - Soft-deactivate the consumed
transfer-v1row. - Import the MK as a non-extractable runtime key — the new device is immediately usable.
Why server-side TTL (SUR-237 /
migration 0014). Originally, freshness was checked against the working
device’s wall clock. A skewed-forward clock could produce a created_at far
in the future, defeating the 60-second TTL. Migration 0014 added the
DEFAULT ((extract(epoch from now()) * 1000)::bigint) so the database
stamps created_at and the RPC compares against the same DB clock. The
client-side TRANSFER_MAX_AGE_MS and TRANSFER_MAX_FUTURE_SKEW_MS
constants were removed; only TRANSFER_SANITY_FUTURE_SKEW_MS = 5 minutes
remains as defense-in-depth against absurd futures.
8. Session caching (PRF bytes)
WebAuthn assertion is the slow part of unlock — typically 1–3 seconds and
a OS-level prompt on some platforms. To avoid prompting on every page
refresh, Surfc caches the PRF output (not the MK) in sessionStorage.
| Property | Value |
|---|---|
| Storage key | surfc_prf |
| Value | Base64-encoded raw PRF bytes |
| Lifetime | Browser tab session — cleared on tab close, sign-out, and resetEncryptionState |
| Persistence across browser restart? | No |
tryEagerKeyRestore() flow:
- Check the
passkeyEnrolledmeta flag. - Check
sessionStorageforsurfc_prf. - Decode → fetch the cached
prf-v1blob from Dexie → derive wrapping key → unwrap → import as non-extractable. - On any error, clear
sessionStorageand returnfalseso the next unlock attempt forces a full WebAuthn assertion.
The path completes in ~10 ms when warm vs. ~1–3 seconds for the cold WebAuthn path.
If sessionStorage is unavailable (private browsing, restricted contexts),
the cache write is a no-op and the user gets a normal WebAuthn prompt on
each load. This is non-fatal.
9. Encryption rules (load-bearing invariants)
These rules are load-bearing. They are enforced by code review, not by the type system, and a violation can compromise the threat model.
- Route every encrypt/decrypt call through
keyManager.getEncryptionKey()plusnoteEncryption.encryptText/noteEncryption.decryptText. Never callcrypto.subtledirectly in components or hooks. Centralisation is what makes the v1→v2 wire-format migration trustworthy. - Never log or persist raw PRF output or raw MK bytes beyond the
immediate operation that produced them. No
console.logofprfOutputor the unwrapped buffer; no copies into Dexie,localStorage, or React state. The only place either lives long-term issessionStoragefor PRF (per §8) and the non-extractable runtimeCryptoKeyfor MK. - Never call
credential.toJSON()orassertion.toJSON(). WebAuthn Level 3 may serialise the PRF result into the JSON output, exposing key material. The crypto module reads PRF viagetClientExtensionResults()directly. There is a comment to this effect inpasskeyEnrollment.js:8–10. enc:v2(withnoteIdAAD) is the current write format;enc:v1is read-only legacy. All new encryptions and migration re-encryptions pass anoteIdtoencryptText.- If a task adds a new note field that should be encrypted, route it
through
db.jssaveNote/updateNote— they already accept and applyencryptFn. Do not add a new bespoke encryption pathway.
These rules also live in surfc/CLAUDE.md § Encryption rules.
The two copies must stay in sync; CLAUDE.md is the source of truth and any
divergence here should be flagged.
10. Threat model
A threat is in scope for E2EE if a mitigation could plausibly live in the crypto module, the schema, or the unlock flow. Threats outside that scope (physical security, account-recovery social engineering on the OS credential store) are noted but not deeply mitigated by Surfc itself.
| # | Threat | Mitigation | Residual risk |
|---|---|---|---|
| 1 | Server-side database compromise (SQL access to notes.text) | MK never plaintext on server. Wrapped blobs require a PRF output to unwrap, which the attacker does not have. | Two-factor compromise (DB + a working device’s authenticator) is required to defeat. |
| 2 | Hostile Surfc insider with database access | Same as #1. | Insider could pair DB access with a code-push attack (#3) to plant a key-logger. |
| 3 | Hostile Surfc insider with code-push access | No cryptographic mitigation possible client-side. Mitigated by code review, deploy controls, and Cloudflare Pages immutable build artefacts. | Accepted trust assumption. Documented here so it is not a surprise. |
| 4 | Leaked or subpoenaed database backup | Backup contains only ciphertext + wrapped blobs. Offline brute-force against AES-GCM-256 with 256-bit MK entropy is infeasible. | If backup is leaked alongside the working device (e.g. a household where both an iCloud backup and a device are seized), attacker holds both halves. |
| 5 | Intercepted network traffic (MITM on Supabase API) | Notes travel over TLS to Supabase. Wrapped blobs are also under TLS, but their disclosure is not load-bearing — they require the PRF to unwrap. | TLS break (root-CA compromise, downgraded handshake) exposes plaintext sync traffic for notes.text. No certificate pinning today. |
| 6 | Lost or stolen device | Passkey is gated by OS-level user verification (Touch ID, Face ID, Windows Hello, iCloud Keychain biometric/PIN). Without unlocking the device, the passkey cannot be exercised. | If the device is unlocked at the moment of theft, the attacker has full access. Equivalent to handing over the device. |
| 7 | Compromised browser (extension, malware) | No cryptographic mitigation possible. PRF bytes and decrypted note plaintext live in JS memory and IndexedDB during a session. | OS sandbox + user vigilance. Discourage untrusted extensions. |
| 8 | Loss of all enrolled passkeys with no active transfer code | Device-transfer flow (§7) provides a 60-second window for any working device to enrol a new device. | If the user loses every enrolled device before initiating a transfer, notes are unrecoverable. By design — the MK is not escrowed anywhere. A recovery path would weaken #1, #2, and #4. |
| 9 | Screen recording / shoulder-surfing | Not cryptographically preventable. Plaintext notes are on-screen by definition. | User responsibility (privacy screen, careful environment). |
| 10 | Passkey-store compromise (iCloud Keychain, Google Password Manager hijack) | Out of scope for Surfc to mitigate. Relies on the OS-level credential store’s own security boundary (account 2FA, device trust). | Mitigation is “enable 2FA on your password-manager account”. |
11. What’s NOT encrypted client-side
Encrypting only notes.text is a deliberate trade-off. Most other note-
adjacent data stays plaintext on the server because the product depends on
it being there.
| Content | Where | Why plaintext |
|---|---|---|
| Note images | Supabase Storage note-images bucket | The Anthropic transcription and idea-discovery pipeline run server-side and need image access. RLS scopes reads to the owner. |
| Book metadata (title, author, ISBN) | books table | Enables book search, dedupe, and cover-image lookup. Encrypting would force a client-side book index. |
| Tags | notes.tags JSONB column | Tag-based filtering and idea search rely on indexable plaintext. Encrypted tags are unsearchable without per-note decrypt. |
| Source attribution (page, chapter, URL) | notes.source* columns | Source dedupe and citation tracking — the cross-source index is the product. |
| Idea index | taggings + custom_ideas tables | The discovery engine queries by idea. Encrypting would require full-table scan plus client-side decrypt and filter. |
Timestamps (created_at, updated_at) | every table | Last-write-wins sync conflict resolution depends on server-comparable timestamps. |
Monetisation tradeoff. The Pro tier’s reDiscoverIdeas capability —
periodic server-side LLM re-analysis to surface forgotten ideas
(SUR-235) — requires plaintext
text-and-images access at the Edge Function boundary. The product roadmap
intentionally treats E2EE as a privacy guarantee on the most sensitive
surface (note text under client-only key control), not a blanket guarantee
across every note-adjacent field. If a future product direction makes the
opposite trade — fully encrypted notes including server-side discovery —
this section is the right place to record the change and the threat-model
implications.
Evidence gathered from source files only, per AGENTS.md.