feat(auth): auto-upgrade every legacy password format to bcrypt on login
checkLogin now verifies and migrates all known password formats without configuration: bcrypt, argon2id/argon2i/argon2d, unsalted md5/sha1/sha256/ sha512, double-md5 (UberCMS/Butterfly), salted md5 with embedded salt (hash:salt, salt:hash, hash$salt), and a guarded plaintext fallback. Every successful legacy login rewrites the stored hash to bcrypt, so the CONVERT_PASSWORDS flag is no longer required (kept for deploy compatibility).
This commit is contained in:
+139
-18
@@ -1,5 +1,13 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { argon2Verify, bcrypt, bcryptVerify, md5 } from "hash-wasm";
|
||||
import {
|
||||
argon2Verify,
|
||||
bcrypt,
|
||||
bcryptVerify,
|
||||
md5,
|
||||
sha1,
|
||||
sha256,
|
||||
sha512,
|
||||
} from "hash-wasm";
|
||||
|
||||
import { env } from "@/env";
|
||||
|
||||
@@ -15,27 +23,104 @@ export async function hashPassword(password: string): Promise<string> {
|
||||
export async function md5Hex(input: string): Promise<string> {
|
||||
return await md5(input);
|
||||
}
|
||||
export async function sha1Hex(input: string): Promise<string> {
|
||||
return await sha1(input);
|
||||
}
|
||||
export async function sha256Hex(input: string): Promise<string> {
|
||||
return await sha256(input);
|
||||
}
|
||||
export async function sha512Hex(input: string): Promise<string> {
|
||||
return await sha512(input);
|
||||
}
|
||||
|
||||
/** Matches an unsalted lowercase/uppercase hex digest of a given length. */
|
||||
async function isHexDigestOf(
|
||||
password: string,
|
||||
stored: string,
|
||||
length: number,
|
||||
hashFn: (input: string) => Promise<string>,
|
||||
): Promise<boolean> {
|
||||
if (!new RegExp(`^[a-f0-9]{${length}}$`, "i").test(stored)) return false;
|
||||
return (await hashFn(password)) === stored.toLowerCase();
|
||||
}
|
||||
|
||||
export async function isMd5Of(
|
||||
password: string,
|
||||
stored: string,
|
||||
): Promise<boolean> {
|
||||
return (
|
||||
/^[a-f0-9]{32}$/i.test(stored) &&
|
||||
(await md5Hex(password)) === stored.toLowerCase()
|
||||
);
|
||||
return isHexDigestOf(password, stored, 32, md5Hex);
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy argon2id verification — kept ONLY so accounts hashed before the
|
||||
* bcrypt switch can still sign in once and be migrated to bcrypt. No new
|
||||
* argon2 hashes are ever produced.
|
||||
*/
|
||||
export async function isArgon2idOf(
|
||||
/** Classic UberCMS/Butterfly scheme: md5(md5(password)). */
|
||||
export async function isDoubleMd5Of(
|
||||
password: string,
|
||||
stored: string,
|
||||
): Promise<boolean> {
|
||||
if (!/^\$argon2id\$/.test(stored)) return false;
|
||||
if (!/^[a-f0-9]{32}$/i.test(stored)) return false;
|
||||
return (await md5Hex(await md5Hex(password))).toLowerCase() === stored.toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy salted md5 where the salt is embedded in the stored value using a
|
||||
* non-hex separator: `<hash>:<salt>`, `<salt>:<hash>`, `<hash>$<salt>`,
|
||||
* `<salt>$<hash>` (also supports `@` and `_`). The digest is verified as both
|
||||
* md5(salt+password) and md5(password+salt) to cover both conventions.
|
||||
*/
|
||||
const SALTED_HASH_DELIMITER_RE = /[:\$@_]/;
|
||||
|
||||
export async function isSaltedMd5Of(
|
||||
password: string,
|
||||
stored: string,
|
||||
): Promise<boolean> {
|
||||
if (!SALTED_HASH_DELIMITER_RE.test(stored)) return false;
|
||||
|
||||
const hash =
|
||||
stored.match(/^([a-f0-9]{32})[:\$@_](.{1,64})$/i)?.[1] ??
|
||||
stored.match(/^(.{1,64})[:\$@_]([a-f0-9]{32})$/i)?.[2];
|
||||
const salt =
|
||||
stored.match(/^([a-f0-9]{32})[:\$@_](.{1,64})$/i)?.[2] ??
|
||||
stored.match(/^(.{1,64})[:\$@_]([a-f0-9]{32})$/i)?.[1];
|
||||
|
||||
if (!hash || !salt || salt.length > 64) return false;
|
||||
|
||||
const hashLower = hash.toLowerCase();
|
||||
for (const candidate of [salt + password, password + salt]) {
|
||||
if ((await md5Hex(candidate)).toLowerCase() === hashLower) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function isSha1Of(
|
||||
password: string,
|
||||
stored: string,
|
||||
): Promise<boolean> {
|
||||
return isHexDigestOf(password, stored, 40, sha1Hex);
|
||||
}
|
||||
|
||||
export async function isSha256Of(
|
||||
password: string,
|
||||
stored: string,
|
||||
): Promise<boolean> {
|
||||
return isHexDigestOf(password, stored, 64, sha256Hex);
|
||||
}
|
||||
|
||||
export async function isSha512Of(
|
||||
password: string,
|
||||
stored: string,
|
||||
): Promise<boolean> {
|
||||
return isHexDigestOf(password, stored, 128, sha512Hex);
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy argon2 verification (argon2id / argon2i / argon2d) — kept ONLY so
|
||||
* accounts hashed before the bcrypt switch can still sign in once and be
|
||||
* migrated to bcrypt. No new argon2 hashes are ever produced.
|
||||
*/
|
||||
export async function isArgon2Of(
|
||||
password: string,
|
||||
stored: string,
|
||||
): Promise<boolean> {
|
||||
if (!/^\$argon2(id|i|d)?\$/.test(stored)) return false;
|
||||
try {
|
||||
return await argon2Verify({ password, hash: stored });
|
||||
} catch {
|
||||
@@ -67,15 +152,28 @@ export interface LoginCheck {
|
||||
upgradedHash?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies a password against a stored hash, supporting every format used by
|
||||
* old and current Habbo retro CMS/emulator stacks:
|
||||
* - bcrypt ($2a/$2b/$2y): direct verification
|
||||
* - argon2id/argon2i/argon2d: verify + auto-upgrade to bcrypt
|
||||
* - md5 / sha1 / sha256 / sha512 (unsalted hex): verify + auto-upgrade
|
||||
* - double md5 (md5(md5(pass))): verify + auto-upgrade
|
||||
* - salted md5 with embedded salt (hash:salt, salt:hash, hash$salt, ...): verify
|
||||
* - plaintext (final fallback): compare + auto-upgrade
|
||||
*
|
||||
* All legacy formats are automatically rewritten to bcrypt on success.
|
||||
*/
|
||||
export async function checkLogin(
|
||||
password: string,
|
||||
stored: string,
|
||||
opts: { convertPasswords: boolean },
|
||||
): Promise<LoginCheck> {
|
||||
// Legacy argon2id — verify so existing users can sign in, then immediately
|
||||
if (!stored) return { valid: false };
|
||||
|
||||
// Legacy argon2 — verify so existing users can sign in, then immediately
|
||||
// rehash to bcrypt so the hash format converges on bcrypt.
|
||||
if (/^\$argon2id\$/.test(stored)) {
|
||||
if (await isArgon2idOf(password, stored)) {
|
||||
if (/^\$argon2/.test(stored)) {
|
||||
if (await isArgon2Of(password, stored)) {
|
||||
return { valid: true, upgradedHash: await hashPassword(password) };
|
||||
}
|
||||
return { valid: false };
|
||||
@@ -85,9 +183,32 @@ export async function checkLogin(
|
||||
return { valid: await isBcryptOf(password, stored) };
|
||||
}
|
||||
|
||||
if (opts.convertPasswords && (await isMd5Of(password, stored))) {
|
||||
// Legacy digest formats (hex) — verify + auto-upgrade to bcrypt.
|
||||
for (const check of [isMd5Of, isSha1Of, isSha256Of, isSha512Of]) {
|
||||
if (await check(password, stored)) {
|
||||
return { valid: true, upgradedHash: await hashPassword(password) };
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy salted / double-digest conventions — verify + auto-upgrade.
|
||||
if (
|
||||
(await isDoubleMd5Of(password, stored)) ||
|
||||
(await isSaltedMd5Of(password, stored))
|
||||
) {
|
||||
return { valid: true, upgradedHash: await hashPassword(password) };
|
||||
}
|
||||
|
||||
return { valid: await verifyPassword(password, stored) };
|
||||
// Final fallback: a raw plaintext password stored by very old CMSes.
|
||||
// Guard against junk/empty values so only a real exact match succeeds.
|
||||
if (stored.length >= 4) {
|
||||
if (
|
||||
typeof password === "string" &&
|
||||
password.length >= 4 &&
|
||||
password === stored
|
||||
) {
|
||||
return { valid: true, upgradedHash: await hashPassword(password) };
|
||||
}
|
||||
}
|
||||
|
||||
return { valid: false };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user