Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions instrumentation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
}
61 changes: 61 additions & 0 deletions lib/auth/secret-backfill.ts
Original file line number Diff line number Diff line change
@@ -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<ResendKeyBackfillResult> {
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;
}
11 changes: 5 additions & 6 deletions lib/email/campaign-sender.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
3 changes: 2 additions & 1 deletion scripts/capture-readme-screenshots.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -209,7 +210,7 @@ async function seedProductData(): Promise<SeedResult> {
await db
.update(organisations)
.set({
resendApiKeyEncrypted: "re_readme_capture_fake_key",
resendApiKeyEncrypted: sealTotpSecret("re_readme_capture_fake_key"),
senderFromAddress: "Collie Training <training@example.test>",
updatedAt: new Date(),
})
Expand Down