diff --git a/.env.example b/.env.example index 4bb4d671..1a1c51da 100644 --- a/.env.example +++ b/.env.example @@ -17,6 +17,11 @@ AUTH_SECRET= APP_KEY= CONVERT_PASSWORDS=false +# Password hashing for NEW/upgraded passwords: "bcrypt" (default; 60-char $2y$, +# fits a varchar(64) users.password) or "argon2id" (~97 chars, needs a wider +# column). Existing accounts in either format still verify on login. +PASSWORD_HASH=bcrypt + # RCON link to the Arcturus emulator RCON_HOST=127.0.0.1 RCON_PORT=3001 diff --git a/src/lib/auth/password.test.ts b/src/lib/auth/password.test.ts index 62f8a502..24c36e15 100644 --- a/src/lib/auth/password.test.ts +++ b/src/lib/auth/password.test.ts @@ -15,12 +15,36 @@ describe("md5Hex", () => { }); }); -describe("argon2id", () => { +describe("hashPassword (default driver: bcrypt)", () => { + it("emits a PHP-style $2y$ bcrypt hash that fits varchar(64) and round-trips", async () => { + const prev = process.env.PASSWORD_HASH; + delete process.env.PASSWORD_HASH; // exercise the default + try { + const h = await hashPassword("s3cret!"); + expect(h).toMatch(/^\$2y\$/); + expect(h.length).toBeLessThanOrEqual(60); // fits varchar(64) + expect(await verifyPassword("s3cret!", h)).toBe(true); + expect(await verifyPassword("wrong", h)).toBe(false); + } finally { + if (prev === undefined) delete process.env.PASSWORD_HASH; + else process.env.PASSWORD_HASH = prev; + } + }); +}); + +describe("hashPassword (PASSWORD_HASH=argon2id)", () => { it("hashes with the AtomCMS params (m=65536,t=4,p=1) and round-trips", async () => { - const h = await hashPassword("s3cret!"); - expect(h).toMatch(/^\$argon2id\$v=19\$m=65536,t=4,p=1\$/); - expect(await verifyPassword("s3cret!", h)).toBe(true); - expect(await verifyPassword("wrong", h)).toBe(false); + const prev = process.env.PASSWORD_HASH; + process.env.PASSWORD_HASH = "argon2id"; + try { + const h = await hashPassword("s3cret!"); + expect(h).toMatch(/^\$argon2id\$v=19\$m=65536,t=4,p=1\$/); + expect(await verifyPassword("s3cret!", h)).toBe(true); + expect(await verifyPassword("wrong", h)).toBe(false); + } finally { + if (prev === undefined) delete process.env.PASSWORD_HASH; + else process.env.PASSWORD_HASH = prev; + } }); }); @@ -44,11 +68,13 @@ describe("isMd5Of", () => { }); describe("checkLogin", () => { - it("upgrades a legacy md5 hash to argon2id when conversion is enabled", async () => { + it("upgrades a legacy md5 hash to the configured hash when conversion is enabled", async () => { const stored = md5Hex("oldpass"); const res = await checkLogin("oldpass", stored, { convertPasswords: true }); expect(res.valid).toBe(true); - expect(res.upgradedHash).toMatch(/^\$argon2id\$/); + // Default driver is bcrypt — the upgraded hash must fit varchar(64). + expect(res.upgradedHash).toMatch(/^\$2y\$/); + expect((res.upgradedHash as string).length).toBeLessThanOrEqual(60); // The upgraded hash verifies the same password. expect(await verifyPassword("oldpass", res.upgradedHash as string)).toBe(true); }); @@ -60,7 +86,7 @@ describe("checkLogin", () => { expect(res.upgradedHash).toBeUndefined(); }); - it("validates an existing argon2id hash with no upgrade", async () => { + 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); diff --git a/src/lib/auth/password.ts b/src/lib/auth/password.ts index 4176f324..3464cc7f 100644 --- a/src/lib/auth/password.ts +++ b/src/lib/auth/password.ts @@ -1,30 +1,51 @@ import { createHash, randomBytes } from "node:crypto"; -import { compare as bcryptCompare } from "bcryptjs"; +import { compare as bcryptCompare, hash as bcryptHash } from "bcryptjs"; import { argon2id, argon2Verify } from "hash-wasm"; -// AtomCMS hashing (config/hashing.php): default driver argon2id with -// memory=65536 KiB, time=4, threads=1; bcrypt rounds=12 as the legacy fallback. -// The game emulator validates the SAME users.password hash, so these must match. +// AtomCMS hashing (config/hashing.php): argon2id with memory=65536 KiB, time=4, +// threads=1; bcrypt rounds=12 as the legacy fallback. The game emulator +// validates the SAME users.password hash, so these must match. const ARGON2_PARAMS = { parallelism: 1, iterations: 4, memorySize: 65536, // KiB hashLength: 32, } as const; +const BCRYPT_ROUNDS = 12; + +// Which algorithm hashPassword() emits for NEW/upgraded passwords. +// - "bcrypt" (DEFAULT): 60-char $2y$ hash. Fits a varchar(64) users.password +// (the common emulator/AtomCMS column width) and matches existing accounts. +// - "argon2id": ~97-char PHC hash. ONLY usable if users.password is widened +// (e.g. varchar(255)). Opt in with PASSWORD_HASH=argon2id. +// verifyPassword() always accepts BOTH, so logins keep working either way. +function hashDriver(): "bcrypt" | "argon2id" { + return process.env.PASSWORD_HASH?.toLowerCase() === "argon2id" ? "argon2id" : "bcrypt"; +} /** Lowercase hex md5 of a UTF-8 string (matches PHP md5()). */ export function md5Hex(input: string): string { return createHash("md5").update(input, "utf8").digest("hex"); } -/** Produce an argon2id hash in PHC format identical to PHP's PASSWORD_ARGON2ID. */ +/** + * Hash a new password with the configured driver. Defaults to bcrypt ($2y$, + * rounds=12) so the result fits a varchar(64) column; set PASSWORD_HASH=argon2id + * for argon2id (requires a wider column). Both are verifiable by verifyPassword. + */ export async function hashPassword(password: string): Promise { - return argon2id({ - password, - salt: randomBytes(16), - outputType: "encoded", - ...ARGON2_PARAMS, - }); + if (hashDriver() === "argon2id") { + return argon2id({ + password, + salt: randomBytes(16), + outputType: "encoded", + ...ARGON2_PARAMS, + }); + } + // bcryptjs emits $2a$/$2b$; normalise to the PHP-canonical $2y$ the emulator + // and existing AtomCMS rows use. + const h = await bcryptHash(password, BCRYPT_ROUNDS); + return h.replace(/^\$2[ab]\$/, "$2y$"); } /** True when `stored` is exactly the md5 of `password` (legacy AtomCMS accounts). */