/** * One-time migration: encrypt any plaintext credential rows in * `system_settings` that should now be AES-256-GCM encrypted per the * settings registry. Safe to re-run (idempotent — only touches plaintext * rows, skips rows that are already encrypted envelopes). * * Currently handles: * - `documenso_api_key_override` → in-place encrypt * - `storage_s3_access_key` (legacy) → encrypt + move to * `storage_s3_access_key_encrypted` * - `documenso_webhook_secret` (if string) → in-place encrypt * * Run: `pnpm tsx scripts/encrypt-plaintext-credentials.ts` */ import { and, eq, isNull } from 'drizzle-orm'; import { db } from '@/lib/db'; import { systemSettings } from '@/lib/db/schema'; import { encrypt } from '@/lib/utils/encryption'; const KEYS_TO_ENCRYPT_IN_PLACE = ['documenso_api_key_override', 'documenso_webhook_secret']; function isEncryptedEnvelope(value: unknown): boolean { return ( typeof value === 'object' && value !== null && typeof (value as { iv?: unknown }).iv === 'string' && typeof (value as { tag?: unknown }).tag === 'string' && typeof (value as { data?: unknown }).data === 'string' ); } async function encryptInPlace(key: string): Promise<{ touched: number; skipped: number }> { const rows = await db .select({ key: systemSettings.key, portId: systemSettings.portId, value: systemSettings.value }) .from(systemSettings) .where(eq(systemSettings.key, key)); let touched = 0; let skipped = 0; for (const row of rows) { if (isEncryptedEnvelope(row.value)) { skipped++; continue; } if (typeof row.value !== 'string' || row.value === '') { skipped++; continue; } const envelope = JSON.parse(encrypt(row.value)) as { iv: string; tag: string; data: string; }; if (row.portId) { await db .update(systemSettings) .set({ value: envelope, updatedAt: new Date() }) .where(and(eq(systemSettings.key, key), eq(systemSettings.portId, row.portId))); } else { await db .update(systemSettings) .set({ value: envelope, updatedAt: new Date() }) .where(and(eq(systemSettings.key, key), isNull(systemSettings.portId))); } touched++; } return { touched, skipped }; } async function moveS3AccessKeyToEncrypted(): Promise<{ moved: number; alreadyMigrated: number; }> { // Move global rows only — s3 storage settings are global by design. const legacyRows = await db .select({ value: systemSettings.value }) .from(systemSettings) .where(and(eq(systemSettings.key, 'storage_s3_access_key'), isNull(systemSettings.portId))); if (legacyRows.length === 0) { return { moved: 0, alreadyMigrated: 0 }; } // Check if the encrypted form already exists. const existingEncrypted = await db .select({ key: systemSettings.key }) .from(systemSettings) .where( and(eq(systemSettings.key, 'storage_s3_access_key_encrypted'), isNull(systemSettings.portId)), ); if (existingEncrypted.length > 0) { // Encrypted form wins; leave the legacy row in place so reads still // tolerate it (the storage layer reads both and prefers encrypted). return { moved: 0, alreadyMigrated: legacyRows.length }; } const plaintext = legacyRows[0]!.value; if (typeof plaintext !== 'string' || plaintext === '') { return { moved: 0, alreadyMigrated: 0 }; } const envelope = JSON.parse(encrypt(plaintext)) as { iv: string; tag: string; data: string }; await db.insert(systemSettings).values({ key: 'storage_s3_access_key_encrypted', portId: null, value: envelope, }); // Drop the legacy plaintext row so it doesn't show up in admin // settings dumps anymore. The storage layer's backward-compat path // continues to handle older rows on other deployments. await db .delete(systemSettings) .where(and(eq(systemSettings.key, 'storage_s3_access_key'), isNull(systemSettings.portId))); return { moved: 1, alreadyMigrated: 0 }; } async function main(): Promise { console.log('Encrypting plaintext credentials...'); for (const key of KEYS_TO_ENCRYPT_IN_PLACE) { const { touched, skipped } = await encryptInPlace(key); console.log(` ${key}: ${touched} encrypted, ${skipped} skipped`); } const s3 = await moveS3AccessKeyToEncrypted(); console.log( ` storage_s3_access_key → _encrypted: ${s3.moved} moved, ${s3.alreadyMigrated} already migrated`, ); console.log('Done.'); process.exit(0); } main().catch((err: unknown) => { console.error('Migration failed:', err); process.exit(1); });