Default password hashing to bcrypt (fits varchar(64) users.password)
Verified against the live AtomCMS DB: users.password is varchar(64), so argon2id (~97 chars) overflows the column and registration/upgrade fail with 'value too long'. bcrypt (60-char $2y$) fits and matches the existing accounts. hashPassword() now emits bcrypt by default; set PASSWORD_HASH=argon2id to opt back in (needs a widened column). verifyPassword() still accepts both, so existing logins keep working. Verified end-to-end against the live DB: bcrypt $2y$ login round-trips (correct=true, wrong=false). tsc 0, vitest 8/8 (password suite).
This commit is contained in:
1 parent
d5efbba9f6
commit
4eccd146ba
3 files changed
+71
-19
No files matched your search
@@ -17,6 +17,11 @@ AUTH_SECRET=
|
|||||||
APP_KEY=
|
APP_KEY=
|
||||||
CONVERT_PASSWORDS=false
|
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 link to the Arcturus emulator
|
||||||
RCON_HOST=127.0.0.1
|
RCON_HOST=127.0.0.1
|
||||||
RCON_PORT=3001
|
RCON_PORT=3001
|
||||||
|
|||||||
@@ -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 () => {
|
it("hashes with the AtomCMS params (m=65536,t=4,p=1) and round-trips", async () => {
|
||||||
const h = await hashPassword("s3cret!");
|
const prev = process.env.PASSWORD_HASH;
|
||||||
expect(h).toMatch(/^\$argon2id\$v=19\$m=65536,t=4,p=1\$/);
|
process.env.PASSWORD_HASH = "argon2id";
|
||||||
expect(await verifyPassword("s3cret!", h)).toBe(true);
|
try {
|
||||||
expect(await verifyPassword("wrong", h)).toBe(false);
|
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", () => {
|
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 stored = md5Hex("oldpass");
|
||||||
const res = await checkLogin("oldpass", stored, { convertPasswords: true });
|
const res = await checkLogin("oldpass", stored, { convertPasswords: true });
|
||||||
expect(res.valid).toBe(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.
|
// The upgraded hash verifies the same password.
|
||||||
expect(await verifyPassword("oldpass", res.upgradedHash as string)).toBe(true);
|
expect(await verifyPassword("oldpass", res.upgradedHash as string)).toBe(true);
|
||||||
});
|
});
|
||||||
@@ -60,7 +86,7 @@ describe("checkLogin", () => {
|
|||||||
expect(res.upgradedHash).toBeUndefined();
|
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 stored = await hashPassword("modern");
|
||||||
const res = await checkLogin("modern", stored, { convertPasswords: true });
|
const res = await checkLogin("modern", stored, { convertPasswords: true });
|
||||||
expect(res.valid).toBe(true);
|
expect(res.valid).toBe(true);
|
||||||
|
|||||||
+32
-11
@@ -1,30 +1,51 @@
|
|||||||
import { createHash, randomBytes } from "node:crypto";
|
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";
|
import { argon2id, argon2Verify } from "hash-wasm";
|
||||||
|
|
||||||
// AtomCMS hashing (config/hashing.php): default driver argon2id with
|
// AtomCMS hashing (config/hashing.php): argon2id with memory=65536 KiB, time=4,
|
||||||
// memory=65536 KiB, time=4, threads=1; bcrypt rounds=12 as the legacy fallback.
|
// threads=1; bcrypt rounds=12 as the legacy fallback. The game emulator
|
||||||
// The game emulator validates the SAME users.password hash, so these must match.
|
// validates the SAME users.password hash, so these must match.
|
||||||
const ARGON2_PARAMS = {
|
const ARGON2_PARAMS = {
|
||||||
parallelism: 1,
|
parallelism: 1,
|
||||||
iterations: 4,
|
iterations: 4,
|
||||||
memorySize: 65536, // KiB
|
memorySize: 65536, // KiB
|
||||||
hashLength: 32,
|
hashLength: 32,
|
||||||
} as const;
|
} 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()). */
|
/** Lowercase hex md5 of a UTF-8 string (matches PHP md5()). */
|
||||||
export function md5Hex(input: string): string {
|
export function md5Hex(input: string): string {
|
||||||
return createHash("md5").update(input, "utf8").digest("hex");
|
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<string> {
|
export async function hashPassword(password: string): Promise<string> {
|
||||||
return argon2id({
|
if (hashDriver() === "argon2id") {
|
||||||
password,
|
return argon2id({
|
||||||
salt: randomBytes(16),
|
password,
|
||||||
outputType: "encoded",
|
salt: randomBytes(16),
|
||||||
...ARGON2_PARAMS,
|
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). */
|
/** True when `stored` is exactly the md5 of `password` (legacy AtomCMS accounts). */
|
||||||
|
|||||||
Reference in new issue
Block a user