import { createCipheriv, createDecipheriv, createHmac, randomBytes, timingSafeEqual } 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). * * 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) ), * } ) */ export class LaravelEncrypter { private readonly key: Buffer; /** APP_KEY is "base64:...." (or a raw 32-byte string). */ constructor(appKey: string) { 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 for AES-256-CBC (got ${raw.length})`); } this.key = raw; } encrypt(value: string, serialize = true): string { const iv = randomBytes(16); const data = serialize ? phpSerializeString(value) : value; const cipher = createCipheriv("aes-256-cbc", this.key, iv); const valueB64 = cipher.update(data, "utf8", "base64") + cipher.final("base64"); const ivB64 = iv.toString("base64"); const mac = this.hmac(ivB64, valueB64); const payload = JSON.stringify({ iv: ivB64, value: valueB64, mac }); return Buffer.from(payload, "utf8").toString("base64"); } decrypt(payload: string, serialize = true): string { const json = JSON.parse(Buffer.from(payload, "base64").toString("utf8")) as { iv: string; value: string; mac: 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 decipher = createDecipheriv("aes-256-cbc", this.key, iv); 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); } 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::""; */ export function phpSerializeString(value: string): string { return `s:${Buffer.byteLength(value, "utf8")}:"${value}";`; } /** PHP unserialize() for a serialized string payload. */ export 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; // Slice by BYTE length (PHP counts bytes), then back to a JS string. const bytes = Buffer.from(serialized, "utf8").subarray( Buffer.byteLength(serialized.slice(0, start), "utf8"), ); return bytes.subarray(0, byteLen).toString("utf8"); }