feat(auth): auto-upgrade every legacy password format to bcrypt on login
checkLogin now verifies and migrates all known password formats without configuration: bcrypt, argon2id/argon2i/argon2d, unsalted md5/sha1/sha256/ sha512, double-md5 (UberCMS/Butterfly), salted md5 with embedded salt (hash:salt, salt:hash, hash$salt), and a guarded plaintext fallback. Every successful legacy login rewrites the stored hash to bcrypt, so the CONVERT_PASSWORDS flag is no longer required (kept for deploy compatibility).
This commit is contained in:
1 parent
905573e627
commit
e153300da0
5 files changed
+326
-47
No files matched your search
+180
-20
@@ -11,10 +11,18 @@ vi.mock("@/env", () => ({
|
||||
import {
|
||||
checkLogin,
|
||||
hashPassword,
|
||||
isArgon2idOf,
|
||||
isArgon2Of,
|
||||
isBcryptOf,
|
||||
isDoubleMd5Of,
|
||||
isMd5Of,
|
||||
isSaltedMd5Of,
|
||||
isSha1Of,
|
||||
isSha256Of,
|
||||
isSha512Of,
|
||||
md5Hex,
|
||||
sha1Hex,
|
||||
sha256Hex,
|
||||
sha512Hex,
|
||||
verifyPassword,
|
||||
} from "./password";
|
||||
|
||||
@@ -25,6 +33,12 @@ describe("md5Hex", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("sha512Hex", () => {
|
||||
it("matches canonical vectors", async () => {
|
||||
expect(await sha512Hex("abc")).toMatch(/^ddaf35a193617aba/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("hashPassword", () => {
|
||||
it("emits a bcrypt hash and round-trips", async () => {
|
||||
const h = await hashPassword("s3cret!");
|
||||
@@ -34,13 +48,57 @@ describe("hashPassword", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("isArgon2idOf", () => {
|
||||
describe("isArgon2Of", () => {
|
||||
it("verifies a legacy argon2id hash (pre-migration accounts)", 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);
|
||||
expect(await isArgon2Of("test-password-123", stored)).toBe(true);
|
||||
expect(await isArgon2Of("wrong", stored)).toBe(false);
|
||||
expect(await isArgon2Of("anything", "$2y$12$ABC")).toBe(false);
|
||||
});
|
||||
|
||||
it("verifies legacy argon2i hashes via hash-wasm round-trip", async () => {
|
||||
const { argon2i } = await import("hash-wasm");
|
||||
const salt = new Uint8Array(16);
|
||||
const stored = await argon2i({
|
||||
password: "oldpass",
|
||||
salt,
|
||||
parallelism: 1,
|
||||
iterations: 1,
|
||||
memorySize: 1024,
|
||||
hashLength: 32,
|
||||
outputType: "encoded",
|
||||
});
|
||||
expect(stored).toMatch(/^\$argon2i\$/);
|
||||
expect(await isArgon2Of("oldpass", stored)).toBe(true);
|
||||
expect(await isArgon2Of("wrong", stored)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sha digest verifiers", () => {
|
||||
it("rejects non-matching lengths", async () => {
|
||||
expect(await isSha1Of("x", "zzzz")).toBe(false);
|
||||
expect(await isSha256Of("x", "zzzz")).toBe(false);
|
||||
expect(await isSha512Of("x", "zzzz")).toBe(false);
|
||||
});
|
||||
|
||||
it("verifies sha1 hashes (uppercase and lowercase)", async () => {
|
||||
const hex = await sha1Hex("habbo");
|
||||
expect(await isSha1Of("habbo", hex)).toBe(true);
|
||||
expect(await isSha1Of("habbo", hex.toUpperCase())).toBe(true);
|
||||
expect(await isSha1Of("wrong", hex)).toBe(false);
|
||||
});
|
||||
|
||||
it("verifies sha256 hashes", async () => {
|
||||
const hex = await sha256Hex("habbo");
|
||||
expect(await isSha256Of("habbo", hex)).toBe(true);
|
||||
expect(await isSha256Of("wrong", hex)).toBe(false);
|
||||
});
|
||||
|
||||
it("verifies sha512 hashes", async () => {
|
||||
const hex = await sha512Hex("habbo");
|
||||
expect(await isSha512Of("habbo", hex)).toBe(true);
|
||||
expect(await isSha512Of("wrong", hex)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -68,6 +126,48 @@ describe("isMd5Of", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("isDoubleMd5Of", () => {
|
||||
it("detects UberCMS/Butterfly double-md5 passwords", async () => {
|
||||
const stored = await md5Hex(await md5Hex("oldpass"));
|
||||
expect(await isDoubleMd5Of("oldpass", stored)).toBe(true);
|
||||
expect(await isDoubleMd5Of("wrong", stored)).toBe(false);
|
||||
expect(await isDoubleMd5Of("oldpass", "not-a-hash")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isSaltedMd5Of", () => {
|
||||
it("verifies md5(salt+password) with hash:salt layout", async () => {
|
||||
const salt = "pepper123";
|
||||
const stored = `${(await md5Hex(salt + "oldpass"))}:${salt}`;
|
||||
expect(await isSaltedMd5Of("oldpass", stored)).toBe(true);
|
||||
expect(await isSaltedMd5Of("wrong", stored)).toBe(false);
|
||||
});
|
||||
|
||||
it("verifies md5(password+salt) with hash:salt layout", async () => {
|
||||
const salt = "pepper123";
|
||||
const stored = `${(await md5Hex("oldpass" + salt))}:${salt}`;
|
||||
expect(await isSaltedMd5Of("oldpass", stored)).toBe(true);
|
||||
});
|
||||
|
||||
it("verifies the salt:hash layout", async () => {
|
||||
const salt = "abc123";
|
||||
const stored = `${salt}:${await md5Hex(salt + "oldpass")}`;
|
||||
expect(await isSaltedMd5Of("oldpass", stored)).toBe(true);
|
||||
expect(await isSaltedMd5Of("wrong", stored)).toBe(false);
|
||||
});
|
||||
|
||||
it("verifies the hash$salt layout", async () => {
|
||||
const salt = "s0lt_9";
|
||||
const stored = `${await md5Hex("oldpass" + salt)}$s0lt_9`;
|
||||
expect(await isSaltedMd5Of("oldpass", stored)).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects junk that does not match any layout", async () => {
|
||||
expect(await isSaltedMd5Of("oldpass", "z9z9z9")).toBe(false);
|
||||
expect(await isSaltedMd5Of("oldpass", "not-a-hash:xyz")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("verifyPassword", () => {
|
||||
it("verifies bcrypt hashes", async () => {
|
||||
const h = await hashPassword("hunter2");
|
||||
@@ -78,9 +178,9 @@ describe("verifyPassword", () => {
|
||||
});
|
||||
|
||||
describe("checkLogin", () => {
|
||||
it("upgrades a legacy md5 hash to bcrypt when conversion is enabled", async () => {
|
||||
it("upgrades a legacy md5 hash to bcrypt", async () => {
|
||||
const stored = await md5Hex("oldpass");
|
||||
const res = await checkLogin("oldpass", stored, { convertPasswords: true });
|
||||
const res = await checkLogin("oldpass", stored);
|
||||
expect(res.valid).toBe(true);
|
||||
expect(res.upgradedHash).toMatch(/^\$2[aby]\$/);
|
||||
expect(await verifyPassword("oldpass", res.upgradedHash as string)).toBe(
|
||||
@@ -88,25 +188,78 @@ describe("checkLogin", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("does NOT upgrade md5 when conversion is disabled", async () => {
|
||||
const stored = await md5Hex("oldpass");
|
||||
const res = await checkLogin("oldpass", stored, {
|
||||
convertPasswords: false,
|
||||
});
|
||||
expect(res.valid).toBe(false);
|
||||
expect(res.upgradedHash).toBeUndefined();
|
||||
});
|
||||
|
||||
it("migrates a legacy argon2id hash to bcrypt", 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,
|
||||
});
|
||||
const res = await checkLogin("test-password-123", stored);
|
||||
expect(res.valid).toBe(true);
|
||||
expect(res.upgradedHash).toMatch(/^\$2[aby]\$/);
|
||||
});
|
||||
|
||||
for (const [name, hashThePassword] of [
|
||||
["sha1", sha1Hex],
|
||||
["sha256", sha256Hex],
|
||||
["sha512", sha512Hex],
|
||||
] as const) {
|
||||
it(`migrates a legacy ${name} hash to bcrypt`, async () => {
|
||||
const stored = await hashThePassword("oldpass");
|
||||
const res = await checkLogin("oldpass", stored);
|
||||
expect(res.valid).toBe(true);
|
||||
expect(res.upgradedHash).toMatch(/^\$2[aby]\$/);
|
||||
expect(await verifyPassword("oldpass", res.upgradedHash as string)).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
it("migrates a double-md5 hash to bcrypt", async () => {
|
||||
const stored = await md5Hex(await md5Hex("oldpass"));
|
||||
const res = await checkLogin("oldpass", stored);
|
||||
expect(res.valid).toBe(true);
|
||||
expect(res.upgradedHash).toMatch(/^\$2[aby]\$/);
|
||||
});
|
||||
|
||||
it("migrates a salted md5 hash to bcrypt (md5(salt+password))", async () => {
|
||||
const salt = "pepper123";
|
||||
const stored = `${await md5Hex(salt + "oldpass")}:${salt}`;
|
||||
const res = await checkLogin("oldpass", stored);
|
||||
expect(res.valid).toBe(true);
|
||||
expect(res.upgradedHash).toMatch(/^\$2[aby]\$/);
|
||||
expect(await verifyPassword("oldpass", res.upgradedHash as string)).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("migrates a salted md5 hash to bcrypt (md5(password+salt))", async () => {
|
||||
const salt = "abc123";
|
||||
const stored = `${salt}:${await md5Hex("oldpass" + salt)}`;
|
||||
const res = await checkLogin("oldpass", stored);
|
||||
expect(res.valid).toBe(true);
|
||||
expect(res.upgradedHash).toMatch(/^\$2[aby]\$/);
|
||||
});
|
||||
|
||||
it("rejects a wrong password for salted/double hashes", async () => {
|
||||
const salted = `${await md5Hex("pepper123oldpass")}:pepper123`;
|
||||
const doubled = await md5Hex(await md5Hex("oldpass"));
|
||||
expect((await checkLogin("wrongpass", salted)).valid).toBe(false);
|
||||
expect((await checkLogin("wrongpass", doubled)).valid).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts a raw plaintext password and upgrades it to bcrypt", async () => {
|
||||
const res = await checkLogin("hunter44", "hunter44");
|
||||
expect(res.valid).toBe(true);
|
||||
expect(res.upgradedHash).toMatch(/^\$2[aby]\$/);
|
||||
expect(await verifyPassword("hunter44", res.upgradedHash as string)).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("does not treat short/junk stored values as plaintext", async () => {
|
||||
expect((await checkLogin("abc", "abc")).valid).toBe(false);
|
||||
expect((await checkLogin("x", "")).valid).toBe(false);
|
||||
expect((await checkLogin("pass", "not-a-hash-format")).valid).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts an existing bcrypt hash with no rehash", async () => {
|
||||
const { bcrypt } = await import("hash-wasm");
|
||||
const { randomBytes } = await import("node:crypto");
|
||||
@@ -116,8 +269,15 @@ describe("checkLogin", () => {
|
||||
costFactor: 10,
|
||||
outputType: "encoded",
|
||||
});
|
||||
const res = await checkLogin("modern", stored, { convertPasswords: true });
|
||||
const res = await checkLogin("modern", stored);
|
||||
expect(res.valid).toBe(true);
|
||||
expect(res.upgradedHash).toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects a wrong password regardless of format", async () => {
|
||||
const stored = await md5Hex("oldpass");
|
||||
const res = await checkLogin("wrongpass", stored);
|
||||
expect(res.valid).toBe(false);
|
||||
expect(res.upgradedHash).toBeUndefined();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user