Skip to content

feat(security): seal legacy plaintext Resend API keys, fail closed on bad keys#51

Open
jusso-dev wants to merge 1 commit into
mainfrom
claude/issue-28-resend-key-fail-closed
Open

feat(security): seal legacy plaintext Resend API keys, fail closed on bad keys#51
jusso-dev wants to merge 1 commit into
mainfrom
claude/issue-28-resend-key-fail-closed

Conversation

@jusso-dev

Copy link
Copy Markdown
Owner

Summary

Closes #28.

Audit finding. organisations.resendApiKeyEncrypted is wrapped with the existing AES-GCM sealTotpSecret helper on the settings save path (app/actions/settings.ts:191), and on next admin save it lazily seals legacy plaintext rows. But two gaps remained:

  1. Tenants who never re-saved settings still held plaintext re_… keys in the DB.
  2. decryptIfSet in lib/email/campaign-sender.ts had a fallback: when AES-GCM unsealing threw, it returned the raw value if it looked like a Resend key. That defeats the point of at-rest encryption (an attacker with DB access but no BETTER_AUTH_SECRET still gets a working key) and meant a wrong key silently appeared to succeed.

Changes.

  • lib/auth/secret-backfill.ts: new backfillSealedResendKeys finds any rows matching the plaintext shape and seals them in place. Idempotent — already-sealed rows no longer match the plaintext regex.
  • instrumentation.ts: runs the backfill on boot next to the existing SSO cache warmer. Failures are logged but non-fatal.
  • lib/email/campaign-sender.ts: decryptIfSet drops the plaintext fallback. Any value that fails AES-GCM unsealing now throws — wrong key fails closed.
  • scripts/capture-readme-screenshots.ts: seals its dummy re_readme_capture_fake_key before insert so the screenshot tenant matches the new invariant.

Test plan

  • On a tenant whose row is plaintext: boot the app → backfill seals it → DB dump shows base64 ciphertext.
  • On a tenant with a row sealed under the wrong BETTER_AUTH_SECRET: launching a campaign throws (the unable to authenticate data GCM error) instead of returning a stale plaintext.
  • On a tenant with a normally sealed row: getTransportForOrganisation returns the decrypted key as before.

https://claude.ai/code/session_01PiiqDRQJdW1sBLvEmZ3GBC


Generated by Claude Code

… bad keys (closes #28)

The settings save path already wraps `resendApiKeyEncrypted` with the
AES-GCM `sealTotpSecret` helper, but rows imported before that change —
and DB dumps from tenants who have never re-saved settings — still
contain plaintext `re_...` keys. `decryptIfSet` in
`lib/email/campaign-sender.ts` papered over this by falling back to
returning the raw plaintext when the auth-tag check failed. That meant
an attacker with DB access (but no `BETTER_AUTH_SECRET`) could still
recover working Resend keys, and a wrong key silently appeared to
succeed instead of failing closed.

This change:
  - Adds `lib/auth/secret-backfill.ts` with `backfillSealedResendKeys`,
    which finds any rows matching the plaintext shape and seals them
    in place. Idempotent — already-sealed rows are skipped because they
    no longer match the plaintext regex.
  - Wires the backfill into `instrumentation.ts` next to the existing
    SSO cache warmer. Failures are logged but non-fatal.
  - Removes the plaintext fallback from `decryptIfSet`: any value that
    fails AES-GCM unsealing now throws, so a wrong key fails closed.
  - Fixes the README screenshot capture script to seal its dummy key
    before insert.

DB dumps now show ciphertext for `resend_api_key_encrypted` on every
tenant once instrumentation has run.
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.

Verify KMS for resendApiKeyEncrypted column

2 participants