diff --git a/src/lib/auth/password.test.ts b/src/lib/auth/password.test.ts index 5f2b0ea1..9a50b138 100644 --- a/src/lib/auth/password.test.ts +++ b/src/lib/auth/password.test.ts @@ -13,9 +13,9 @@ import { hashPassword, isArgon2Of, isBcryptOf, - isDoubleMd5Of, + isCombinedDigestOf, isMd5Of, - isSaltedMd5Of, + isSaltedDigestOf, isSha1Of, isSha256Of, isSha512Of, @@ -126,45 +126,85 @@ describe("isMd5Of", () => { }); }); -describe("isDoubleMd5Of", () => { +describe("isCombinedDigestOf", () => { 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); + expect(await isCombinedDigestOf("oldpass", stored)).toBe(true); + expect(await isCombinedDigestOf("wrong", stored)).toBe(false); + expect(await isCombinedDigestOf("oldpass", "not-a-hash")).toBe(false); + }); + + it("detects md5(sha1(pass)) and sha1(md5(pass)) combos", async () => { + const a = await md5Hex(await sha1Hex("oldpass")); + expect(await isCombinedDigestOf("oldpass", a)).toBe(true); + expect(await isCombinedDigestOf("wrong", a)).toBe(false); + + const b = await sha1Hex(await md5Hex("oldpass")); + expect(await isCombinedDigestOf("oldpass", b)).toBe(true); + expect(await isCombinedDigestOf("wrong", b)).toBe(false); + }); + + it("detects double sha1 / double sha256 / double sha512", async () => { + expect( + await isCombinedDigestOf( + "oldpass", + await sha1Hex(await sha1Hex("oldpass")), + ), + ).toBe(true); + expect( + await isCombinedDigestOf( + "oldpass", + await sha256Hex(await sha256Hex("oldpass")), + ), + ).toBe(true); + expect( + await isCombinedDigestOf( + "oldpass", + await sha512Hex(await sha512Hex("oldpass")), + ), + ).toBe(true); }); }); -describe("isSaltedMd5Of", () => { +describe("isSaltedDigestOf", () => { 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); + expect(await isSaltedDigestOf("oldpass", stored)).toBe(true); + expect(await isSaltedDigestOf("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); + expect(await isSaltedDigestOf("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); + expect(await isSaltedDigestOf("oldpass", stored)).toBe(true); + expect(await isSaltedDigestOf("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); + expect(await isSaltedDigestOf("oldpass", stored)).toBe(true); + }); + + it("verifies salted sha1 and sha256 digests", async () => { + const salt = "pepper123"; + const sha1Stored = `${await sha1Hex(salt + "oldpass")}:${salt}`; + expect(await isSaltedDigestOf("oldpass", sha1Stored)).toBe(true); + + const sha256Stored = `${await sha256Hex("oldpass" + salt)}:${salt}`; + expect(await isSaltedDigestOf("oldpass", sha256Stored)).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); + expect(await isSaltedDigestOf("oldpass", "z9z9z9")).toBe(false); + expect(await isSaltedDigestOf("oldpass", "not-a-hash:xyz")).toBe(false); }); }); @@ -212,13 +252,20 @@ describe("checkLogin", () => { }); } - it("migrates a double-md5 hash to bcrypt", async () => { + it("migrates a combined digest hash to bcrypt (md5(md5(pass)))", 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 combined digest hash to bcrypt (sha1(md5(pass)))", async () => { + const stored = await sha1Hex(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}`; @@ -230,19 +277,19 @@ describe("checkLogin", () => { ); }); - it("migrates a salted md5 hash to bcrypt (md5(password+salt))", async () => { + it("migrates a salted sha256 hash to bcrypt (sha256(salt+password))", async () => { const salt = "abc123"; - const stored = `${salt}:${await md5Hex("oldpass" + salt)}`; + const stored = `${salt}:${await sha256Hex(salt + "oldpass")}`; 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 () => { + it("rejects a wrong password for salted/combined hashes", async () => { const salted = `${await md5Hex("pepper123oldpass")}:pepper123`; - const doubled = await md5Hex(await md5Hex("oldpass")); + const combined = await sha1Hex(await md5Hex("oldpass")); expect((await checkLogin("wrongpass", salted)).valid).toBe(false); - expect((await checkLogin("wrongpass", doubled)).valid).toBe(false); + expect((await checkLogin("wrongpass", combined)).valid).toBe(false); }); it("accepts a raw plaintext password and upgrades it to bcrypt", async () => { diff --git a/src/lib/auth/password.ts b/src/lib/auth/password.ts index 0cbba748..79c87745 100644 --- a/src/lib/auth/password.ts +++ b/src/lib/auth/password.ts @@ -51,48 +51,6 @@ export async function isMd5Of( return isHexDigestOf(password, stored, 32, md5Hex); } -/** Classic UberCMS/Butterfly scheme: md5(md5(password)). */ -export async function isDoubleMd5Of( - password: string, - stored: string, -): Promise { - if (!/^[a-f0-9]{32}$/i.test(stored)) return false; - return ( - (await md5Hex(await md5Hex(password))).toLowerCase() === - stored.toLowerCase() - ); -} - -/** - * Legacy salted md5 where the salt is embedded in the stored value using a - * non-hex separator: `:`, `:`, `$`, - * `$` (also supports `@` and `_`). The digest is verified as both - * md5(salt+password) and md5(password+salt) to cover both conventions. - */ -const SALTED_HASH_DELIMITER_RE = /[:\$@_]/; - -export async function isSaltedMd5Of( - password: string, - stored: string, -): Promise { - if (!SALTED_HASH_DELIMITER_RE.test(stored)) return false; - - const hash = - stored.match(/^([a-f0-9]{32})[:\$@_](.{1,64})$/i)?.[1] ?? - stored.match(/^(.{1,64})[:\$@_]([a-f0-9]{32})$/i)?.[2]; - const salt = - stored.match(/^([a-f0-9]{32})[:\$@_](.{1,64})$/i)?.[2] ?? - stored.match(/^(.{1,64})[:\$@_]([a-f0-9]{32})$/i)?.[1]; - - if (!hash || !salt || salt.length > 64) return false; - - const hashLower = hash.toLowerCase(); - for (const candidate of [salt + password, password + salt]) { - if ((await md5Hex(candidate)).toLowerCase() === hashLower) return true; - } - return false; -} - export async function isSha1Of( password: string, stored: string, @@ -114,6 +72,86 @@ export async function isSha512Of( return isHexDigestOf(password, stored, 128, sha512Hex); } +const DIGEST_HEX_LENGTHS: ReadonlyArray<{ + length: number; + hash: (input: string) => Promise; +}> = [ + { length: 32, hash: md5Hex }, + { length: 40, hash: sha1Hex }, + { length: 64, hash: sha256Hex }, + { length: 128, hash: sha512Hex }, +]; + +/** + * Combined/double digest conventions used by legacy CMSes and forums, e.g. + * md5(md5(pass)) (UberCMS/Butterfly), md5(sha1(pass)), sha1(md5(pass)), + * double sha1 / double sha256, and cross md5/sha512 combinations. + */ +const COMBINED_DIGEST_SCHEMES: ReadonlyArray<{ + inner: (input: string) => Promise; + outer: (input: string) => Promise; + length: number; +}> = [ + { outer: md5Hex, inner: md5Hex, length: 32 }, + { outer: sha1Hex, inner: md5Hex, length: 40 }, + { outer: md5Hex, inner: sha1Hex, length: 32 }, + { outer: sha1Hex, inner: sha1Hex, length: 40 }, + { outer: sha256Hex, inner: md5Hex, length: 64 }, + { outer: md5Hex, inner: sha256Hex, length: 32 }, + { outer: sha256Hex, inner: sha1Hex, length: 64 }, + { outer: sha1Hex, inner: sha256Hex, length: 40 }, + { outer: sha256Hex, inner: sha256Hex, length: 64 }, + { outer: sha512Hex, inner: md5Hex, length: 128 }, + { outer: md5Hex, inner: sha512Hex, length: 32 }, + { outer: sha512Hex, inner: sha512Hex, length: 128 }, +]; + +export async function isCombinedDigestOf( + password: string, + stored: string, +): Promise { + const storedLower = stored.toLowerCase(); + for (const { inner, outer, length } of COMBINED_DIGEST_SCHEMES) { + if (!new RegExp(`^[a-f0-9]{${length}}$`, "i").test(stored)) continue; + if ((await outer(await inner(password))) === storedLower) return true; + } + return false; +} + +/** + * Legacy salted digests where the salt is embedded in the stored value using a + * non-hex separator: `:`, `:`, `$`, + * `$` (also supports `@` and `_`). Every digest family (md5, sha1, + * sha256, sha512) is tried, and both md5(salt+password) and md5(password+salt) + * orderings are verified to cover both conventions. + */ +const SALTED_HASH_DELIMITER_RE = /[:\$@_]/; + +export async function isSaltedDigestOf( + password: string, + stored: string, +): Promise { + if (!SALTED_HASH_DELIMITER_RE.test(stored)) return false; + + for (const { length, hash: hashFn } of DIGEST_HEX_LENGTHS) { + const hashFirst = stored.match( + new RegExp(`^([a-f0-9]{${length}})[:\$@_](.{1,64})$`, "i"), + ); + const saltFirst = stored.match( + new RegExp(`^(.{1,64})[:\$@_]([a-f0-9]{${length}})$`, "i"), + ); + const hashHex = hashFirst?.[1] ?? saltFirst?.[2]; + const salt = hashFirst?.[2] ?? saltFirst?.[1]; + if (!hashHex || !salt || salt.length > 64) continue; + + const hashLower = hashHex.toLowerCase(); + for (const candidate of [salt + password, password + salt]) { + if ((await hashFn(candidate)).toLowerCase() === hashLower) return true; + } + } + return false; +} + /** * Legacy argon2 verification (argon2id / argon2i / argon2d) — kept ONLY so * accounts hashed before the bcrypt switch can still sign in once and be @@ -161,8 +199,8 @@ export interface LoginCheck { * - bcrypt ($2a/$2b/$2y): direct verification * - argon2id/argon2i/argon2d: verify + auto-upgrade to bcrypt * - md5 / sha1 / sha256 / sha512 (unsalted hex): verify + auto-upgrade - * - double md5 (md5(md5(pass))): verify + auto-upgrade - * - salted md5 with embedded salt (hash:salt, salt:hash, hash$salt, ...): verify + * - combined digests (md5(md5(pass)), sha1(md5(pass)), double sha256, ...): verify + * - salted digests with embedded salt (hash:salt, salt:hash, hash$salt, ...): verify * - plaintext (final fallback): compare + auto-upgrade * * All legacy formats are automatically rewritten to bcrypt on success. @@ -193,10 +231,10 @@ export async function checkLogin( } } - // Legacy salted / double-digest conventions — verify + auto-upgrade. + // Legacy combined / salted digest conventions — verify + auto-upgrade. if ( - (await isDoubleMd5Of(password, stored)) || - (await isSaltedMd5Of(password, stored)) + (await isCombinedDigestOf(password, stored)) || + (await isSaltedDigestOf(password, stored)) ) { return { valid: true, upgradedHash: await hashPassword(password) }; }