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(), })