Skip to content

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.text and only wrapped key blobs in wrapped_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 _encryptionKey in keyManager.js. Never in React state, IndexedDB, localStorage, or logs.
  • Raw bytes: The ArrayBuffer form 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 stringsurfc-master-key-wrap-v1 — so the wrapping key is cryptographically independent from the legacy PRF-derived encryption key, which used surfc-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 saltSHA-256("surfc-prf-eval-v1"), deterministic per-application (passkeyEnrollment.js:21–31).
  • HKDF infosurfc-master-key-wrap-v1 for MK wrapping; surfc-note-encryption-v1 for the legacy direct-encryption path.
  • Relying-Party IDsurfc.app (base domain), so passkeys survive the surfc.app → app.surfc.app migration (SUR-218) and any future subdomain.
  • AuthenticatorauthenticatorAttachment: 'platform' (Touch ID, Face ID, Windows Hello, iCloud Keychain). Never phone QR.
  • Resident keyresidentKey: 'required' and allowCredentials: [] on the get() call, so the browser shows its own picker; Surfc never stores a credentialId.

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.

FormatSentinelWire shapeAADStatus
enc:v1enc:v1:enc:v1:<base64(iv)>.<base64(ciphertext)>noneRead-only legacy. New encrypts never produce v1.
enc:v2enc: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

ComponentFileResponsibility
keyManagersrc/crypto/keyManager.jsMK generation, wrap/unwrap (PRF and PIN), re-wrap for multi-device. Holds module-level non-extractable CryptoKey.
noteEncryptionsrc/crypto/noteEncryption.jsStateless AES-GCM-256 encrypt/decrypt for note text. v1/v2 wire format. isEncrypted() structural check.
passkeyEnrollmentsrc/crypto/passkeyEnrollment.jsAll navigator.credentials.create / get calls. Registers platform passkey with WebAuthn PRF extension. Returns PRF output as raw ArrayBuffer.
deviceTransfersrc/crypto/deviceTransfer.jsPIN-based MK transfer between devices. Working device wraps with 6-digit PIN (PBKDF2 600 000 iterations); new device redeems.
useKeyManagementsrc/hooks/useKeyManagement.jsReact orchestration of the full lifecycle: enrol, unlock, legacy migration, multi-device add, device transfer create/redeem, device list + removal, eager restore.
LinkedDevicesModalsrc/components/LinkedDevicesModal.jsxUI surface for the device list. Active prf-v1 blobs with device label and date. Inline removal with last-device guard.
deviceLabelsrc/utils/deviceLabel.jsPure 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):

ColumnTypeNotes
idtextPK. Client-generated.
user_iduuidFK → auth.users.
wrapper_typetext'prf-v1' (permanent, per-device) or 'transfer-v1' (ephemeral, device-to-device).
wrapped_keytextBase64. AES-GCM ciphertext of MK under wrapping key.
ivtextBase64. 12-byte AES-GCM IV.
salttextBase64. 32-byte salt — HKDF for PRF, PBKDF2 for PIN.
key_versionintegerDefault 1. Reserved for format evolution.
is_activebooleanDefault true. Soft-deactivation flag.
created_atbigintUnix epoch ms. Server-stamped via DEFAULT ((extract(epoch from now()) * 1000)::bigint) since migration 0014.
updated_atbigintUnix epoch ms. Default 0. Client-supplied.
deletedbooleanDefault false. Soft-delete flag (separate from is_active).
device_labeltext (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:

  1. Generate a random 6-digit PIN via generateTransferPin()crypto.getRandomValues(4 bytes) interpreted as uint32 mod 1 000 000, zero-padded (deviceTransfer.js:30–34).
  2. Unwrap the MK from the existing prf-v1 blob.
  3. 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).
  4. Insert a new wrapped_key_blobs row with wrapper_type = 'transfer-v1'.
  5. Display the PIN to the user with a 60-second visible countdown.

On the new device:

  1. Fetch a fresh transfer-v1 blob via the select_fresh_transfer_blob RPC. The RPC is SECURITY DEFINER, reads auth.uid() directly inside the WHERE clause (not as a parameter — IDOR guard), and enforces the freshness bound server-side: created_at > now - 60 000 ms AND created_at <= now + 5 000 ms. The lower bound is the TTL; the upper bound rejects pre-migration rows with skewed client-stamped timestamps.
  2. Unwrap the MK with the user-entered PIN. If the PIN is wrong, this throws — fail-fast before any UI commitment.
  3. Register a new platform passkey via registerEncryptionPasskey.
  4. Wrap the MK under the new device’s PRF output and insert a prf-v1 row.
  5. Soft-deactivate the consumed transfer-v1 row.
  6. 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.

PropertyValue
Storage keysurfc_prf
ValueBase64-encoded raw PRF bytes
LifetimeBrowser tab session — cleared on tab close, sign-out, and resetEncryptionState
Persistence across browser restart?No

tryEagerKeyRestore() flow:

  1. Check the passkeyEnrolled meta flag.
  2. Check sessionStorage for surfc_prf.
  3. Decode → fetch the cached prf-v1 blob from Dexie → derive wrapping key → unwrap → import as non-extractable.
  4. On any error, clear sessionStorage and return false so 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.

  1. Route every encrypt/decrypt call through keyManager.getEncryptionKey() plus noteEncryption.encryptText / noteEncryption.decryptText. Never call crypto.subtle directly in components or hooks. Centralisation is what makes the v1→v2 wire-format migration trustworthy.
  2. Never log or persist raw PRF output or raw MK bytes beyond the immediate operation that produced them. No console.log of prfOutput or the unwrapped buffer; no copies into Dexie, localStorage, or React state. The only place either lives long-term is sessionStorage for PRF (per §8) and the non-extractable runtime CryptoKey for MK.
  3. Never call credential.toJSON() or assertion.toJSON(). WebAuthn Level 3 may serialise the PRF result into the JSON output, exposing key material. The crypto module reads PRF via getClientExtensionResults() directly. There is a comment to this effect in passkeyEnrollment.js:8–10.
  4. enc:v2 (with noteId AAD) is the current write format; enc:v1 is read-only legacy. All new encryptions and migration re-encryptions pass a noteId to encryptText.
  5. If a task adds a new note field that should be encrypted, route it through db.js saveNote / updateNote — they already accept and apply encryptFn. 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.

#ThreatMitigationResidual risk
1Server-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.
2Hostile Surfc insider with database accessSame as #1.Insider could pair DB access with a code-push attack (#3) to plant a key-logger.
3Hostile Surfc insider with code-push accessNo 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.
4Leaked or subpoenaed database backupBackup 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.
5Intercepted 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.
6Lost or stolen devicePasskey 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.
7Compromised 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.
8Loss of all enrolled passkeys with no active transfer codeDevice-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.
9Screen recording / shoulder-surfingNot cryptographically preventable. Plaintext notes are on-screen by definition.User responsibility (privacy screen, careful environment).
10Passkey-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.

ContentWhereWhy plaintext
Note imagesSupabase Storage note-images bucketThe 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 tableEnables book search, dedupe, and cover-image lookup. Encrypting would force a client-side book index.
Tagsnotes.tags JSONB columnTag-based filtering and idea search rely on indexable plaintext. Encrypted tags are unsearchable without per-note decrypt.
Source attribution (page, chapter, URL)notes.source* columnsSource dedupe and citation tracking — the cross-source index is the product.
Idea indextaggings + custom_ideas tablesThe discovery engine queries by idea. Encrypting would require full-table scan plus client-side decrypt and filter.
Timestamps (created_at, updated_at)every tableLast-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.