From 30ed2b8ce2e12cc4ba5fb72213b8e32d8e2fd56c Mon Sep 17 00:00:00 2001 From: openhands Date: Mon, 3 Aug 2026 18:08:57 +0200 Subject: [PATCH] refactor: switch password hashing from argon2 to bcrypt - hashPassword now emits bcrypt (cost 12) instead of argon2id - checkLogin migrates legacy md5/argon2id hashes to bcrypt on sign-in - keep argon2id verification only as a one-time migration path - replace ARGON2_* env vars with BCRYPT_COST --- .env.example | 5 ++-- src/env.ts | 8 +++---- src/lib/auth/password.test.ts | 44 ++++++++++++----------------------- src/lib/auth/password.ts | 38 +++++++++++++++++------------- 4 files changed, 42 insertions(+), 53 deletions(-) diff --git a/.env.example b/.env.example index 68fe9c2315..06ac4b0ee9 100644 --- a/.env.example +++ b/.env.example @@ -32,9 +32,8 @@ NEXT_PUBLIC_IMAGER_URL=http://localhost:3002/imaging AUTH_SECRET=your-super-secret-auth-key-change-this-min-32-chars APP_KEY=base64:your-app-key-here= CONVERT_PASSWORDS=true -ARGON2_MEMORY_KB=65536 -ARGON2_ITERATIONS=4 -ARGON2_PARALLELISM=1 +# CONVERT_PASSWORDS: enables legacy md5/argon2id -> bcrypt upgrade on login. +BCRYPT_COST=12 # --- PATHS --- BADGE_UPLOAD_DIR=./public/assets/images/badges diff --git a/src/env.ts b/src/env.ts index 415bb4d838..c0c3d15f42 100644 --- a/src/env.ts +++ b/src/env.ts @@ -51,15 +51,13 @@ const schema = z APP_KEY: z.string().optional(), // Mirrors Laravel config('habbo.site.convert_passwords') — enables - // legacy md5/bcrypt hashes to be upgraded to argon2id on login. + // legacy md5/argon2id hashes to be upgraded to bcrypt on login. CONVERT_PASSWORDS: z .string() .optional() .transform((v) => v === "true" || v === "1"), - // Argon2id parameters — defaults match the old AtomCMS (Laravel) setup. - ARGON2_MEMORY_KB: z.coerce.number().int().positive().default(65_536), - ARGON2_ITERATIONS: z.coerce.number().int().positive().default(4), - ARGON2_PARALLELISM: z.coerce.number().int().positive().default(1), + // bcrypt cost factor used for new password hashes. + BCRYPT_COST: z.coerce.number().int().min(4).max(31).default(12), // Filesystem dir the badge uploader writes .gif into (the emulator's // badge image folder, e.g. .../assets/c_images/album1584). Upload is disabled // when unset. diff --git a/src/lib/auth/password.test.ts b/src/lib/auth/password.test.ts index 3a41eeacee..f69075e5f6 100644 --- a/src/lib/auth/password.test.ts +++ b/src/lib/auth/password.test.ts @@ -1,9 +1,7 @@ import { describe, expect, it, vi } from "vitest"; const mockEnv = vi.hoisted(() => ({ - ARGON2_MEMORY_KB: 65_536, - ARGON2_ITERATIONS: 4, - ARGON2_PARALLELISM: 1, + BCRYPT_COST: 12, })); vi.mock("@/env", () => ({ @@ -28,16 +26,16 @@ describe("md5Hex", () => { }); describe("hashPassword", () => { - it("emits an argon2id hash and round-trips", async () => { + it("emits a bcrypt hash and round-trips", async () => { const h = await hashPassword("s3cret!"); - expect(h).toMatch(/^\$argon2id\$/); + expect(h).toMatch(/^\$2[aby]\$/); expect(await verifyPassword("s3cret!", h)).toBe(true); expect(await verifyPassword("wrong", h)).toBe(false); }); }); describe("isArgon2idOf", () => { - it("verifies an argon2id hash (AtomCMS/Laravel)", async () => { + 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); @@ -47,7 +45,7 @@ describe("isArgon2idOf", () => { }); describe("isBcryptOf", () => { - it("verifies a legacy bcrypt hash", async () => { + it("verifies a bcrypt hash", async () => { const { bcrypt } = await import("hash-wasm"); const { randomBytes } = await import("node:crypto"); const stored = await bcrypt({ @@ -71,20 +69,20 @@ describe("isMd5Of", () => { }); describe("verifyPassword", () => { - it("verifies argon2id hashes", async () => { + it("verifies bcrypt hashes", async () => { const h = await hashPassword("hunter2"); - expect(h).toMatch(/^\$argon2id\$/); + expect(h).toMatch(/^\$2[aby]\$/); expect(await verifyPassword("hunter2", h)).toBe(true); expect(await verifyPassword("nope", h)).toBe(false); }); }); describe("checkLogin", () => { - it("upgrades a legacy md5 hash to argon2id when conversion is enabled", async () => { + it("upgrades a legacy md5 hash to bcrypt when conversion is enabled", async () => { const stored = await md5Hex("oldpass"); const res = await checkLogin("oldpass", stored, { convertPasswords: true }); expect(res.valid).toBe(true); - expect(res.upgradedHash).toMatch(/^\$argon2id\$/); + expect(res.upgradedHash).toMatch(/^\$2[aby]\$/); expect(await verifyPassword("oldpass", res.upgradedHash as string)).toBe( true, ); @@ -99,37 +97,25 @@ describe("checkLogin", () => { expect(res.upgradedHash).toBeUndefined(); }); - it("accepts an argon2id hash with no rehash", async () => { + 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, }); expect(res.valid).toBe(true); - expect(res.upgradedHash).toBeUndefined(); + expect(res.upgradedHash).toMatch(/^\$2[aby]\$/); }); - it("upgrades a legacy bcrypt hash to argon2id when conversion is enabled", async () => { - // Generate a real bcrypt hash via hash-wasm and verify the upgrade path. + it("accepts an existing bcrypt hash with no rehash", async () => { const { bcrypt } = await import("hash-wasm"); + const { randomBytes } = await import("node:crypto"); const stored = await bcrypt({ - password: "oldbcrypt", - salt: await import("node:crypto").then((c) => c.randomBytes(16)), + password: "modern", + salt: randomBytes(16), costFactor: 10, outputType: "encoded", }); - const res = await checkLogin("oldbcrypt", stored, { - convertPasswords: true, - }); - expect(res.valid).toBe(true); - expect(res.upgradedHash).toMatch(/^\$argon2id\$/); - expect(await verifyPassword("oldbcrypt", res.upgradedHash as string)).toBe( - true, - ); - }); - - it("validates an existing modern hash with no upgrade", async () => { - const stored = await hashPassword("modern"); const res = await checkLogin("modern", stored, { convertPasswords: true }); expect(res.valid).toBe(true); expect(res.upgradedHash).toBeUndefined(); diff --git a/src/lib/auth/password.ts b/src/lib/auth/password.ts index 90de0fc634..b9dd1a0482 100644 --- a/src/lib/auth/password.ts +++ b/src/lib/auth/password.ts @@ -1,16 +1,13 @@ import { randomBytes } from "node:crypto"; -import { argon2id, argon2Verify, bcryptVerify, md5 } from "hash-wasm"; +import { argon2Verify, bcrypt, bcryptVerify, md5 } from "hash-wasm"; import { env } from "@/env"; export async function hashPassword(password: string): Promise { - return await argon2id({ + return await bcrypt({ password, salt: randomBytes(16), - parallelism: env.ARGON2_PARALLELISM, - iterations: env.ARGON2_ITERATIONS, - memorySize: env.ARGON2_MEMORY_KB, - hashLength: 32, + costFactor: env.BCRYPT_COST, outputType: "encoded", }); } @@ -29,6 +26,11 @@ export async function isMd5Of( ); } +/** + * 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( password: string, stored: string, @@ -41,7 +43,6 @@ export async function isArgon2idOf( } } -/** Legacy bcrypt support — only kept to verify & auto-upgrade old accounts. */ export async function isBcryptOf( password: string, stored: string, @@ -58,9 +59,6 @@ export async function verifyPassword( password: string, stored: string, ): Promise { - if (/^\$argon2id\$/.test(stored)) { - return isArgon2idOf(password, stored); - } return isBcryptOf(password, stored); } @@ -74,14 +72,22 @@ export async function checkLogin( stored: string, opts: { convertPasswords: boolean }, ): Promise { + // Legacy argon2id — 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)) { + return { valid: true, upgradedHash: await hashPassword(password) }; + } + return { valid: false }; + } + + if (/^\$2[aby]\$/.test(stored)) { + return { valid: await isBcryptOf(password, stored) }; + } + if (opts.convertPasswords && (await isMd5Of(password, stored))) { return { valid: true, upgradedHash: await hashPassword(password) }; } - if (opts.convertPasswords && (await isArgon2idOf(password, stored))) { - return { valid: true }; - } - if (opts.convertPasswords && (await isBcryptOf(password, stored))) { - return { valid: true, upgradedHash: await hashPassword(password) }; - } + return { valid: await verifyPassword(password, stored) }; }