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

+78
View File
@@ -0,0 +1,78 @@
import { createHash, randomBytes } from "node:crypto";
import { compare as bcryptCompare } from "bcryptjs";
import { argon2id, argon2Verify } from "hash-wasm";
// AtomCMS hashing (config/hashing.php): default driver argon2id with
// memory=65536 KiB, time=4, threads=1; bcrypt rounds=12 as the legacy fallback.
// The game emulator validates the SAME users.password hash, so these must match.
const ARGON2_PARAMS = {
parallelism: 1,
iterations: 4,
memorySize: 65536, // KiB
hashLength: 32,
} as const;
/** Lowercase hex md5 of a UTF-8 string (matches PHP md5()). */
export function md5Hex(input: string): string {
return createHash("md5").update(input, "utf8").digest("hex");
}
/** Produce an argon2id hash in PHC format identical to PHP's PASSWORD_ARGON2ID. */
export async function hashPassword(password: string): Promise<string> {
return argon2id({
password,
salt: randomBytes(16),
outputType: "encoded",
...ARGON2_PARAMS,
});
}
/** True when `stored` is exactly the md5 of `password` (legacy AtomCMS accounts). */
export function isMd5Of(password: string, stored: string): boolean {
return /^[a-f0-9]{32}$/i.test(stored) && md5Hex(password) === stored.toLowerCase();
}
/**
* Verify a password against a stored hash, auto-detecting the algorithm the way
* Laravel's Hash::check does. Returns false for unknown/legacy formats (md5 is
* handled by the conversion path in checkLogin, not here).
*/
export async function verifyPassword(password: string, stored: string): Promise<boolean> {
if (stored.startsWith("$argon2")) {
try {
return await argon2Verify({ password, hash: stored });
} catch {
return false;
}
}
if (/^\$2[aby]\$/.test(stored)) {
try {
return await bcryptCompare(password, stored);
} catch {
return false;
}
}
return false;
}
export interface LoginCheck {
valid: boolean;
/** Set when a legacy md5 hash was upgraded — persist it to users.password. */
upgradedHash?: string;
}
/**
* Full AtomCMS credential check including the md5 -> argon2id on-login upgrade
* (gated by `convertPasswords`, i.e. config('habbo.site.convert_passwords')).
* Mirrors RedirectIfTwoFactorAuthenticatable::convertUserPassword + validate.
*/
export async function checkLogin(
password: string,
stored: string,
opts: { convertPasswords: boolean },
): Promise<LoginCheck> {
if (opts.convertPasswords && isMd5Of(password, stored)) {
return { valid: true, upgradedHash: await hashPassword(password) };
}
return { valid: await verifyPassword(password, stored) };
}