From b76c56037e044093db006167d53a7b424e5ae0ea Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 23 May 2026 13:44:09 +0000 Subject: [PATCH] feat(security): seal legacy plaintext Resend API keys, fail closed on bad keys (closes #28) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- instrumentation.ts | 7 +++ lib/auth/secret-backfill.ts | 61 +++++++++++++++++++++++++++ lib/email/campaign-sender.ts | 11 +++-- scripts/capture-readme-screenshots.ts | 3 +- 4 files changed, 75 insertions(+), 7 deletions(-) create mode 100644 lib/auth/secret-backfill.ts diff --git a/instrumentation.ts b/instrumentation.ts index dcc6b96..93b72ed 100644 --- a/instrumentation.ts +++ b/instrumentation.ts @@ -10,4 +10,11 @@ export async function register() { await ensureSsoCacheLoaded().catch((error) => { console.error("Failed to warm SSO cache during instrumentation", error); }); + + // Seal any plaintext Resend API keys carried over from before AES-GCM at-rest + // encryption shipped. Idempotent — rows already sealed are skipped. + const { backfillSealedResendKeys } = await import("./lib/auth/secret-backfill"); + await backfillSealedResendKeys().catch((error) => { + console.error("Failed to backfill sealed resend keys during instrumentation", error); + }); } diff --git a/lib/auth/secret-backfill.ts b/lib/auth/secret-backfill.ts new file mode 100644 index 0000000..6fa90f5 --- /dev/null +++ b/lib/auth/secret-backfill.ts @@ -0,0 +1,61 @@ +import { eq, sql } from "drizzle-orm"; + +import { sealTotpSecret } from "@/lib/auth/totp"; +import { db } from "@/lib/db/client"; +import { organisations } from "@/lib/db/schema"; + +/** + * Resend API key prefix. New keys (`re_…`) are short enough that an + * accidentally-plaintext value still fits inside the `text` column that + * also stores AES-GCM ciphertext (base64). The pattern below matches the + * complete plaintext shape so we never mistake a sealed value (random + * base64) for a plaintext key. + */ +const RESEND_PLAINTEXT_PATTERN = /^re_[A-Za-z0-9_-]+$/; + +export type ResendKeyBackfillResult = { + scanned: number; + sealed: number; + errors: string[]; +}; + +/** + * Find any rows whose `resend_api_key_encrypted` column still holds a + * plaintext Resend key (legacy data from before the AES-GCM wrapper + * landed) and seal them at rest. Safe to run on every boot — idempotent + * because rows that no longer match the plaintext pattern are skipped. + */ +export async function backfillSealedResendKeys(): Promise { + const result: ResendKeyBackfillResult = { scanned: 0, sealed: 0, errors: [] }; + + const candidates = await db + .select({ + id: organisations.id, + resendApiKeyEncrypted: organisations.resendApiKeyEncrypted, + }) + .from(organisations) + .where(sql`${organisations.resendApiKeyEncrypted} ~ '^re_[A-Za-z0-9_-]+$'`); + + result.scanned = candidates.length; + + for (const row of candidates) { + if (!row.resendApiKeyEncrypted || !RESEND_PLAINTEXT_PATTERN.test(row.resendApiKeyEncrypted)) { + continue; + } + try { + const sealed = sealTotpSecret(row.resendApiKeyEncrypted); + await db + .update(organisations) + .set({ resendApiKeyEncrypted: sealed }) + .where(eq(organisations.id, row.id)); + result.sealed += 1; + console.log(`[secret-backfill] sealed plaintext resend key for org=${row.id}`); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + result.errors.push(`${row.id}: ${message}`); + console.error(`[secret-backfill] failed to seal resend key org=${row.id}: ${message}`); + } + } + + return result; +} diff --git a/lib/email/campaign-sender.ts b/lib/email/campaign-sender.ts index 63d4a9b..70f4dd2 100644 --- a/lib/email/campaign-sender.ts +++ b/lib/email/campaign-sender.ts @@ -296,12 +296,11 @@ export type OrganisationTransportConfig = { function decryptIfSet(sealed: string | null): string | null { if (!sealed) return null; - try { - return openTotpSecret(sealed); - } catch (error) { - if (/^re_[A-Za-z0-9_-]+$/.test(sealed)) return sealed; - throw error; - } + // No plaintext fallback: if `openTotpSecret` throws, the AES-GCM auth tag + // failed or the column is not sealed. Either way we fail closed rather than + // hand the caller a credential we cannot vouch for. Plaintext rows are + // sealed on boot by `backfillSealedResendKeys` (see instrumentation.ts). + return openTotpSecret(sealed); } export function getTransportForOrganisation(org: OrganisationTransportConfig): CampaignTransport { diff --git a/scripts/capture-readme-screenshots.ts b/scripts/capture-readme-screenshots.ts index 8d5a56d..a17858f 100644 --- a/scripts/capture-readme-screenshots.ts +++ b/scripts/capture-readme-screenshots.ts @@ -5,6 +5,7 @@ import { dirname, resolve } from "node:path"; import { spawn, type ChildProcess } from "node:child_process"; import { randomUUID } from "node:crypto"; +import { sealTotpSecret } from "@/lib/auth/totp"; import { db, sql as dbSql } from "@/lib/db/client"; import { campaignTargets, @@ -209,7 +210,7 @@ async function seedProductData(): Promise { await db .update(organisations) .set({ - resendApiKeyEncrypted: "re_readme_capture_fake_key", + resendApiKeyEncrypted: sealTotpSecret("re_readme_capture_fake_key"), senderFromAddress: "Collie Training ", updatedAt: new Date(), })