feat(auth): auto-upgrade every legacy password format to bcrypt on login
CI / check (push) Failing after 25s
CI / preflight (push) Skipped
CI / deploy (push) Skipped

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:
openhands committed 2026-09-17 14:56:31 +02:00
1 parent 905573e627
commit e153300da0
5 files changed
+326 -47

No files matched your search

+2 -2
View File
@@ -69,8 +69,8 @@ const schema = z
// Laravel APP_KEY (base64:...) — needed to read existing 2FA secrets.
APP_KEY: z.string().optional(),
// Mirrors Laravel config('habbo.site.convert_passwords') — enables
// legacy md5/argon2id hashes to be upgraded to bcrypt on login.
// Deprecated: legacy md5/argon2id hashes are ALWAYS upgraded to bcrypt
// on login now (no flag required). Kept for config compatibility.
CONVERT_PASSWORDS: z
.string()
.optional()
+2 -5
View File
@@ -1,5 +1,4 @@
import { sql } from "drizzle-orm";
import { env } from "@/env";
import { checkLogin } from "@/lib/auth/password";
import { cachedQuery, invalidateKey } from "@/lib/cached-db";
import { db } from "@/lib/db";
@@ -98,7 +97,7 @@ export async function invalidateLoginCache(username: string): Promise<void> {
/** Runs a dummy hash check so missing-user responses stay timing-constant. */
export async function runDummyHashCheck(password: string): Promise<void> {
await checkLogin(password, DUMMY_BCRYPT_HASH, { convertPasswords: false });
await checkLogin(password, DUMMY_BCRYPT_HASH);
}
/** Verifies the password against the stored hash and reports a possible upgrade. */
@@ -107,9 +106,7 @@ export async function verifyLoginPassword(
password: string,
): Promise<{ valid: boolean; upgradedHash?: string }> {
if (!user.password) return { valid: false };
return checkLogin(password, user.password, {
convertPasswords: env.CONVERT_PASSWORDS,
});
return checkLogin(password, user.password);
}
/** True when email verification is required but this account hasn't verified yet. */
+180 -20
View File
@@ -11,10 +11,18 @@ vi.mock("@/env", () => ({
import {
checkLogin,
hashPassword,
isArgon2idOf,
isArgon2Of,
isBcryptOf,
isDoubleMd5Of,
isMd5Of,
isSaltedMd5Of,
isSha1Of,
isSha256Of,
isSha512Of,
md5Hex,
sha1Hex,
sha256Hex,
sha512Hex,
verifyPassword,
} from "./password";
@@ -25,6 +33,12 @@ describe("md5Hex", () => {
});
});
describe("sha512Hex", () => {
it("matches canonical vectors", async () => {
expect(await sha512Hex("abc")).toMatch(/^ddaf35a193617aba/);
});
});
describe("hashPassword", () => {
it("emits a bcrypt hash and round-trips", async () => {
const h = await hashPassword("s3cret!");
@@ -34,13 +48,57 @@ describe("hashPassword", () => {
});
});
describe("isArgon2idOf", () => {
describe("isArgon2Of", () => {
it("verifies a legacy argon2id hash (pre-migration accounts)", async () => {
const stored =
"$argon2id$v=19$m=1024,t=1,p=1$pTCeoGfX788sH7Z3ju9rJw$4awmR4yciu2L+xDNQJ/NesWX3Kio+fwN8wSCtp4XUp0";
expect(await isArgon2idOf("test-password-123", stored)).toBe(true);
expect(await isArgon2idOf("wrong", stored)).toBe(false);
expect(await isArgon2idOf("anything", "$2y$12$ABC")).toBe(false);
expect(await isArgon2Of("test-password-123", stored)).toBe(true);
expect(await isArgon2Of("wrong", stored)).toBe(false);
expect(await isArgon2Of("anything", "$2y$12$ABC")).toBe(false);
});
it("verifies legacy argon2i hashes via hash-wasm round-trip", async () => {
const { argon2i } = await import("hash-wasm");
const salt = new Uint8Array(16);
const stored = await argon2i({
password: "oldpass",
salt,
parallelism: 1,
iterations: 1,
memorySize: 1024,
hashLength: 32,
outputType: "encoded",
});
expect(stored).toMatch(/^\$argon2i\$/);
expect(await isArgon2Of("oldpass", stored)).toBe(true);
expect(await isArgon2Of("wrong", stored)).toBe(false);
});
});
describe("sha digest verifiers", () => {
it("rejects non-matching lengths", async () => {
expect(await isSha1Of("x", "zzzz")).toBe(false);
expect(await isSha256Of("x", "zzzz")).toBe(false);
expect(await isSha512Of("x", "zzzz")).toBe(false);
});
it("verifies sha1 hashes (uppercase and lowercase)", async () => {
const hex = await sha1Hex("habbo");
expect(await isSha1Of("habbo", hex)).toBe(true);
expect(await isSha1Of("habbo", hex.toUpperCase())).toBe(true);
expect(await isSha1Of("wrong", hex)).toBe(false);
});
it("verifies sha256 hashes", async () => {
const hex = await sha256Hex("habbo");
expect(await isSha256Of("habbo", hex)).toBe(true);
expect(await isSha256Of("wrong", hex)).toBe(false);
});
it("verifies sha512 hashes", async () => {
const hex = await sha512Hex("habbo");
expect(await isSha512Of("habbo", hex)).toBe(true);
expect(await isSha512Of("wrong", hex)).toBe(false);
});
});
@@ -68,6 +126,48 @@ describe("isMd5Of", () => {
});
});
describe("isDoubleMd5Of", () => {
it("detects UberCMS/Butterfly double-md5 passwords", async () => {
const stored = await md5Hex(await md5Hex("oldpass"));
expect(await isDoubleMd5Of("oldpass", stored)).toBe(true);
expect(await isDoubleMd5Of("wrong", stored)).toBe(false);
expect(await isDoubleMd5Of("oldpass", "not-a-hash")).toBe(false);
});
});
describe("isSaltedMd5Of", () => {
it("verifies md5(salt+password) with hash:salt layout", async () => {
const salt = "pepper123";
const stored = `${(await md5Hex(salt + "oldpass"))}:${salt}`;
expect(await isSaltedMd5Of("oldpass", stored)).toBe(true);
expect(await isSaltedMd5Of("wrong", stored)).toBe(false);
});
it("verifies md5(password+salt) with hash:salt layout", async () => {
const salt = "pepper123";
const stored = `${(await md5Hex("oldpass" + salt))}:${salt}`;
expect(await isSaltedMd5Of("oldpass", stored)).toBe(true);
});
it("verifies the salt:hash layout", async () => {
const salt = "abc123";
const stored = `${salt}:${await md5Hex(salt + "oldpass")}`;
expect(await isSaltedMd5Of("oldpass", stored)).toBe(true);
expect(await isSaltedMd5Of("wrong", stored)).toBe(false);
});
it("verifies the hash$salt layout", async () => {
const salt = "s0lt_9";
const stored = `${await md5Hex("oldpass" + salt)}$s0lt_9`;
expect(await isSaltedMd5Of("oldpass", stored)).toBe(true);
});
it("rejects junk that does not match any layout", async () => {
expect(await isSaltedMd5Of("oldpass", "z9z9z9")).toBe(false);
expect(await isSaltedMd5Of("oldpass", "not-a-hash:xyz")).toBe(false);
});
});
describe("verifyPassword", () => {
it("verifies bcrypt hashes", async () => {
const h = await hashPassword("hunter2");
@@ -78,9 +178,9 @@ describe("verifyPassword", () => {
});
describe("checkLogin", () => {
it("upgrades a legacy md5 hash to bcrypt when conversion is enabled", async () => {
it("upgrades a legacy md5 hash to bcrypt", async () => {
const stored = await md5Hex("oldpass");
const res = await checkLogin("oldpass", stored, { convertPasswords: true });
const res = await checkLogin("oldpass", stored);
expect(res.valid).toBe(true);
expect(res.upgradedHash).toMatch(/^\$2[aby]\$/);
expect(await verifyPassword("oldpass", res.upgradedHash as string)).toBe(
@@ -88,25 +188,78 @@ describe("checkLogin", () => {
);
});
it("does NOT upgrade md5 when conversion is disabled", async () => {
const stored = await md5Hex("oldpass");
const res = await checkLogin("oldpass", stored, {
convertPasswords: false,
});
expect(res.valid).toBe(false);
expect(res.upgradedHash).toBeUndefined();
});
it("migrates a legacy argon2id hash to bcrypt", async () => {
const stored =
"$argon2id$v=19$m=1024,t=1,p=1$pTCeoGfX788sH7Z3ju9rJw$4awmR4yciu2L+xDNQJ/NesWX3Kio+fwN8wSCtp4XUp0";
const res = await checkLogin("test-password-123", stored, {
convertPasswords: true,
});
const res = await checkLogin("test-password-123", stored);
expect(res.valid).toBe(true);
expect(res.upgradedHash).toMatch(/^\$2[aby]\$/);
});
for (const [name, hashThePassword] of [
["sha1", sha1Hex],
["sha256", sha256Hex],
["sha512", sha512Hex],
] as const) {
it(`migrates a legacy ${name} hash to bcrypt`, async () => {
const stored = await hashThePassword("oldpass");
const res = await checkLogin("oldpass", stored);
expect(res.valid).toBe(true);
expect(res.upgradedHash).toMatch(/^\$2[aby]\$/);
expect(await verifyPassword("oldpass", res.upgradedHash as string)).toBe(
true,
);
});
}
it("migrates a double-md5 hash to bcrypt", async () => {
const stored = await md5Hex(await md5Hex("oldpass"));
const res = await checkLogin("oldpass", stored);
expect(res.valid).toBe(true);
expect(res.upgradedHash).toMatch(/^\$2[aby]\$/);
});
it("migrates a salted md5 hash to bcrypt (md5(salt+password))", async () => {
const salt = "pepper123";
const stored = `${await md5Hex(salt + "oldpass")}:${salt}`;
const res = await checkLogin("oldpass", stored);
expect(res.valid).toBe(true);
expect(res.upgradedHash).toMatch(/^\$2[aby]\$/);
expect(await verifyPassword("oldpass", res.upgradedHash as string)).toBe(
true,
);
});
it("migrates a salted md5 hash to bcrypt (md5(password+salt))", async () => {
const salt = "abc123";
const stored = `${salt}:${await md5Hex("oldpass" + salt)}`;
const res = await checkLogin("oldpass", stored);
expect(res.valid).toBe(true);
expect(res.upgradedHash).toMatch(/^\$2[aby]\$/);
});
it("rejects a wrong password for salted/double hashes", async () => {
const salted = `${await md5Hex("pepper123oldpass")}:pepper123`;
const doubled = await md5Hex(await md5Hex("oldpass"));
expect((await checkLogin("wrongpass", salted)).valid).toBe(false);
expect((await checkLogin("wrongpass", doubled)).valid).toBe(false);
});
it("accepts a raw plaintext password and upgrades it to bcrypt", async () => {
const res = await checkLogin("hunter44", "hunter44");
expect(res.valid).toBe(true);
expect(res.upgradedHash).toMatch(/^\$2[aby]\$/);
expect(await verifyPassword("hunter44", res.upgradedHash as string)).toBe(
true,
);
});
it("does not treat short/junk stored values as plaintext", async () => {
expect((await checkLogin("abc", "abc")).valid).toBe(false);
expect((await checkLogin("x", "")).valid).toBe(false);
expect((await checkLogin("pass", "not-a-hash-format")).valid).toBe(false);
});
it("accepts an existing bcrypt hash with no rehash", async () => {
const { bcrypt } = await import("hash-wasm");
const { randomBytes } = await import("node:crypto");
@@ -116,8 +269,15 @@ describe("checkLogin", () => {
costFactor: 10,
outputType: "encoded",
});
const res = await checkLogin("modern", stored, { convertPasswords: true });
const res = await checkLogin("modern", stored);
expect(res.valid).toBe(true);
expect(res.upgradedHash).toBeUndefined();
});
it("rejects a wrong password regardless of format", async () => {
const stored = await md5Hex("oldpass");
const res = await checkLogin("wrongpass", stored);
expect(res.valid).toBe(false);
expect(res.upgradedHash).toBeUndefined();
});
});
+139 -18
View File
@@ -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 };
}