refactor: switch password hashing from argon2 to bcrypt
- hashPassword now emits bcrypt (cost 12) instead of argon2id - checkLogin migrates legacy md5/argon2id hashes to bcrypt on sign-in - keep argon2id verification only as a one-time migration path - replace ARGON2_* env vars with BCRYPT_COST
This commit is contained in:
1 parent
6fc14b9b84
commit
30ed2b8ce2
4 files changed
+42
-53
No files matched your search
+2
-3
@@ -32,9 +32,8 @@ NEXT_PUBLIC_IMAGER_URL=http://localhost:3002/imaging
|
|||||||
AUTH_SECRET=your-super-secret-auth-key-change-this-min-32-chars
|
AUTH_SECRET=your-super-secret-auth-key-change-this-min-32-chars
|
||||||
APP_KEY=base64:your-app-key-here=
|
APP_KEY=base64:your-app-key-here=
|
||||||
CONVERT_PASSWORDS=true
|
CONVERT_PASSWORDS=true
|
||||||
ARGON2_MEMORY_KB=65536
|
# CONVERT_PASSWORDS: enables legacy md5/argon2id -> bcrypt upgrade on login.
|
||||||
ARGON2_ITERATIONS=4
|
BCRYPT_COST=12
|
||||||
ARGON2_PARALLELISM=1
|
|
||||||
|
|
||||||
# --- PATHS ---
|
# --- PATHS ---
|
||||||
BADGE_UPLOAD_DIR=./public/assets/images/badges
|
BADGE_UPLOAD_DIR=./public/assets/images/badges
|
||||||
|
|||||||
+3
-5
@@ -51,15 +51,13 @@ const schema = z
|
|||||||
APP_KEY: z.string().optional(),
|
APP_KEY: z.string().optional(),
|
||||||
|
|
||||||
// Mirrors Laravel config('habbo.site.convert_passwords') — enables
|
// Mirrors Laravel config('habbo.site.convert_passwords') — enables
|
||||||
// legacy md5/bcrypt hashes to be upgraded to argon2id on login.
|
// legacy md5/argon2id hashes to be upgraded to bcrypt on login.
|
||||||
CONVERT_PASSWORDS: z
|
CONVERT_PASSWORDS: z
|
||||||
.string()
|
.string()
|
||||||
.optional()
|
.optional()
|
||||||
.transform((v) => v === "true" || v === "1"),
|
.transform((v) => v === "true" || v === "1"),
|
||||||
// Argon2id parameters — defaults match the old AtomCMS (Laravel) setup.
|
// bcrypt cost factor used for new password hashes.
|
||||||
ARGON2_MEMORY_KB: z.coerce.number().int().positive().default(65_536),
|
BCRYPT_COST: z.coerce.number().int().min(4).max(31).default(12),
|
||||||
ARGON2_ITERATIONS: z.coerce.number().int().positive().default(4),
|
|
||||||
ARGON2_PARALLELISM: z.coerce.number().int().positive().default(1),
|
|
||||||
// Filesystem dir the badge uploader writes <code>.gif into (the emulator's
|
// Filesystem dir the badge uploader writes <code>.gif into (the emulator's
|
||||||
// badge image folder, e.g. .../assets/c_images/album1584). Upload is disabled
|
// badge image folder, e.g. .../assets/c_images/album1584). Upload is disabled
|
||||||
// when unset.
|
// when unset.
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
const mockEnv = vi.hoisted(() => ({
|
const mockEnv = vi.hoisted(() => ({
|
||||||
ARGON2_MEMORY_KB: 65_536,
|
BCRYPT_COST: 12,
|
||||||
ARGON2_ITERATIONS: 4,
|
|
||||||
ARGON2_PARALLELISM: 1,
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@/env", () => ({
|
vi.mock("@/env", () => ({
|
||||||
@@ -28,16 +26,16 @@ describe("md5Hex", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("hashPassword", () => {
|
describe("hashPassword", () => {
|
||||||
it("emits an argon2id hash and round-trips", async () => {
|
it("emits a bcrypt hash and round-trips", async () => {
|
||||||
const h = await hashPassword("s3cret!");
|
const h = await hashPassword("s3cret!");
|
||||||
expect(h).toMatch(/^\$argon2id\$/);
|
expect(h).toMatch(/^\$2[aby]\$/);
|
||||||
expect(await verifyPassword("s3cret!", h)).toBe(true);
|
expect(await verifyPassword("s3cret!", h)).toBe(true);
|
||||||
expect(await verifyPassword("wrong", h)).toBe(false);
|
expect(await verifyPassword("wrong", h)).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("isArgon2idOf", () => {
|
describe("isArgon2idOf", () => {
|
||||||
it("verifies an argon2id hash (AtomCMS/Laravel)", async () => {
|
it("verifies a legacy argon2id hash (pre-migration accounts)", async () => {
|
||||||
const stored =
|
const stored =
|
||||||
"$argon2id$v=19$m=1024,t=1,p=1$pTCeoGfX788sH7Z3ju9rJw$4awmR4yciu2L+xDNQJ/NesWX3Kio+fwN8wSCtp4XUp0";
|
"$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("test-password-123", stored)).toBe(true);
|
||||||
@@ -47,7 +45,7 @@ describe("isArgon2idOf", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("isBcryptOf", () => {
|
describe("isBcryptOf", () => {
|
||||||
it("verifies a legacy bcrypt hash", async () => {
|
it("verifies a bcrypt hash", async () => {
|
||||||
const { bcrypt } = await import("hash-wasm");
|
const { bcrypt } = await import("hash-wasm");
|
||||||
const { randomBytes } = await import("node:crypto");
|
const { randomBytes } = await import("node:crypto");
|
||||||
const stored = await bcrypt({
|
const stored = await bcrypt({
|
||||||
@@ -71,20 +69,20 @@ describe("isMd5Of", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("verifyPassword", () => {
|
describe("verifyPassword", () => {
|
||||||
it("verifies argon2id hashes", async () => {
|
it("verifies bcrypt hashes", async () => {
|
||||||
const h = await hashPassword("hunter2");
|
const h = await hashPassword("hunter2");
|
||||||
expect(h).toMatch(/^\$argon2id\$/);
|
expect(h).toMatch(/^\$2[aby]\$/);
|
||||||
expect(await verifyPassword("hunter2", h)).toBe(true);
|
expect(await verifyPassword("hunter2", h)).toBe(true);
|
||||||
expect(await verifyPassword("nope", h)).toBe(false);
|
expect(await verifyPassword("nope", h)).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("checkLogin", () => {
|
describe("checkLogin", () => {
|
||||||
it("upgrades a legacy md5 hash to argon2id when conversion is enabled", async () => {
|
it("upgrades a legacy md5 hash to bcrypt when conversion is enabled", async () => {
|
||||||
const stored = await md5Hex("oldpass");
|
const stored = await 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\$/);
|
expect(res.upgradedHash).toMatch(/^\$2[aby]\$/);
|
||||||
expect(await verifyPassword("oldpass", res.upgradedHash as string)).toBe(
|
expect(await verifyPassword("oldpass", res.upgradedHash as string)).toBe(
|
||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
@@ -99,37 +97,25 @@ describe("checkLogin", () => {
|
|||||||
expect(res.upgradedHash).toBeUndefined();
|
expect(res.upgradedHash).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("accepts an argon2id hash with no rehash", async () => {
|
it("migrates a legacy argon2id hash to bcrypt", async () => {
|
||||||
const stored =
|
const stored =
|
||||||
"$argon2id$v=19$m=1024,t=1,p=1$pTCeoGfX788sH7Z3ju9rJw$4awmR4yciu2L+xDNQJ/NesWX3Kio+fwN8wSCtp4XUp0";
|
"$argon2id$v=19$m=1024,t=1,p=1$pTCeoGfX788sH7Z3ju9rJw$4awmR4yciu2L+xDNQJ/NesWX3Kio+fwN8wSCtp4XUp0";
|
||||||
const res = await checkLogin("test-password-123", stored, {
|
const res = await checkLogin("test-password-123", stored, {
|
||||||
convertPasswords: true,
|
convertPasswords: true,
|
||||||
});
|
});
|
||||||
expect(res.valid).toBe(true);
|
expect(res.valid).toBe(true);
|
||||||
expect(res.upgradedHash).toBeUndefined();
|
expect(res.upgradedHash).toMatch(/^\$2[aby]\$/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("upgrades a legacy bcrypt hash to argon2id when conversion is enabled", async () => {
|
it("accepts an existing bcrypt hash with no rehash", async () => {
|
||||||
// Generate a real bcrypt hash via hash-wasm and verify the upgrade path.
|
|
||||||
const { bcrypt } = await import("hash-wasm");
|
const { bcrypt } = await import("hash-wasm");
|
||||||
|
const { randomBytes } = await import("node:crypto");
|
||||||
const stored = await bcrypt({
|
const stored = await bcrypt({
|
||||||
password: "oldbcrypt",
|
password: "modern",
|
||||||
salt: await import("node:crypto").then((c) => c.randomBytes(16)),
|
salt: randomBytes(16),
|
||||||
costFactor: 10,
|
costFactor: 10,
|
||||||
outputType: "encoded",
|
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 });
|
const res = await checkLogin("modern", stored, { convertPasswords: true });
|
||||||
expect(res.valid).toBe(true);
|
expect(res.valid).toBe(true);
|
||||||
expect(res.upgradedHash).toBeUndefined();
|
expect(res.upgradedHash).toBeUndefined();
|
||||||
|
|||||||
+22
-16
@@ -1,16 +1,13 @@
|
|||||||
import { randomBytes } from "node:crypto";
|
import { randomBytes } from "node:crypto";
|
||||||
import { argon2id, argon2Verify, bcryptVerify, md5 } from "hash-wasm";
|
import { argon2Verify, bcrypt, bcryptVerify, md5 } from "hash-wasm";
|
||||||
|
|
||||||
import { env } from "@/env";
|
import { env } from "@/env";
|
||||||
|
|
||||||
export async function hashPassword(password: string): Promise<string> {
|
export async function hashPassword(password: string): Promise<string> {
|
||||||
return await argon2id({
|
return await bcrypt({
|
||||||
password,
|
password,
|
||||||
salt: randomBytes(16),
|
salt: randomBytes(16),
|
||||||
parallelism: env.ARGON2_PARALLELISM,
|
costFactor: env.BCRYPT_COST,
|
||||||
iterations: env.ARGON2_ITERATIONS,
|
|
||||||
memorySize: env.ARGON2_MEMORY_KB,
|
|
||||||
hashLength: 32,
|
|
||||||
outputType: "encoded",
|
outputType: "encoded",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -29,6 +26,11 @@ export async function isMd5Of(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Legacy argon2id verification — kept ONLY so accounts hashed before the
|
||||||
|
* bcrypt switch can still sign in once and be migrated to bcrypt. No new
|
||||||
|
* argon2 hashes are ever produced.
|
||||||
|
*/
|
||||||
export async function isArgon2idOf(
|
export async function isArgon2idOf(
|
||||||
password: string,
|
password: string,
|
||||||
stored: string,
|
stored: string,
|
||||||
@@ -41,7 +43,6 @@ export async function isArgon2idOf(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Legacy bcrypt support — only kept to verify & auto-upgrade old accounts. */
|
|
||||||
export async function isBcryptOf(
|
export async function isBcryptOf(
|
||||||
password: string,
|
password: string,
|
||||||
stored: string,
|
stored: string,
|
||||||
@@ -58,9 +59,6 @@ export async function verifyPassword(
|
|||||||
password: string,
|
password: string,
|
||||||
stored: string,
|
stored: string,
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
if (/^\$argon2id\$/.test(stored)) {
|
|
||||||
return isArgon2idOf(password, stored);
|
|
||||||
}
|
|
||||||
return isBcryptOf(password, stored);
|
return isBcryptOf(password, stored);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,14 +72,22 @@ export async function checkLogin(
|
|||||||
stored: string,
|
stored: string,
|
||||||
opts: { convertPasswords: boolean },
|
opts: { convertPasswords: boolean },
|
||||||
): Promise<LoginCheck> {
|
): Promise<LoginCheck> {
|
||||||
|
// Legacy argon2id — verify so existing users can sign in, then immediately
|
||||||
|
// rehash to bcrypt so the hash format converges on bcrypt.
|
||||||
|
if (/^\$argon2id\$/.test(stored)) {
|
||||||
|
if (await isArgon2idOf(password, stored)) {
|
||||||
|
return { valid: true, upgradedHash: await hashPassword(password) };
|
||||||
|
}
|
||||||
|
return { valid: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/^\$2[aby]\$/.test(stored)) {
|
||||||
|
return { valid: await isBcryptOf(password, stored) };
|
||||||
|
}
|
||||||
|
|
||||||
if (opts.convertPasswords && (await isMd5Of(password, stored))) {
|
if (opts.convertPasswords && (await isMd5Of(password, stored))) {
|
||||||
return { valid: true, upgradedHash: await hashPassword(password) };
|
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) };
|
return { valid: await verifyPassword(password, stored) };
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user