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:
Simo committed 2026-06-28 16:26:33 +02:00
1 parent d5efbba9f6
commit 4eccd146ba
3 files changed
+71 -19

No files matched your search

+32 -11
View File
@@ -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<string> {
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). */