feat(auth): switch password hashing to argon2id with legacy auto-upgrade
- hashPassword now emits argon2id (same params as the legacy AtomCMS Laravel setup: memory 64MB, iterations 4, parallelism 1) - legacy md5 and bcrypt hashes are verified and auto-upgraded to argon2id on successful login (CONVERT_PASSWORDS=true) - replace BCRYPT_ROUNDS env with ARGON2_MEMORY_KB / ARGON2_ITERATIONS / ARGON2_PARALLELISM - update README and add tests for argon2id and bcrypt upgrade paths
This commit is contained in:
1 parent
d39738eb0d
commit
c601ffbb76
6 files changed
+121
-28
No files matched your search
@@ -1,7 +1,9 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mockEnv = vi.hoisted(() => ({
|
||||
BCRYPT_ROUNDS: 12,
|
||||
ARGON2_MEMORY_KB: 65_536,
|
||||
ARGON2_ITERATIONS: 4,
|
||||
ARGON2_PARALLELISM: 1,
|
||||
}));
|
||||
|
||||
vi.mock("@/env", () => ({
|
||||
@@ -11,6 +13,8 @@ vi.mock("@/env", () => ({
|
||||
import {
|
||||
checkLogin,
|
||||
hashPassword,
|
||||
isArgon2idOf,
|
||||
isBcryptOf,
|
||||
isMd5Of,
|
||||
md5Hex,
|
||||
verifyPassword,
|
||||
@@ -24,20 +28,37 @@ describe("md5Hex", () => {
|
||||
});
|
||||
|
||||
describe("hashPassword", () => {
|
||||
it("emits a bcrypt hash and round-trips", async () => {
|
||||
it("emits an argon2id hash and round-trips", async () => {
|
||||
const h = await hashPassword("s3cret!");
|
||||
expect(h).toMatch(/^\$2y\$\d{2}\$/);
|
||||
expect(h).toMatch(/^\$argon2id\$/);
|
||||
expect(await verifyPassword("s3cret!", h)).toBe(true);
|
||||
expect(await verifyPassword("wrong", h)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("verifyPassword", () => {
|
||||
it("verifies legacy bcrypt hashes ($2y$)", async () => {
|
||||
const h = await hashPassword("hunter2");
|
||||
expect(h).toMatch(/^\$2y\$/);
|
||||
expect(await verifyPassword("hunter2", h)).toBe(true);
|
||||
expect(await verifyPassword("nope", h)).toBe(false);
|
||||
describe("isArgon2idOf", () => {
|
||||
it("verifies an argon2id hash (AtomCMS/Laravel)", async () => {
|
||||
const stored =
|
||||
"$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("wrong", stored)).toBe(false);
|
||||
expect(await isArgon2idOf("anything", "$2y$12$ABC")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isBcryptOf", () => {
|
||||
it("verifies a legacy bcrypt hash", async () => {
|
||||
const { bcrypt } = await import("hash-wasm");
|
||||
const { randomBytes } = await import("node:crypto");
|
||||
const stored = await bcrypt({
|
||||
password: "hunter2",
|
||||
salt: randomBytes(16),
|
||||
costFactor: 10,
|
||||
outputType: "encoded",
|
||||
});
|
||||
expect(await isBcryptOf("hunter2", stored)).toBe(true);
|
||||
expect(await isBcryptOf("wrong", stored)).toBe(false);
|
||||
expect(await isBcryptOf("anything", "$argon2id$v=19$")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -49,12 +70,21 @@ describe("isMd5Of", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("verifyPassword", () => {
|
||||
it("verifies argon2id hashes", async () => {
|
||||
const h = await hashPassword("hunter2");
|
||||
expect(h).toMatch(/^\$argon2id\$/);
|
||||
expect(await verifyPassword("hunter2", h)).toBe(true);
|
||||
expect(await verifyPassword("nope", h)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("checkLogin", () => {
|
||||
it("upgrades a legacy md5 hash to bcrypt when conversion is enabled", async () => {
|
||||
it("upgrades a legacy md5 hash to argon2id when conversion is enabled", async () => {
|
||||
const stored = await md5Hex("oldpass");
|
||||
const res = await checkLogin("oldpass", stored, { convertPasswords: true });
|
||||
expect(res.valid).toBe(true);
|
||||
expect(res.upgradedHash).toMatch(/^\$2y\$/);
|
||||
expect(res.upgradedHash).toMatch(/^\$argon2id\$/);
|
||||
expect(await verifyPassword("oldpass", res.upgradedHash as string)).toBe(
|
||||
true,
|
||||
);
|
||||
@@ -69,6 +99,35 @@ describe("checkLogin", () => {
|
||||
expect(res.upgradedHash).toBeUndefined();
|
||||
});
|
||||
|
||||
it("accepts an argon2id hash with no rehash", async () => {
|
||||
const stored =
|
||||
"$argon2id$v=19$m=1024,t=1,p=1$pTCeoGfX788sH7Z3ju9rJw$4awmR4yciu2L+xDNQJ/NesWX3Kio+fwN8wSCtp4XUp0";
|
||||
const res = await checkLogin("test-password-123", stored, {
|
||||
convertPasswords: true,
|
||||
});
|
||||
expect(res.valid).toBe(true);
|
||||
expect(res.upgradedHash).toBeUndefined();
|
||||
});
|
||||
|
||||
it("upgrades a legacy bcrypt hash to argon2id when conversion is enabled", async () => {
|
||||
// Generate a real bcrypt hash via hash-wasm and verify the upgrade path.
|
||||
const { bcrypt } = await import("hash-wasm");
|
||||
const stored = await bcrypt({
|
||||
password: "oldbcrypt",
|
||||
salt: await import("node:crypto").then((c) => c.randomBytes(16)),
|
||||
costFactor: 10,
|
||||
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 });
|
||||
|
||||
Reference in new issue
Block a user