BYOK Encryption — Client-Side Key Protection Design

Status: LOCKED. This document records the design, threat model, and phased implementation plan for encrypting BYOK AI provider API keys at rest in D1. Implementation must not deviate from this spec without updating it first.

Related: ARCHITECTURE.md (security model), REALTIME-ARCHITECTURE-V3.md (P2P layer this design rides on), repo root CLAUDE.md §8 (BYOK cascade model).


1. Executive Summary

Today, BYOK AI provider API keys (Groq, Anthropic, Mistral, etc.) are stored in the D1 workspaces.cloud_keys column as plaintext JSON. A D1 breach — whether via a SQL injection, a compromised Cloudflare account, or a misconfigured data export — leaks every user's provider API keys in cleartext. We have something we do not strictly need: plaintext data on a server we do not fully control.

The fix is straightforward. We encrypt the keys in the browser before writing them to D1, and store the decryption key only on user devices — never on any server. D1 then holds useless ciphertext. A breach reveals nothing actionable about AI provider credentials.

Locked decision (Option B):

  • API keys are encrypted with AES-256-GCM before being written to D1.
  • The encryption key (workspace_secret, a 256-bit random scalar) lives exclusively in client IndexedDB, never on any server.
  • Multi-device access is handled by P2P secret transfer (WebRTC), not by storing the secret anywhere centrally.
  • New devices pair with an existing device in a one-time handshake. After pairing, the secret is stored in that device's IndexedDB permanently.
  • The server cannot decrypt. A D1 breach yields useless ciphertext.
  • The design is backward-compatible: the existing TEXT cloud_keys path continues to work during migration.

Tradeoffs accepted:

  • Users who lose all their devices also lose their keys. They can re-enter them.
  • New-device pairing requires one existing device to be online. If none are available, the user re-enters keys.
  • Agent access (MCP, server-side automation) requires that the agent has been paired once with a human session and has stored the secret locally.

2. Threat Model

What we are protecting against

Threat Description This design mitigates?
D1 breach SQL dump, Cloudflare account compromise, misconfigured export leaks the cloud_keys column Yes — ciphertext only
Cloudflare worker compromise Attacker reads D1 in the Worker request handler Yes — Worker never holds the secret
Server-side log injection Keys appear in Worker logs or observability traces Yes — Worker never sees plaintext keys
D1 cold backup exfiltration An old R2 snapshot contains historical cloud_keys rows Yes — rows were always encrypted
Shared-link sniffing Someone intercepts the workspace URL over plaintext HTTP Out of scope — TLS handles this

What we accept as risk

Threat Description Mitigation
Client device compromise Attacker has physical access or malware on a user's device IndexedDB is accessible to the origin JS; full device compromise means key exposure. Acceptable — the keys are on that device for that user's use.
XSS in the app Script injected into the CareerVector origin can read IndexedDB Standard XSS hardening (CSP, input sanitisation). Not specific to this design.
WebRTC pairing MITM Attacker intercepts the pairing exchange Pairing code provides out-of-band authentication; see Section 5.
User sharing link with untrusted party Anyone with the workspace URL can trigger a pairing request Link = identity is a foundational invariant; this design does not change the access model.
Key provider breach Groq/Anthropic/etc. is breached and the key itself is compromised Out of scope. Rotating the key is the user's action.

Non-goals

  • This design does not add user authentication to CareerVector. Link = identity remains.
  • This design does not protect keys in transit — TLS already handles that.
  • This design does not rotate keys automatically. Key lifecycle is the user's responsibility.
  • This design does not sandbox which tabs can access the secret. All tabs on the origin share the same IndexedDB.

3. Crypto Choice

Primitive: AES-256-GCM

The encryption primitive is AES-256-GCM via the browser WebCrypto API (SubtleCrypto).

Property Value
Algorithm AES-GCM
Key size 256-bit
Nonce size 96-bit (12 bytes), randomly generated per encryption
Tag size 128-bit (16 bytes, GCM default)
API window.crypto.subtle.encrypt / .decrypt

Why AES-256-GCM over ChaCha20-Poly1305:

  • Native browser support. WebCrypto (SubtleCrypto) has had AES-GCM since day one on all target browsers (Chrome, Firefox, Safari). ChaCha20-Poly1305 is not exposed by SubtleCrypto as of 2026. Using it would require a JavaScript implementation (e.g. @noble/ciphers), which adds bundle size, moves the crypto off-CPU-ring, and introduces a dependency update surface.
  • Hardware acceleration. AES-NI is present on every target desktop CPU (Intel since 2010, AMD since 2011). AES-GCM runs at memory-bandwidth speed. For the small key material being encrypted (a few KB of JSON), performance is not a practical concern in either direction, but the hardware-native path eliminates timing side-channel risk.
  • Standardisation. AES-256-GCM is NIST-approved and FIPS 140-2 compliant. This matters for any enterprise or regulated sector users.

ChaCha20-Poly1305 would be preferred in an environment where AES-NI is absent (embedded, old mobile) or where constant-time guarantees without hardware are required. Neither applies here.

Nonce handling

A fresh 12-byte nonce is generated for every encryption operation (crypto.getRandomValues). The nonce is prepended to the ciphertext blob before storage: [12 bytes nonce][ciphertext][16 bytes tag].

Nonce reuse with the same key breaks GCM confidentiality and integrity. With a 96-bit random nonce and a key that encrypts at most a handful of updates per day, the birthday-bound collision probability is negligible over any realistic workspace lifetime.

If the cloud_keys payload is ever encrypted more frequently (e.g. per-keystroke), switch to a counter nonce with an indexed ops log. For the current use case (user saves API key settings), random nonce is correct.

Key derivation

The workspace_secret is a raw 256-bit random value (crypto.getRandomValues(new Uint8Array(32))), generated once at workspace creation on the first client that sets a BYOK key. It is imported directly as CryptoKey via crypto.subtle.importKey('raw', ..., 'AES-GCM', false, ['encrypt', 'decrypt']). The extractable: false flag is set so the key material cannot be read back from the CryptoKey object after import — it can only be used for encrypt/decrypt operations.

For persistence in IndexedDB the raw bytes are stored (not the non-extractable CryptoKey wrapper), since IndexedDB does not support storing CryptoKey objects portably across origins and browser restarts. On load, the raw bytes are re-imported as a non-extractable CryptoKey.


4. Storage Model

D1 schema change

The existing cloud_keys TEXT column is migrated to cloud_keys_enc BLOB alongside the original. Both columns coexist during the migration window.

-- Migration: add encrypted BLOB column
ALTER TABLE workspaces ADD COLUMN cloud_keys_enc BLOB DEFAULT NULL;

The existing cloud_keys TEXT DEFAULT NULL column is not dropped until all active workspaces have been migrated and a deployment window has passed (see Section 7).

Blob format

The stored blob is:

[1 byte version][12 bytes nonce][N bytes AES-256-GCM ciphertext + 16 byte tag]
  • Version byte: currently 0x01. Allows future algorithm rotation without column schema changes.
  • Nonce: 12 bytes, random per encryption.
  • Ciphertext + tag: output of crypto.subtle.encrypt({ name: 'AES-GCM', iv: nonce }, key, plaintext).

The plaintext is the same JSON that currently lives in cloud_keys TEXT — the CloudKeys schema unchanged. Encryption is transparent to the cascade and chain resolution layers.

Read path

When a client loads a workspace:

  1. Server returns cloud_keys_enc BLOB (or cloud_keys TEXT for unencrypted legacy rows).
  2. Client checks: if cloud_keys_enc is present and non-null, decrypt with the workspace_secret from IndexedDB before parsing.
  3. If workspace_secret is absent from IndexedDB (new device, cleared storage), show the pairing prompt (see Section 5) or the re-entry prompt.
  4. After decryption, the CloudKeys JSON is parsed and used identically to today.

The server returns the raw blob. The server never sees the plaintext CloudKeys JSON for encrypted workspaces.

Write path

  1. User saves API key settings in the browser.
  2. Client serialises the CloudKeys JSON to UTF-8 bytes.
  3. Client encrypts with the workspace_secret.
  4. Client PUTs the blob to cloud_keys_enc; the server stores it verbatim.
  5. The server sets cloud_keys TEXT to null for this workspace (signals "migrated").

Backward compatibility

cloud_keys TEXT cloud_keys_enc BLOB Client behaviour
Non-null Null Legacy path: parse TEXT as JSON, prompt user to encrypt (phase 2 migration UX)
Null Non-null Encrypted path: decrypt BLOB, use JSON
Non-null Non-null Should not occur; encrypted path wins if both present (defensive)
Null Null No keys configured: show BYOK setup

This ensures the old client code continues to work if a workspace has not yet been migrated. New clients handle both paths.


5. Multi-Device Pairing Protocol

The workspace_secret must reach a new device without transiting the server. The mechanism is a direct P2P transfer over WebRTC, using the same signaling infrastructure already present in the v2 realtime layer (see REALTIME-ARCHITECTURE-V2.md §3).

Pairing flow overview

  Existing device (Initiator)           New device (Joiner)
  ──────────────────────────────        ──────────────────────────────
  1. User opens "Pair new device"
     in Settings.
  2. App generates a 6-digit
     pairing code and a short-lived
     ephemeral key pair.
  3. Displays: "Enter this code
     on your new device: 4-7-2-9-1-3"
     (or show QR code).
                                        4. User opens CareerVector,
                                           opens "I have a pairing code",
                                           enters 4-7-2-9-1-3.
  5. Both devices discover each other
     via the signaling endpoint using
     the pairing code as a channel ID.
  6. WebRTC connection established
     (T1/T2 STUN/TURN as usual).
  7. Initiator sends workspace_secret
     encrypted under the ephemeral key
     (or over the already-encrypted
     WebRTC DTLS channel).
                                        8. Joiner receives secret, stores
                                           it in local IndexedDB.
                                        9. Joiner now has full access.
  10. Pairing code is invalidated
      (TTL: 5 minutes or first use).

Pairing code

The 6-digit code is generated by crypto.getRandomValues mapped to decimal digits. It serves as the channel ID on the signaling server and as a human-readable out-of-band authenticator.

The code must be entered on the new device by the user. This is the critical security step: only someone who can see the initiator's screen can complete the pairing. The link-sharing model means anyone with the workspace URL can request a pairing session, but completing it requires the code from the initiator.

Ephemeral key exchange

The signaling channel uses the shared pairing code as a channel ID, and WebRTC's DTLS provides forward-secret encryption for the data channel itself. Because DTLS already encrypts the data channel, the workspace_secret can be sent directly over the established WebRTC data channel without additional application-level wrapping. An optional application-level ECDH wrap (using the public key announced in the signaling offer/answer) can be added for defence-in-depth if desired; it is not required because the DTLS layer already provides this.

Signaling

Pairing uses the same signaling endpoint already implemented for the v2 realtime layer (POST /api/signal), with the pairing code as the room ID instead of the workspace ID. Pairing rooms have a 5-minute TTL enforced by the DO alarm mechanism. No schema changes are needed for signaling.

Anyone with the workspace URL can open the "I have a pairing code" flow and wait for an initiator. They cannot complete the pairing without the code from the initiator. The code is a second factor derived from being able to see the initiator's screen — consistent with the trust model.

Waiting mode

If a new device arrives and no initiator is currently online, the new device shows a message: "To access saved API keys, open CareerVector on a device where you've already configured them, then choose Settings > Pair this device." There is no timeout; the new device can wait indefinitely or the user can re-enter their keys.

Agent pairing

An AI agent running outside the browser (e.g. Claude Code via MCP) pairs once with a human session. The human initiates from their browser; the agent receives the secret and stores it in its local credential store (or passes it as an environment variable for that session). After that, the agent can decrypt cloud_keys_enc independently. The pairing UX for agents is command-line: the human's browser displays the code; the agent is given the code via a prompt or config. This is outside the scope of the app UI; it is an agent deployment concern.


6. Failure Modes

User loses all devices

What happens: All copies of workspace_secret are gone. The ciphertext in D1 cannot be decrypted.

Recovery: The user re-enters their API keys. The client generates a new workspace_secret, re-encrypts, and writes the new blob to D1. Old ciphertext is overwritten.

Verdict: Acceptable. This is the known tradeoff of client-side encryption without a server key escrow. The user loses nothing except the convenience of not re-typing a few API keys — which are recoverable from the provider dashboards.

New device with no active peer online

What happens: The new device cannot complete P2P pairing.

Options available to the user:

  1. Wait until they have access to an existing device (e.g. their desktop), then pair.
  2. Re-enter API keys manually on the new device. The new device generates a new workspace_secret and re-encrypts. Old ciphertext in D1 is superseded on next write.

Verdict: Acceptable. The friction is low: re-entering an API key is a 30-second task. The security gain (zero server-side exposure) is worth the rare inconvenience.

Server compromise

What happens: An attacker with full read access to D1 obtains cloud_keys_enc BLOB values.

Impact: The blobs are AES-256-GCM ciphertext with a key that exists only in client IndexedDB. Without the key, the ciphertext is not decryptable. The attacker gains nothing useful about AI provider keys.

Verdict: Design goal achieved.

Client device compromise

What happens: Malware or physical attacker can read IndexedDB for the CareerVector origin.

Impact: workspace_secret raw bytes are readable. All cloud_keys for workspaces this device has accessed are exposed.

Verdict: Acceptable, bounded. Only one user's keys are affected. This is equivalent to the attacker having full access to the user's browser profile — at that point, the browser itself is the vulnerability. There is no cross-user blast radius.

Pairing code interception

What happens: An attacker observes the pairing code (e.g. shoulder-surfing, screen recording).

Impact: The attacker can initiate a WebRTC pairing with the initiator's device and receive the workspace_secret.

Mitigation: Pairing codes are single-use and expire in 5 minutes. The initiator's UI shows when a connection is established, giving the user the opportunity to abort. After the code is consumed, it cannot be reused.

WebRTC pairing unavailable (severe NAT or firewall)

What happens: The T1/T2 STUN/TURN path fails for the pairing connection.

Impact: Pairing falls back to the T3 thin DO relay (WebSocket). The secret is encrypted via DTLS over that relay. The DO relay is a byte-fanout; it does not see the content.

Verdict: Acceptable. DTLS encryption is maintained even over the DO relay.


7. Implementation Plan

The implementation is additive. The existing TEXT path is never destroyed mid-flight.

Phase 1: Infrastructure (no user-visible change)

  • Add cloud_keys_enc BLOB DEFAULT NULL column to D1 via a D1 migration.
  • Implement WorkspaceSecret module in lib/domain/src/workspace-secret.ts:
    • generateSecret(): Promise<Uint8Array> — generate a fresh 256-bit random value.
    • importKey(raw: Uint8Array): Promise<CryptoKey> — import as non-extractable AES-256-GCM key.
    • encrypt(key: CryptoKey, plaintext: Uint8Array): Promise<Uint8Array> — prepend version + nonce.
    • decrypt(key: CryptoKey, blob: Uint8Array): Promise<Uint8Array> — parse version + nonce + decrypt.
    • persistSecret(wsId: string, raw: Uint8Array): Promise<void> — write raw bytes to IndexedDB.
    • loadSecret(wsId: string): Promise<Uint8Array | null> — read from IndexedDB.
  • Write unit tests: round-trip encrypt/decrypt, nonce uniqueness, version byte parsing, missing secret returns null.

Phase 2: Read path migration

  • Update workspace GET response to include cloud_keys_enc (raw base64 blob) alongside the existing masked cloud_keys TEXT field.
  • Update the client-side WorkspaceLoader to attempt decryption when cloud_keys_enc is present and a secret is available in IndexedDB.
  • If cloud_keys_enc is present but no local secret exists, trigger the pairing/re-entry prompt.
  • No change to write path yet. New workspaces still write to cloud_keys TEXT.

Phase 3: Write path migration

  • When a user saves API key settings:
    • If no workspace_secret exists in IndexedDB for this workspace, generate one and persist it.
    • Encrypt the CloudKeys JSON.
    • Write cloud_keys_enc BLOB to D1 and set cloud_keys TEXT to null.
  • Server-side: update PUT /api/workspaces/:id to accept cloud_keys_enc (blob) and handle null cloud_keys.
  • Existing workspaces retain the TEXT path until the user next saves settings (opportunistic migration).

Phase 4: P2P pairing protocol

  • Implement PairingSession in ui/src/lib/pairing/:
    • initiatorSession(wsId) — generate code, open signaling room, await WebRTC data channel, send secret, close session.
    • joinerSession(code) — connect to signaling room with code, receive secret over WebRTC data channel, write to IndexedDB.
  • Add pairing UI in Settings: "Pair a new device" button on initiator side; "I have a pairing code" flow on joiner side.
  • Add pairing prompt when a new device first encounters an encrypted workspace.

Phase 5: Cleanup

  • After a deployment window (at least 30 days post-Phase 3), scan D1 for workspaces with non-null cloud_keys TEXT and null cloud_keys_enc. Emit a telemetry count. Optionally log a console warning on client side.
  • When unmigrated workspace count reaches zero (or near-zero), schedule DROP COLUMN cloud_keys in a future migration. Do not rush this — there is no correctness risk in keeping the column.

8. Open Questions

Q1: Should pairing require the user to confirm on the initiator side after the connection is established?

The current design is implicit: the initiator generates the code and the first device to connect with that code receives the secret. An explicit confirmation step ("New device connected. Share keys? [Confirm] [Deny]") reduces the risk of an attacker racing a legitimate joiner but adds friction. Recommendation: implement explicit confirmation in v1 of the pairing UI, relax to implicit if user feedback indicates the confirmation is confusing.

Q2: Multiple simultaneous pairing sessions?

The current design allows one pairing session per workspace per initiator at a time. Concurrent pairing (e.g. user pairs two devices simultaneously) is an edge case. Initial implementation should reject a second pairing request while one is in-flight.

Q3: Secret rotation?

If a user suspects their secret has been compromised (device stolen, suspicious access), they may want to re-encrypt with a fresh secret and revoke old copies. This requires:

  • Generating a new secret.
  • Re-encrypting cloud_keys_enc with the new secret.
  • Writing the new blob.
  • The old secret on other devices is now invalid — those devices will get a decryption error and need to re-pair.

Secret rotation is not in scope for the initial implementation. Note it as a future capability.

Q4: Workspace export and import?

If a user exports a workspace (ZIP download), should the export include the plaintext or encrypted keys? Recommendation: plaintext keys in the export (the export is protected by the user downloading it, not by a server), with a warning in the UI that the export contains live API key material.


9. Risks

Risk 1: IndexedDB is not guaranteed durable. Browsers can evict IndexedDB under storage pressure (especially in private/incognito mode). If workspace_secret is evicted, the user faces the same UX as losing a device. Mitigation: do not support private/incognito mode for workspaces with encrypted keys (or show a warning); document the risk in Settings.

Risk 2: Future browser API changes may affect SubtleCrypto semantics. WebCrypto has been stable for years and is a W3C Recommendation. The risk is low but non-zero. Mitigation: the version byte in the blob format allows algorithm migration without data loss.

Risk 3: Agent pairing is out-of-band. Agent pairing depends on the human-operator workflow described in Section 5. If this workflow is not documented or is misunderstood, an agent deployment could end up configured without access to encrypted keys and fail silently. Mitigation: agent pairing is documented in the MCP server README and the agent AGENT.md.

Risk 4: Phase 3 opportunistic migration means some workspaces stay on TEXT indefinitely. Workspaces where the user never revisits Settings will not migrate. If the user's keys are never rotated or changed, the TEXT column stays populated. Mitigation: Phase 5 telemetry identifies unmigrated workspaces. A future release can add an explicit migration prompt (e.g. banner: "Your API keys are not yet encrypted — click here to protect them").


10. Rejected Alternatives

Option A: Pure P2P, no D1 storage

Design: API keys are never persisted to D1. They live only in IndexedDB on each device. Multi-device access is handled entirely by P2P transfer on demand: a new device requests the keys from an online peer each time.

Why rejected:

  • Availability. If no peer is online, the new device has no keys and the cascade fails silently. This makes AI features unreliable in a multi-device, async workflow.
  • Agent incompatibility. An agent needs keys available on-demand, without a human peer online. Pure P2P cannot satisfy this.
  • Complexity for no gain. D1 with encrypted blobs provides the same confidentiality guarantee as pure P2P (server cannot decrypt in either case) while adding durability and async availability. Pure P2P adds complexity (on-demand request/transfer protocol, queue for offline joiners) with no security benefit over encrypted D1 storage.

Option C: URL fragment key transport

Design: The workspace_secret is included in the workspace URL as a fragment (#key=...) so it is never sent to the server. New devices receive the key in the URL itself when the user shares a link with the key fragment appended.

Why rejected:

  • Link = identity is foundational. CareerVector's access model is that the workspace URL is the access credential. Baking a cryptographic secret into the URL conflates the sharing credential with the encryption key. Sharing the link would now mean sharing the encryption key, defeating the purpose.
  • No independent key rotation. If the secret is in the URL, rotating the key means changing the URL, which breaks all existing links.
  • Browser history and logging. URL fragments appear in browser history, server access logs for navigations that leak the fragment (some do), and HTTP Referer headers. Storing a 256-bit secret in a URL is not appropriate.
  • Agent incompatibility. Agents would need the full URL-with-fragment to operate, coupling key management to URL management in a fragile way.

Option D: Server-managed key wrapping (KMS)

Design: The server generates or wraps the workspace_secret using Cloudflare's managed encryption or a KMS service. The wrapped key is stored alongside the ciphertext. The server unwraps on demand.

Why rejected:

  • Violates the core goal. If the server can unwrap the key, a server compromise or KMS access compromise means keys are exposed. The threat model we are protecting against is exactly this.
  • Introduces server-side key management complexity. KMS billing, API availability, key rotation, and cross-region replication are all infrastructure concerns that contradict the zero-infrastructure stance of this project.
  • CLAUDE.md §1 (no user accounts). Server-side key management implies binding keys to identities. Without user accounts, there is no safe way to decide which requests get unwrapped keys from the server.
Source: wiki/content/architecture/BYOK-ENCRYPTION-DESIGN.md