diff --git a/.env.example b/.env.example index db35824d..cf0224cb 100644 --- a/.env.example +++ b/.env.example @@ -32,7 +32,9 @@ 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 -BCRYPT_ROUNDS=12 +ARGON2_MEMORY_KB=65536 +ARGON2_ITERATIONS=4 +ARGON2_PARALLELISM=1 # --- PATHS --- BADGE_UPLOAD_DIR=./public/assets/images/badges diff --git a/README.md b/README.md index 0b584f56..edd8ec49 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ A modern, high-performance content management system for Habbo hotel emulators, built on **Next.js 16** (App Router) with **Drizzle ORM** and **React 19**. Designed to integrate seamlessly with Polaris / Arcturus Morningstar MySQL/MariaDB databases. -Features a premium animated homepage (typewriter hero, floating orbs, scroll counters), a full admin panel, NextAuth authentication (bcrypt with MD5-to-bcrypt upgrade), real-time RCON communication, Server-Sent Events for live radio data, smooth page transitions, and PM2 production deployment. +Features a premium animated homepage (typewriter hero, floating orbs, scroll counters), a full admin panel, NextAuth authentication (argon2id hashing with legacy md5/bcrypt auto-upgrade), real-time RCON communication, Server-Sent Events for live radio data, smooth page transitions, and PM2 production deployment. --- diff --git a/src/env.ts b/src/env.ts index 112c83f6..58f1a4d9 100644 --- a/src/env.ts +++ b/src/env.ts @@ -50,13 +50,16 @@ 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 md5->bcrypt upgrade. + // Mirrors Laravel config('habbo.site.convert_passwords') — enables + // legacy md5/bcrypt hashes to be upgraded to argon2id on login. CONVERT_PASSWORDS: z .string() .optional() .transform((v) => v === "true" || v === "1"), - // Bcrypt cost factor (rounds). - BCRYPT_ROUNDS: z.coerce.number().int().positive().default(12), + // 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), // 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.ts b/src/lib/auth.ts index 74a82713..f7fc040b 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -176,7 +176,7 @@ export const { handlers, signOut, auth } = NextAuth({ return null; } - // Byte-compatible AtomCMS check (bcrypt + md5->bcrypt upgrade). + // Byte-compatible AtomCMS check (argon2id + legacy md5/bcrypt upgrade). if (!user.password) return null; const res = await checkLogin(password, user.password, { convertPasswords: env.CONVERT_PASSWORDS, diff --git a/src/lib/auth/password.test.ts b/src/lib/auth/password.test.ts index 707a94e0..3a41eeac 100644 --- a/src/lib/auth/password.test.ts +++ b/src/lib/auth/password.test.ts @@ -1,7 +1,9 @@ import { describe, expect, it, vi } from "vitest"; const mockEnv = vi.hoisted(() => ({ - BCRYPT_ROUNDS: 12, + ARGON2_MEMORY_KB: 65_536, + ARGON2_ITERATIONS: 4, + ARGON2_PARALLELISM: 1, })); vi.mock("@/env", () => ({ @@ -11,6 +13,8 @@ vi.mock("@/env", () => ({ import { checkLogin, hashPassword, + isArgon2idOf, + isBcryptOf, isMd5Of, md5Hex, verifyPassword, @@ -24,20 +28,37 @@ describe("md5Hex", () => { }); describe("hashPassword", () => { - it("emits a bcrypt hash and round-trips", async () => { + it("emits an argon2id hash and round-trips", async () => { const h = await hashPassword("s3cret!"); - expect(h).toMatch(/^\$2y\$\d{2}\$/); + expect(h).toMatch(/^\$argon2id\$/); expect(await verifyPassword("s3cret!", h)).toBe(true); expect(await verifyPassword("wrong", h)).toBe(false); }); }); -describe("verifyPassword", () => { - it("verifies legacy bcrypt hashes ($2y$)", async () => { - const h = await hashPassword("hunter2"); - expect(h).toMatch(/^\$2y\$/); - expect(await verifyPassword("hunter2", h)).toBe(true); - expect(await verifyPassword("nope", h)).toBe(false); +describe("isArgon2idOf", () => { + it("verifies an argon2id hash (AtomCMS/Laravel)", 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); + }); +}); + +describe("isBcryptOf", () => { + it("verifies a legacy bcrypt hash", async () => { + const { bcrypt } = await import("hash-wasm"); + const { randomBytes } = await import("node:crypto"); + const stored = await bcrypt({ + password: "hunter2", + salt: randomBytes(16), + costFactor: 10, + outputType: "encoded", + }); + expect(await isBcryptOf("hunter2", stored)).toBe(true); + expect(await isBcryptOf("wrong", stored)).toBe(false); + expect(await isBcryptOf("anything", "$argon2id$v=19$")).toBe(false); }); }); @@ -49,12 +70,21 @@ describe("isMd5Of", () => { }); }); +describe("verifyPassword", () => { + it("verifies argon2id hashes", async () => { + const h = await hashPassword("hunter2"); + expect(h).toMatch(/^\$argon2id\$/); + expect(await verifyPassword("hunter2", h)).toBe(true); + expect(await verifyPassword("nope", h)).toBe(false); + }); +}); + describe("checkLogin", () => { - it("upgrades a legacy md5 hash to bcrypt when conversion is enabled", async () => { + it("upgrades a legacy md5 hash to argon2id 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(/^\$2y\$/); + expect(res.upgradedHash).toMatch(/^\$argon2id\$/); expect(await verifyPassword("oldpass", res.upgradedHash as string)).toBe( true, ); @@ -69,6 +99,35 @@ describe("checkLogin", () => { expect(res.upgradedHash).toBeUndefined(); }); + it("accepts an argon2id hash with no rehash", 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(); + }); + + 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. + const { bcrypt } = await import("hash-wasm"); + const stored = await bcrypt({ + password: "oldbcrypt", + salt: await import("node:crypto").then((c) => c.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 }); diff --git a/src/lib/auth/password.ts b/src/lib/auth/password.ts index 9d271882..90de0fc6 100644 --- a/src/lib/auth/password.ts +++ b/src/lib/auth/password.ts @@ -1,16 +1,18 @@ import { randomBytes } from "node:crypto"; -import { bcrypt, bcryptVerify, md5 } from "hash-wasm"; +import { argon2id, argon2Verify, bcryptVerify, md5 } from "hash-wasm"; import { env } from "@/env"; export async function hashPassword(password: string): Promise { - const h = await bcrypt({ + return await argon2id({ password, salt: randomBytes(16), - costFactor: env.BCRYPT_ROUNDS, + parallelism: env.ARGON2_PARALLELISM, + iterations: env.ARGON2_ITERATIONS, + memorySize: env.ARGON2_MEMORY_KB, + hashLength: 32, outputType: "encoded", }); - return h.replace(/^\$2[ab]\$/, "$2y$"); } export async function md5Hex(input: string): Promise { @@ -27,18 +29,39 @@ export async function isMd5Of( ); } +export async function isArgon2idOf( + password: string, + stored: string, +): Promise { + if (!/^\$argon2id\$/.test(stored)) return false; + try { + return await argon2Verify({ password, hash: stored }); + } catch { + return false; + } +} + +/** Legacy bcrypt support — only kept to verify & auto-upgrade old accounts. */ +export async function isBcryptOf( + password: string, + stored: string, +): Promise { + if (!/^\$2[aby]\$/.test(stored)) return false; + try { + return await bcryptVerify({ password, hash: stored }); + } catch { + return false; + } +} + export async function verifyPassword( password: string, stored: string, ): Promise { - if (/^\$2[aby]\$/.test(stored)) { - try { - return await bcryptVerify({ password, hash: stored }); - } catch { - return false; - } + if (/^\$argon2id\$/.test(stored)) { + return isArgon2idOf(password, stored); } - return false; + return isBcryptOf(password, stored); } export interface LoginCheck { @@ -54,5 +77,11 @@ export async function checkLogin( 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) }; }