Remove argon2id, use bcrypt-only password hashing

This commit is contained in:
openhands committed 2026-07-29 22:50:17 +02:00
1 parent 7f58e428ed
commit 7cdb785218
8 files changed
+14 -124

No files matched your search

+2 -38
View File
@@ -1,12 +1,6 @@
import { randomBytes } from "node:crypto";
import { argon2id } from "hash-wasm";
import { describe, expect, it, vi } from "vitest";
const mockEnv = vi.hoisted(() => ({
PASSWORD_HASH: undefined as string | undefined,
ARGON2_PARALLELISM: 1,
ARGON2_ITERATIONS: 4,
ARGON2_MEMORY_SIZE: 65536,
BCRYPT_ROUNDS: 12,
}));
@@ -29,9 +23,8 @@ describe("md5Hex", () => {
});
});
describe("hashPassword (default driver: bcrypt)", () => {
describe("hashPassword", () => {
it("emits a bcrypt hash and round-trips", async () => {
mockEnv.PASSWORD_HASH = undefined;
const h = await hashPassword("s3cret!");
expect(h).toMatch(/^\$2y\$\d{2}\$/);
expect(await verifyPassword("s3cret!", h)).toBe(true);
@@ -39,35 +32,8 @@ describe("hashPassword (default driver: bcrypt)", () => {
});
});
describe("hashPassword (PASSWORD_HASH=argon2id)", () => {
it("hashes with the AtomCMS params and round-trips", async () => {
mockEnv.PASSWORD_HASH = "argon2id";
mockEnv.ARGON2_MEMORY_SIZE = 1024;
mockEnv.ARGON2_ITERATIONS = 1;
const h = await hashPassword("s3cret!");
expect(h).toMatch(/^\$argon2id\$v=19\$m=1024,t=1,p=1\$/);
expect(await verifyPassword("s3cret!", h)).toBe(true);
expect(await verifyPassword("wrong", h)).toBe(false);
});
});
describe("verifyPassword", () => {
it("verifies argon2id hashes", async () => {
const h = await argon2id({
password: "hunter2",
salt: randomBytes(16),
outputType: "encoded",
parallelism: 1,
iterations: 4,
memorySize: 1024,
hashLength: 32,
});
expect(await verifyPassword("hunter2", h)).toBe(true);
expect(await verifyPassword("nope", h)).toBe(false);
});
it("verifies legacy bcrypt hashes ($2y$)", async () => {
mockEnv.PASSWORD_HASH = undefined;
const h = await hashPassword("hunter2");
expect(h).toMatch(/^\$2y\$/);
expect(await verifyPassword("hunter2", h)).toBe(true);
@@ -85,7 +51,6 @@ describe("isMd5Of", () => {
describe("checkLogin", () => {
it("upgrades a legacy md5 hash to bcrypt when conversion is enabled", async () => {
mockEnv.PASSWORD_HASH = undefined;
const stored = await md5Hex("oldpass");
const res = await checkLogin("oldpass", stored, { convertPasswords: true });
expect(res.valid).toBe(true);
@@ -105,10 +70,9 @@ describe("checkLogin", () => {
});
it("validates an existing modern hash with no upgrade", async () => {
mockEnv.PASSWORD_HASH = undefined;
const stored = await hashPassword("modern");
const res = await checkLogin("modern", stored, { convertPasswords: true });
expect(res.valid).toBe(true);
expect(res.upgradedHash).toBeUndefined();
});
});
});
+5 -70
View File
@@ -1,7 +1,5 @@
import { randomBytes } from "node:crypto";
import {
argon2id,
argon2Verify,
bcrypt,
bcryptVerify,
md5,
@@ -9,55 +7,7 @@ import {
import { env } from "@/env";
// 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.
function argon2Params() {
return {
parallelism: env.ARGON2_PARALLELISM,
iterations: env.ARGON2_ITERATIONS,
memorySize: env.ARGON2_MEMORY_SIZE,
hashLength: 32,
} as const;
}
// Which algorithm hashPassword() emits for NEW/upgraded passwords.
// - "bcrypt" (DEFAULT): 60-char $2y$ hash. Fits varchar(255) users.password.
// - "argon2id": ~97-char PHC hash. Opt in with PASSWORD_HASH=argon2id.
// verifyPassword() always accepts BOTH, so logins keep working either way.
function hashDriver(): "bcrypt" | "argon2id" {
return env.PASSWORD_HASH === "argon2id" ? "argon2id" : "bcrypt";
}
/**
* Lowercase hex md5 of a UTF-8 string (matches PHP md5()).
*
* This uses hash-wasm's MD5 (not node:crypto) to match PHP's md5() output,
* enabling verification of legacy AtomCMS password hashes during the on-login
* upgrade path (isMd5Of → checkLogin). It is NOT used to hash new passwords
* and does NOT affect credential security.
*/
export async function md5Hex(input: string): Promise<string> {
return await md5(input);
}
/**
* 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> {
if (hashDriver() === "argon2id") {
return argon2id({
password,
salt: randomBytes(16),
outputType: "encoded",
...argon2Params(),
});
}
// hash-wasm bcrypt emits $2a$; normalise to the PHP-canonical $2y$ the
// emulator and existing AtomCMS rows use.
const h = await bcrypt({
password,
salt: randomBytes(16),
@@ -67,7 +17,10 @@ export async function hashPassword(password: string): Promise<string> {
return h.replace(/^\$2[ab]\$/, "$2y$");
}
/** True when `stored` is exactly the md5 of `password` (legacy AtomCMS accounts). */
export async function md5Hex(input: string): Promise<string> {
return await md5(input);
}
export async function isMd5Of(
password: string,
stored: string,
@@ -78,22 +31,10 @@ export async function isMd5Of(
);
}
/**
* Verify a password against a stored hash, auto-detecting the algorithm the way
* Laravel's Hash::check does. Returns false for unknown/legacy formats (md5 is
* handled by the conversion path in checkLogin, not here).
*/
export async function verifyPassword(
password: string,
stored: string,
): Promise<boolean> {
if (stored.startsWith("$argon2")) {
try {
return await argon2Verify({ password, hash: stored });
} catch {
return false;
}
}
if (/^\$2[aby]\$/.test(stored)) {
try {
return await bcryptVerify({ password, hash: stored });
@@ -106,15 +47,9 @@ export async function verifyPassword(
export interface LoginCheck {
valid: boolean;
/** Set when a legacy md5 hash was upgraded — persist it to users.password. */
upgradedHash?: string;
}
/**
* Full AtomCMS credential check including the md5 -> argon2id on-login upgrade
* (gated by `convertPasswords`, i.e. config('habbo.site.convert_passwords')).
* Mirrors RedirectIfTwoFactorAuthenticatable::convertUserPassword + validate.
*/
export async function checkLogin(
password: string,
stored: string,
@@ -124,4 +59,4 @@ export async function checkLogin(
return { valid: true, upgradedHash: await hashPassword(password) };
}
return { valid: await verifyPassword(password, stored) };
}
}