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_keyspath 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 bySubtleCryptoas 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:
- Server returns
cloud_keys_enc BLOB(orcloud_keys TEXTfor unencrypted legacy rows). - Client checks: if
cloud_keys_encis present and non-null, decrypt with theworkspace_secretfrom IndexedDB before parsing. - If
workspace_secretis absent from IndexedDB (new device, cleared storage), show the pairing prompt (see Section 5) or the re-entry prompt. - After decryption, the
CloudKeysJSON 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
- User saves API key settings in the browser.
- Client serialises the
CloudKeysJSON to UTF-8 bytes. - Client encrypts with the
workspace_secret. - Client
PUTs the blob tocloud_keys_enc; the server stores it verbatim. - The server sets
cloud_keys TEXTtonullfor 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.
Link = identity compatibility
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:
- Wait until they have access to an existing device (e.g. their desktop), then pair.
- Re-enter API keys manually on the new device. The new device generates a new
workspace_secretand 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 NULLcolumn to D1 via a D1 migration. - Implement
WorkspaceSecretmodule inlib/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 maskedcloud_keysTEXT field. - Update the client-side
WorkspaceLoaderto attempt decryption whencloud_keys_encis present and a secret is available in IndexedDB. - If
cloud_keys_encis 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_secretexists in IndexedDB for this workspace, generate one and persist it. - Encrypt the
CloudKeysJSON. - Write
cloud_keys_enc BLOBto D1 and setcloud_keys TEXTto null.
- If no
- Server-side: update
PUT /api/workspaces/:idto acceptcloud_keys_enc(blob) and handle nullcloud_keys. - Existing workspaces retain the TEXT path until the user next saves settings (opportunistic migration).
Phase 4: P2P pairing protocol
- Implement
PairingSessioninui/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 TEXTand nullcloud_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_keysin 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
Refererheaders. 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.