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:
openhands committed 2026-07-10 23:51:56 +02:00
1 parent 259c0c96ab
commit 818df3697b
3 files changed
+156 -39

No files matched your search

+3 -3
View File
@@ -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", () => {
+16 -36
View File
@@ -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:<byteLength>:"<value>"; */