Migrate from AES-256-CBC to AES-256-GCM for authenticated encryption
- Replace CBC+HMAC with GCM (built-in authentication via authTag) - Remove createHmac and timingSafeEqual imports (no longer needed) - Remove Snyk-ignore comments (no longer suppressible findings) - Update test: tampered MAC test -> tampered auth tag test - Add one-time migration script for existing CBC-encrypted 2FA secrets
This commit is contained in:
1 parent
259c0c96ab
commit
818df3697b
3 files changed
+156
-39
No files matched your search
@@ -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");
|
||||||
|
}
|
||||||
@@ -28,13 +28,13 @@ describe("LaravelEncrypter", () => {
|
|||||||
expect(enc.decryptString(payload)).toBe("hello world");
|
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 enc = new LaravelEncrypter(APP_KEY);
|
||||||
const payload = enc.encrypt("x");
|
const payload = enc.encrypt("x");
|
||||||
const json = JSON.parse(Buffer.from(payload, "base64").toString("utf8"));
|
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");
|
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", () => {
|
it("decrypts a payload produced with a fresh instance of the same key", () => {
|
||||||
|
|||||||
@@ -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
|
* Encrypter using AES-256-GCM.
|
||||||
* 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).
|
|
||||||
*
|
*
|
||||||
* Payload format (what Laravel writes): base64( JSON {
|
* Payload format: base64( JSON {
|
||||||
* iv: base64(16-byte IV),
|
* iv: base64(12-byte IV),
|
||||||
* value: base64(AES-256-CBC ciphertext, itself base64 in the json),
|
* value: base64(AES-256-GCM ciphertext, itself base64 in the json),
|
||||||
* mac: hex( HMAC-SHA256(ivB64 . valueB64, key) ),
|
* tag: base64(16-byte authentication tag),
|
||||||
* } )
|
* } )
|
||||||
*/
|
*/
|
||||||
export class LaravelEncrypter {
|
export class LaravelEncrypter {
|
||||||
@@ -22,24 +18,20 @@ export class LaravelEncrypter {
|
|||||||
? Buffer.from(appKey.slice("base64:".length), "base64")
|
? Buffer.from(appKey.slice("base64:".length), "base64")
|
||||||
: Buffer.from(appKey, "utf8");
|
: Buffer.from(appKey, "utf8");
|
||||||
if (raw.length !== 32) {
|
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;
|
this.key = raw;
|
||||||
}
|
}
|
||||||
|
|
||||||
encrypt(value: string, serialize = true): string {
|
encrypt(value: string, serialize = true): string {
|
||||||
const iv = randomBytes(16);
|
const iv = randomBytes(12);
|
||||||
const data = serialize ? phpSerializeString(value) : value;
|
const data = serialize ? phpSerializeString(value) : value;
|
||||||
// snyk:ignore:javascript/CipherWithNoIntegrity
|
const cipher = createCipheriv("aes-256-gcm", this.key, iv);
|
||||||
// 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 valueB64 = cipher.update(data, "utf8", "base64") + cipher.final("base64");
|
const valueB64 = cipher.update(data, "utf8", "base64") + cipher.final("base64");
|
||||||
|
const tag = cipher.getAuthTag();
|
||||||
const ivB64 = iv.toString("base64");
|
const ivB64 = iv.toString("base64");
|
||||||
const mac = this.hmac(ivB64, valueB64);
|
const tagB64 = tag.toString("base64");
|
||||||
const payload = JSON.stringify({ iv: ivB64, value: valueB64, mac });
|
const payload = JSON.stringify({ iv: ivB64, value: valueB64, tag: tagB64 });
|
||||||
return Buffer.from(payload, "utf8").toString("base64");
|
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 {
|
const json = JSON.parse(Buffer.from(payload, "base64").toString("utf8")) as {
|
||||||
iv: string;
|
iv: string;
|
||||||
value: 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");
|
const iv = Buffer.from(json.iv, "base64");
|
||||||
// snyk:ignore:javascript/CipherWithNoIntegrity
|
const tag = Buffer.from(json.tag, "base64");
|
||||||
// AES-256-CBC required for Laravel compatibility; MAC already verified
|
const decipher = createDecipheriv("aes-256-gcm", this.key, iv);
|
||||||
// above so padding-oracle / tampering is not a risk.
|
decipher.setAuthTag(tag);
|
||||||
const decipher = createDecipheriv("aes-256-cbc", this.key, iv);
|
|
||||||
const plain = decipher.update(json.value, "base64", "utf8") + decipher.final("utf8");
|
const plain = decipher.update(json.value, "base64", "utf8") + decipher.final("utf8");
|
||||||
return serialize ? phpUnserializeString(plain) : plain;
|
return serialize ? phpUnserializeString(plain) : plain;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Laravel's encryptString/decryptString use serialize = false. */
|
|
||||||
encryptString(value: string): string {
|
encryptString(value: string): string {
|
||||||
return this.encrypt(value, false);
|
return this.encrypt(value, false);
|
||||||
}
|
}
|
||||||
@@ -72,10 +56,6 @@ export class LaravelEncrypter {
|
|||||||
decryptString(payload: string): string {
|
decryptString(payload: string): string {
|
||||||
return this.decrypt(payload, false);
|
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:<byteLength>:"<value>"; */
|
/** PHP serialize() for a string: s:<byteLength>:"<value>"; */
|
||||||
|
|||||||
Reference in new issue
Block a user