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:
1 parent
5fd40feaa6
commit
ec2d46e583
11 files changed
+511
No files matched your search
@@ -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) };
|
||||
}
|
||||
Reference in new issue
Block a user