Pure, unit-tested primitives the AtomCMS->Next.js login must reproduce exactly
(verified now with round-trip + known vectors; full end-to-end check deferred
until a real DB + APP_KEY + live emulator are available):
- password.ts: argon2id (m=65536,t=4,p=1 via hash-wasm) + bcrypt ($2y$ accepted)
verify, and the md5->argon2id on-login upgrade gated by convert_passwords
(mirrors RedirectIfTwoFactorAuthenticatable).
- sso-ticket.ts: '{hotel_name without spaces}-{uuidv4}' written to auth_ticket +
ip_current (mirrors User::ssoTicket()).
- laravel-encrypter.ts: AES-256-CBC + HMAC-SHA256 payload compatible with
Laravel encrypt()/encryptString (for existing 2FA secrets) incl. PHP string
(de)serialization.
- totp.ts: otplib Google2FA-compatible TOTP verify (SHA1/6/30).
Libs: hash-wasm + bcryptjs + otplib (pure JS/WASM, no native build). 28 tests.
90 lines
3.4 KiB
TypeScript
90 lines
3.4 KiB
TypeScript
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:<byteLength>:"<value>"; */
|
|
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");
|
|
}
|