import { createHash, randomBytes } from "node:crypto"; import { compare as bcryptCompare, hash as bcryptHash } from "bcryptjs"; import { argon2id, argon2Verify } from "hash-wasm"; // AtomCMS hashing (config/hashing.php): 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; const BCRYPT_ROUNDS = 12; // Which algorithm hashPassword() emits for NEW/upgraded passwords. // - "bcrypt" (DEFAULT): 60-char $2y$ hash. Fits varchar(255) users.password. // - "argon2id": ~97-char PHC hash. Opt in with PASSWORD_HASH=argon2id. // verifyPassword() always accepts BOTH, so logins keep working either way. function hashDriver(): "bcrypt" | "argon2id" { return process.env.PASSWORD_HASH?.toLowerCase() === "argon2id" ? "argon2id" : "bcrypt"; } /** * Lowercase hex md5 of a UTF-8 string (matches PHP md5()). * * This is deliberately MD5 to match PHP's md5() output so we can verify legacy * AtomCMS password hashes during the on-login upgrade path (isMd5Of → checkLogin). * It is NOT used to hash new passwords and does NOT affect credential security. */ export function md5Hex(input: string): string { return createHash("md5").update(input, "utf8").digest("hex"); } /** * Hash a new password with the configured driver. Defaults to bcrypt ($2y$, * rounds=12) so the result fits a varchar(64) column; set PASSWORD_HASH=argon2id * for argon2id (requires a wider column). Both are verifiable by verifyPassword. */ export async function hashPassword(password: string): Promise { if (hashDriver() === "argon2id") { return argon2id({ password, salt: randomBytes(16), outputType: "encoded", ...ARGON2_PARAMS, }); } // bcryptjs emits $2a$/$2b$; normalise to the PHP-canonical $2y$ the emulator // and existing AtomCMS rows use. const h = await bcryptHash(password, BCRYPT_ROUNDS); return h.replace(/^\$2[ab]\$/, "$2y$"); } /** 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 { 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 { if (opts.convertPasswords && isMd5Of(password, stored)) { return { valid: true, upgradedHash: await hashPassword(password) }; } return { valid: await verifyPassword(password, stored) }; }