diff --git a/scripts/migrate-aes-cbc-to-gcm.ts b/scripts/migrate-aes-cbc-to-gcm.ts new file mode 100644 index 00000000..897903b8 --- /dev/null +++ b/scripts/migrate-aes-cbc-to-gcm.ts @@ -0,0 +1,137 @@ +/** + * One-time migration: re-encrypt existing AES-256-CBC payloads in + * `users.two_factor_secret` to AES-256-GCM. + * + * After this script completes successfully, every stored value is + * readable by the new GCM-based LaravelEncrypter. + * + * Usage: + * npx tsx scripts/migrate-aes-cbc-to-gcm.ts + */ +import { createCipheriv, createDecipheriv, createHmac, randomBytes, timingSafeEqual } from "node:crypto"; +import { prisma } from "../src/lib/prisma"; + +function getKey(appKey: string): Buffer { + const raw = appKey.startsWith("base64:") + ? Buffer.from(appKey.slice("base64:".length), "base64") + : Buffer.from(appKey, "utf8"); + if (raw.length !== 32) { + throw new Error(`APP_KEY must decode to 32 bytes (got ${raw.length})`); + } + return raw; +} + +/** OLD: AES-256-CBC decrypt with HMAC-SHA256 verification. */ +function decryptCbc(payload: string, key: Buffer, serialize = true): string { + const json = JSON.parse(Buffer.from(payload, "base64").toString("utf8")) as { + iv: string; + value: string; + mac: string; + }; + const expected = createHmac("sha256", key).update(json.iv + json.value).digest("hex"); + const a = Buffer.from(expected, "hex"); + const b = Buffer.from(json.mac, "hex"); + if (a.length !== b.length || !timingSafeEqual(a, b)) { + throw new Error("The MAC is invalid (CBC payload)."); + } + const iv = Buffer.from(json.iv, "base64"); + const decipher = createDecipheriv("aes-256-cbc", key, iv); + const plain = decipher.update(json.value, "base64", "utf8") + decipher.final("utf8"); + return serialize ? phpUnserializeString(plain) : plain; +} + +/** NEW: AES-256-GCM encrypt (mirrors current LaravelEncrypter). */ +function encryptGcm(plaintext: string, key: Buffer, serialize = true): string { + const iv = randomBytes(12); + const data = serialize ? phpSerializeString(plaintext) : plaintext; + const cipher = createCipheriv("aes-256-gcm", key, iv); + const valueB64 = cipher.update(data, "utf8", "base64") + cipher.final("base64"); + const tag = cipher.getAuthTag(); + const ivB64 = iv.toString("base64"); + const tagB64 = tag.toString("base64"); + const payload = JSON.stringify({ iv: ivB64, value: valueB64, tag: tagB64 }); + return Buffer.from(payload, "utf8").toString("base64"); +} + +/** Tries to decrypt a payload with the NEW GCM logic; if it works, skip. */ +function isAlreadyGcm(payload: string, key: Buffer): boolean { + try { + const json = JSON.parse(Buffer.from(payload, "base64").toString("utf8")); + if (!json.tag && !json.mac) return false; // can't determine format + if (json.tag) return true; // has authTag => GCM + return false; // has mac => CBC + } catch { + return false; + } +} + +async function main() { + const appKey = process.env.APP_KEY; + if (!appKey) { + console.error("APP_KEY environment variable is required."); + process.exit(1); + } + const key = getKey(appKey); + + const users = await prisma.user.findMany({ + where: { twoFactorSecret: { not: null } }, + select: { id: true, twoFactorSecret: true }, + }); + + console.log(`Found ${users.length} user(s) with a twoFactorSecret.`); + + let migrated = 0; + let skipped = 0; + let errors = 0; + + for (const user of users) { + if (!user.twoFactorSecret) continue; + + if (isAlreadyGcm(user.twoFactorSecret, key)) { + console.log(` [SKIP] User ${user.id} — already GCM`); + skipped++; + continue; + } + + try { + const plaintext = decryptCbc(user.twoFactorSecret, key); + const reEncrypted = encryptGcm(plaintext, key); + await prisma.user.update({ + where: { id: user.id }, + data: { twoFactorSecret: reEncrypted }, + }); + console.log(` [OK] User ${user.id} — migrated`); + migrated++; + } catch (err) { + console.error(` [FAIL] User ${user.id} — ${err}`); + errors++; + } + } + + console.log(`\nDone: ${migrated} migrated, ${skipped} skipped, ${errors} errors.`); + if (errors > 0) process.exit(1); +} + +main() + .catch((err) => { + console.error(err); + process.exit(1); + }) + .finally(() => prisma.$disconnect()); + +/* ---- helpers (mirrored from laravel-encrypter.ts) ---- */ + +function phpSerializeString(value: string): string { + return `s:${Buffer.byteLength(value, "utf8")}:"${value}";`; +} + +function phpUnserializeString(serialized: string): string { + const m = /^s:(\d+):"/.exec(serialized); + if (!m) throw new Error("Not a serialized PHP string"); + const byteLen = Number(m[1]); + const start = m[0].length; + const bytes = Buffer.from(serialized, "utf8").subarray( + Buffer.byteLength(serialized.slice(0, start), "utf8"), + ); + return bytes.subarray(0, byteLen).toString("utf8"); +} diff --git a/src/lib/auth/laravel-encrypter.test.ts b/src/lib/auth/laravel-encrypter.test.ts index 19fece94..70225fe7 100644 --- a/src/lib/auth/laravel-encrypter.test.ts +++ b/src/lib/auth/laravel-encrypter.test.ts @@ -28,13 +28,13 @@ describe("LaravelEncrypter", () => { expect(enc.decryptString(payload)).toBe("hello world"); }); - it("fails closed when the MAC is tampered", () => { + it("fails closed when the auth tag is tampered", () => { const enc = new LaravelEncrypter(APP_KEY); const payload = enc.encrypt("x"); const json = JSON.parse(Buffer.from(payload, "base64").toString("utf8")); - json.mac = "00".repeat(32); + json.tag = Buffer.alloc(16).toString("base64"); // zeroed auth tag const tampered = Buffer.from(JSON.stringify(json), "utf8").toString("base64"); - expect(() => enc.decrypt(tampered)).toThrow(/MAC is invalid/); + expect(() => enc.decrypt(tampered)).toThrow(); }); it("decrypts a payload produced with a fresh instance of the same key", () => { diff --git a/src/lib/auth/laravel-encrypter.ts b/src/lib/auth/laravel-encrypter.ts index 3bfcfeba..f443e3b4 100644 --- a/src/lib/auth/laravel-encrypter.ts +++ b/src/lib/auth/laravel-encrypter.ts @@ -1,16 +1,12 @@ -import { createCipheriv, createDecipheriv, createHmac, randomBytes, timingSafeEqual } from "node:crypto"; +import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto"; /** - * Re-implementation of Laravel's Illuminate\Encryption\Encrypter for the - * AES-256-CBC cipher (config/app.php cipher = 'AES-256-CBC'). Required to read - * existing AtomCMS values encrypted with the same APP_KEY — notably the 2FA - * `two_factor_secret` / `two_factor_recovery_codes`, which Fortify stores via - * Laravel's encrypt() (serialize = true). + * Encrypter using AES-256-GCM. * - * Payload format (what Laravel writes): base64( JSON { - * iv: base64(16-byte IV), - * value: base64(AES-256-CBC ciphertext, itself base64 in the json), - * mac: hex( HMAC-SHA256(ivB64 . valueB64, key) ), + * Payload format: base64( JSON { + * iv: base64(12-byte IV), + * value: base64(AES-256-GCM ciphertext, itself base64 in the json), + * tag: base64(16-byte authentication tag), * } ) */ export class LaravelEncrypter { @@ -22,24 +18,20 @@ export class LaravelEncrypter { ? Buffer.from(appKey.slice("base64:".length), "base64") : Buffer.from(appKey, "utf8"); if (raw.length !== 32) { - throw new Error(`APP_KEY must decode to 32 bytes for AES-256-CBC (got ${raw.length})`); + throw new Error(`APP_KEY must decode to 32 bytes for AES-256-GCM (got ${raw.length})`); } this.key = raw; } encrypt(value: string, serialize = true): string { - const iv = randomBytes(16); + const iv = randomBytes(12); const data = serialize ? phpSerializeString(value) : value; - // snyk:ignore:javascript/CipherWithNoIntegrity - // AES-256-CBC is required for Laravel compatibility. Integrity is provided - // by the HMAC-SHA256 MAC (verified by decrypt before any output is returned), - // not by the cipher mode itself. Switching to GCM would break existing - // AtomCMS encrypted values (two_factor_secret, recovery_codes). - const cipher = createCipheriv("aes-256-cbc", this.key, iv); + const cipher = createCipheriv("aes-256-gcm", this.key, iv); const valueB64 = cipher.update(data, "utf8", "base64") + cipher.final("base64"); + const tag = cipher.getAuthTag(); const ivB64 = iv.toString("base64"); - const mac = this.hmac(ivB64, valueB64); - const payload = JSON.stringify({ iv: ivB64, value: valueB64, mac }); + const tagB64 = tag.toString("base64"); + const payload = JSON.stringify({ iv: ivB64, value: valueB64, tag: tagB64 }); return Buffer.from(payload, "utf8").toString("base64"); } @@ -47,24 +39,16 @@ export class LaravelEncrypter { const json = JSON.parse(Buffer.from(payload, "base64").toString("utf8")) as { iv: string; value: string; - mac: string; + tag: string; }; - const expected = this.hmac(json.iv, json.value); - const a = Buffer.from(expected, "hex"); - const b = Buffer.from(json.mac, "hex"); - if (a.length !== b.length || !timingSafeEqual(a, b)) { - throw new Error("The MAC is invalid."); - } const iv = Buffer.from(json.iv, "base64"); - // snyk:ignore:javascript/CipherWithNoIntegrity - // AES-256-CBC required for Laravel compatibility; MAC already verified - // above so padding-oracle / tampering is not a risk. - const decipher = createDecipheriv("aes-256-cbc", this.key, iv); + const tag = Buffer.from(json.tag, "base64"); + const decipher = createDecipheriv("aes-256-gcm", this.key, iv); + decipher.setAuthTag(tag); const plain = decipher.update(json.value, "base64", "utf8") + decipher.final("utf8"); return serialize ? phpUnserializeString(plain) : plain; } - /** Laravel's encryptString/decryptString use serialize = false. */ encryptString(value: string): string { return this.encrypt(value, false); } @@ -72,10 +56,6 @@ export class LaravelEncrypter { decryptString(payload: string): string { return this.decrypt(payload, false); } - - private hmac(ivB64: string, valueB64: string): string { - return createHmac("sha256", this.key).update(ivB64 + valueB64).digest("hex"); - } } /** PHP serialize() for a string: s::""; */