From e153300da0a2a726d5a2d55696b8bae947b6cbc5 Mon Sep 17 00:00:00 2001 From: openhands Date: Thu, 17 Sep 2026 14:56:31 +0200 Subject: [PATCH] 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). --- .env.example | 5 +- src/env.ts | 4 +- src/lib/auth/login-core.ts | 7 +- src/lib/auth/password.test.ts | 200 ++++++++++++++++++++++++++++++---- src/lib/auth/password.ts | 157 +++++++++++++++++++++++--- 5 files changed, 326 insertions(+), 47 deletions(-) diff --git a/.env.example b/.env.example index b1ebd2bd..ac03dfa6 100644 --- a/.env.example +++ b/.env.example @@ -36,8 +36,9 @@ BADGE_URL=/swf/c_images/album1584 # --- SECURITY & HASHING --- AUTH_SECRET=your-super-secret-auth-key-change-this-min-32-chars APP_KEY=base64:your-app-key-here= -CONVERT_PASSWORDS=true -# CONVERT_PASSWORDS: enables legacy md5/argon2id -> bcrypt upgrade on login. +# Deprecated: legacy md5/argon2id hashes are ALWAYS upgraded to bcrypt on +# login now. Kept only for config compatibility with existing deploys. +# CONVERT_PASSWORDS=true BCRYPT_COST=12 # --- PATHS --- diff --git a/src/env.ts b/src/env.ts index fe3deba8..d2d84a3d 100644 --- a/src/env.ts +++ b/src/env.ts @@ -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() diff --git a/src/lib/auth/login-core.ts b/src/lib/auth/login-core.ts index 41e4c354..760d54e3 100644 --- a/src/lib/auth/login-core.ts +++ b/src/lib/auth/login-core.ts @@ -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 { /** Runs a dummy hash check so missing-user responses stay timing-constant. */ export async function runDummyHashCheck(password: string): Promise { - 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. */ diff --git a/src/lib/auth/password.test.ts b/src/lib/auth/password.test.ts index f69075e5..1732dfd2 100644 --- a/src/lib/auth/password.test.ts +++ b/src/lib/auth/password.test.ts @@ -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(); + }); }); diff --git a/src/lib/auth/password.ts b/src/lib/auth/password.ts index b9dd1a04..72b3ab5f 100644 --- a/src/lib/auth/password.ts +++ b/src/lib/auth/password.ts @@ -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 { export async function md5Hex(input: string): Promise { return await md5(input); } +export async function sha1Hex(input: string): Promise { + return await sha1(input); +} +export async function sha256Hex(input: string): Promise { + return await sha256(input); +} +export async function sha512Hex(input: string): Promise { + 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, +): Promise { + 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 { - 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 { - 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: `:`, `:`, `$`, + * `$` (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 { + 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 { + return isHexDigestOf(password, stored, 40, sha1Hex); +} + +export async function isSha256Of( + password: string, + stored: string, +): Promise { + return isHexDigestOf(password, stored, 64, sha256Hex); +} + +export async function isSha512Of( + password: string, + stored: string, +): Promise { + 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 { + 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 { - // 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 }; }