Add byte-compatible Auth & SSO core primitives

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.
This commit is contained in:
Simo committed 2026-06-27 16:00:25 +02:00
1 parent 5fd40feaa6
commit ec2d46e583
11 files changed
+511

No files matched your search

+55
View File
@@ -0,0 +1,55 @@
import { describe, expect, it } from "vitest";
import {
LaravelEncrypter,
phpSerializeString,
phpUnserializeString,
} from "./laravel-encrypter";
// A deterministic 32-byte key in Laravel's "base64:" form.
const APP_KEY = `base64:${Buffer.from("0123456789abcdef0123456789abcdef").toString("base64")}`;
describe("LaravelEncrypter", () => {
it("rejects a key that is not 32 bytes", () => {
expect(() => new LaravelEncrypter("base64:c2hvcnQ=")).toThrow(/32 bytes/);
});
it("round-trips encrypt/decrypt (serialize=true, like Laravel encrypt())", () => {
const enc = new LaravelEncrypter(APP_KEY);
const secret = "JBSWY3DPEHPK3PXP"; // a TOTP secret
const payload = enc.encrypt(secret);
expect(payload).not.toContain(secret);
expect(enc.decrypt(payload)).toBe(secret);
});
it("round-trips encryptString/decryptString (serialize=false)", () => {
const enc = new LaravelEncrypter(APP_KEY);
const payload = enc.encryptString("hello world");
expect(enc.decryptString(payload)).toBe("hello world");
});
it("fails closed when the MAC 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);
const tampered = Buffer.from(JSON.stringify(json), "utf8").toString("base64");
expect(() => enc.decrypt(tampered)).toThrow(/MAC is invalid/);
});
it("decrypts a payload produced with a fresh instance of the same key", () => {
const payload = new LaravelEncrypter(APP_KEY).encrypt("shared");
expect(new LaravelEncrypter(APP_KEY).decrypt(payload)).toBe("shared");
});
});
describe("php string (de)serialization", () => {
it("serializes by byte length", () => {
expect(phpSerializeString("hello")).toBe('s:5:"hello";');
expect(phpSerializeString("café")).toBe('s:5:"café";'); // é is 2 bytes
});
it("round-trips including multibyte", () => {
expect(phpUnserializeString(phpSerializeString("café"))).toBe("café");
expect(phpUnserializeString('s:5:"hello";')).toBe("hello");
});
});