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.
70 lines
2.5 KiB
TypeScript
70 lines
2.5 KiB
TypeScript
import { hash as bcryptHash } from "bcryptjs";
|
|
import { describe, expect, it } from "vitest";
|
|
import {
|
|
checkLogin,
|
|
hashPassword,
|
|
isMd5Of,
|
|
md5Hex,
|
|
verifyPassword,
|
|
} from "./password";
|
|
|
|
describe("md5Hex", () => {
|
|
it("matches PHP md5() on canonical vectors", () => {
|
|
expect(md5Hex("")).toBe("d41d8cd98f00b204e9800998ecf8427e");
|
|
expect(md5Hex("abc")).toBe("900150983cd24fb0d6963f7d28e17f72");
|
|
});
|
|
});
|
|
|
|
describe("argon2id", () => {
|
|
it("hashes with the AtomCMS params (m=65536,t=4,p=1) and round-trips", async () => {
|
|
const h = await hashPassword("s3cret!");
|
|
expect(h).toMatch(/^\$argon2id\$v=19\$m=65536,t=4,p=1\$/);
|
|
expect(await verifyPassword("s3cret!", h)).toBe(true);
|
|
expect(await verifyPassword("wrong", h)).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("bcrypt", () => {
|
|
it("verifies a bcrypt hash and accepts the PHP $2y$ prefix", async () => {
|
|
const h = await bcryptHash("hunter2", 10); // bcryptjs emits $2a$
|
|
expect(await verifyPassword("hunter2", h)).toBe(true);
|
|
// PHP stores $2y$ — bcryptjs must accept it as equivalent.
|
|
const phpStyle = h.replace(/^\$2[ab]\$/, "$2y$");
|
|
expect(await verifyPassword("hunter2", phpStyle)).toBe(true);
|
|
expect(await verifyPassword("nope", h)).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("isMd5Of", () => {
|
|
it("detects a legacy md5 password", () => {
|
|
expect(isMd5Of("habbo", md5Hex("habbo"))).toBe(true);
|
|
expect(isMd5Of("habbo", md5Hex("other"))).toBe(false);
|
|
expect(isMd5Of("habbo", "not-a-hash")).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("checkLogin", () => {
|
|
it("upgrades a legacy md5 hash to argon2id when conversion is enabled", async () => {
|
|
const stored = md5Hex("oldpass");
|
|
const res = await checkLogin("oldpass", stored, { convertPasswords: true });
|
|
expect(res.valid).toBe(true);
|
|
expect(res.upgradedHash).toMatch(/^\$argon2id\$/);
|
|
// The upgraded hash verifies the same password.
|
|
expect(await verifyPassword("oldpass", res.upgradedHash as string)).toBe(true);
|
|
});
|
|
|
|
it("does NOT upgrade md5 when conversion is disabled", async () => {
|
|
const stored = md5Hex("oldpass");
|
|
const res = await checkLogin("oldpass", stored, { convertPasswords: false });
|
|
expect(res.valid).toBe(false);
|
|
expect(res.upgradedHash).toBeUndefined();
|
|
});
|
|
|
|
it("validates an existing argon2id hash with no upgrade", async () => {
|
|
const stored = await hashPassword("modern");
|
|
const res = await checkLogin("modern", stored, { convertPasswords: true });
|
|
expect(res.valid).toBe(true);
|
|
expect(res.upgradedHash).toBeUndefined();
|
|
});
|
|
});
|