InkdownInkdown
Start writing

Anurag

2 files·0 subfolders

Shared Workspace

Anurag
api-key-encryption-que

api-key-encryption-que

Shared from "Anurag" on Inkdown

4. Security — BYOK Encryption, SSRF, Prompt Injection

Q4.1 — Walk me through your BYOK (Bring Your Own Key) implementation end-to-end.

Short: User pastes their OpenAI key in the UI, the client POSTs it to /api/settings/api-key, the server validates the format with regex, encrypts it with AES-256-GCM using a server-side master key, stores the ciphertext in the User.encryptedApiKey column. On chat requests, the server decrypts it, uses it for the LLM call, and never returns the plaintext to the client (only a masked version like sk-...XXXX).

In-depth:

Cold email template

Storage path:

  1. Client POST /api/settings/api-key with { apiKey }.
  2. Server validates: ^sk-(proj-|svcacct-)?[A-Za-z0-9_-]{20,}$ — rejects malformed keys.
  3. Length cap (API_KEY_MAX_LENGTH) prevents payload abuse.
  4. encryptApiKey(plaintext) — AES-256-GCM:
    TypeScript
  5. prisma.user.update({ encryptedApiKey, apiKeyUpdatedAt: new Date() }).

Retrieval path:

  1. Chat request → server reads user.encryptedApiKey.
  2. decryptApiKey(stored) — splits on :, validates 3 parts, sets auth tag, decrypts.
  3. Plaintext passed to new OpenAI({ apiKey }).
  4. The OpenAI client is cached server-side (LRU, 32 entries, keyed by sha256(apiKey)) so we don't reconstruct it every request.

Display path:

  1. GET /api/settings/api-key returns { exists, maskedKey, updatedAt }.
  2. maskApiKey(decrypted) returns sk-proj...XXXX — never the full key.
  3. Client never sees plaintext.

The master encryption key (ENCRYPTION_KEY) is required server-side only. If it's a 64-char hex string it's used directly as 32 bytes; otherwise it's SHA-256-hashed to 32 bytes (allows passphrase-style configs but discourages weak keys via the regex check in the env loader).


Q4.2 — Why AES-256-GCM specifically? Why not AES-CBC or something simpler?

Short: GCM is authenticated encryption — it provides both confidentiality and integrity in one mode. CBC needs a separate HMAC; getting that combination right is famously easy to mess up (padding oracle attacks, etc.). GCM is the modern default for symmetric encryption.

In-depth:

  • AES-CBC encrypts but doesn't authenticate. An attacker who can flip bits in the ciphertext can corrupt the plaintext in predictable ways. You need HMAC-then-encrypt or encrypt-then-HMAC, and historically people got the order wrong (encrypt-then-HMAC is correct).
  • AES-GCM uses Galois/Counter Mode to produce both ciphertext and an authentication tag in a single pass. Decryption verifies the tag — any modification to the ciphertext or IV causes decryption to throw. Built-in integrity.
  • AES-CCM is similar but slower and less common in TLS/JWE.

Properties I rely on:

  1. IV uniqueness. A fresh 16-byte random IV per encryption — GCM is catastrophic if IV is ever reused with the same key (ciphertext XOR leaks plaintext XOR). 16 bytes of randomness gives ~2^64 messages before birthday collision becomes likely; well within safety bounds.
  2. Auth tag verification. decipher.setAuthTag(authTag) then decipher.final() throws if tampered. I catch the throw and return a generic decrypt error — never leak why it failed.
  3. Format. Storing IV alongside ciphertext (in the : -delimited string) is standard. Splitting and reassembling is cheap; the auth tag goes in its own field for clarity.

Could I use libsodium or AWS KMS? Yes, and KMS would be better in a multi-tenant production setting because you don't have to manage the master key yourself. For this project, a single env-var-managed key was a reasonable scope.


Q4.3 — What if the ENCRYPTION_KEY env var leaks?

Short: Game over for stored API keys — an attacker with the master key can decrypt every user's stored key. The mitigation is treating it as the highest-tier secret (Vercel/AWS Parameter Store, never in repo, rotated on suspicion of compromise) and using KMS for production.

In-depth: This is the standard envelope-encryption problem. You're protecting many user secrets with one master key. The compromises and mitigations:

  • In repo / commit history: never. .env.example has the placeholder. .gitignore covers .env.
  • In logs: never logged. Only the fact that decryption failed is logged.
  • In errors: the encryption module throws generic messages; never includes the key value.
  • In memory: Node holds it as a Buffer. Process-level isolation is the boundary.
  • Rotation: if compromised, you'd need to:
    1. Generate new key.
    2. For each user, decrypt with old key + re-encrypt with new key (offline migration).
    3. Update the env var.
    4. Force re-auth or re-entry of API keys for safety.

Production-grade alternatives I'd consider:

  • AWS KMS. Encrypt user keys with a KMS-managed CMK. Gives audit logs, IAM-controlled access, automatic key rotation. Trade-off: every encrypt/decrypt is a network call to KMS.
  • Envelope encryption with KMS. Use KMS to encrypt a per-user data key, store the encrypted data key alongside the ciphertext. Best of both — KMS isolation plus local crypto speed.
  • Vault / 1Password Secrets Automation. External secret manager with retrieval at process startup.

For a side project / early product, a single env-managed key is acceptable. At enterprise scale, I'd move to KMS envelope encryption.