Skip to content

feat(api): encrypt webhook signing secrets at rest - #81

Open
VladUZH wants to merge 1 commit into
mainfrom
feat/webhook-secret-encryption
Open

feat(api): encrypt webhook signing secrets at rest#81
VladUZH wants to merge 1 commit into
mainfrom
feat/webhook-secret-encryption

Conversation

@VladUZH

@VladUZH VladUZH commented Jul 29, 2026

Copy link
Copy Markdown
Member

Closes a long-standing known gap: WebhookEndpoint.secret was stored in plaintext, so a database leak handed an attacker valid-signature forgery against every tenant's webhook consumer.

Design

  • AES-256-GCM under SECRET_ENCRYPTION_KEY (64 hex chars); stored as enc:v1:<iv>:<tag>:<ct>, fresh IV per value
  • Encrypt at endpoint creation; decrypt at both signing sites (delivery + /test)
  • Legacy plaintext rows pass through on decrypt, so existing endpoints keep working until the idempotent backfill (apps/api/scripts/encrypt-webhook-secrets.ts) is run
  • Production boot gate (same pattern as SESSION_SECRET): refuses to start without the key, so a deploy can't silently write new secrets in plaintext; dev/test fall back to plaintext by design
  • 7 unit tests (round-trip, fresh-IV, passthrough, tamper-detection, missing-key, malformed-key); test-api now runs with a throwaway key so the integration suite exercises the real encrypt→decrypt path

⚠️ Merge order — DO NOT merge before step 1

  1. On Railway (platform service): set SECRET_ENCRYPTION_KEY = output of openssl rand -hex 32
  2. Merge this PR (deploy picks up the key)
  3. Once, from a shell with prod DATABASE_URL + the key: npx tsx scripts/encrypt-webhook-secrets.ts

Merging before step 1 will fail the production boot gate and 502 the API.

🤖 Generated with Claude Code

WebhookEndpoint.secret was stored in plaintext — a DB leak handed an
attacker the ability to forge valid signatures for every tenant's
webhook consumer. Now AES-256-GCM under SECRET_ENCRYPTION_KEY:

- lib/secret-crypto.ts: enc:v1:<iv>:<tag>:<ct> format, fresh IV per
  value, legacy plaintext passthrough on decrypt (7 unit tests incl.
  tamper-detection)
- encrypt on endpoint creation; decrypt at both signing sites
- production boot gate mirroring SESSION_SECRET; dev/test fall back
  to plaintext
- scripts/encrypt-webhook-secrets.ts: idempotent one-time backfill
- CI: test-api runs with a throwaway key to exercise the real path

DEPLOY NOTE: set SECRET_ENCRYPTION_KEY on Railway before merging,
then run the backfill once.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
throw new Error('Malformed encrypted secret');
}
const [ivB64, tagB64, ctB64] = parts as [string, string, string];
const decipher = createDecipheriv('aes-256-gcm', key, Buffer.from(ivB64, 'base64'));
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants