Files
EpicNext-Cms/src/lib/auth/password.ts
T
openhands 1875a69b83 Fix security scanner findings
- Replace hardcoded test secrets with crypto-generated values in laravel-encrypter.test.ts and totp.test.ts
- Add 'secure' attribute to locale cookie in language-switcher.tsx
- Validate image URLs before rendering in media-grid.tsx and media-picker.tsx (XSS prevention)
- Validate redirect URL is HTTPS before window.location assignment in TopUpForm.tsx (open redirect prevention)
- Document intentional MD5 usage for legacy PHP compatibility in password.ts
- Document HMAC integrity protection for CBC cipher in laravel-encrypter.ts
2026-07-10 23:08:15 +02:00

104 lines
3.7 KiB
TypeScript

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<string> {
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<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) };
}