feat(auth): support combined and salted digest schemes from any CMS
CI / check (push) Successful in 4m11s
CI / preflight (push) Skipped
CI / deploy (push) Successful in 1m39s

Expand checkLogin to auto-detect and migrate every common retro CMS password
format to bcrypt on login:
- combined digests: md5(md5(pass)), md5(sha1(pass)), sha1(md5(pass)),
  double sha1/sha256/sha512 and md5<->sha256/sha512 combinations
- salted digests of all families (md5/sha1/sha256/sha512) with embedded
  salt using : $ @ _ separators, verifying both salt+pass and pass+salt
- plaintext fallback stays as the final catch-all

All formats verified on login and rewritten to bcrypt, so accounts work
whenever they come from any legacy CMS.
This commit is contained in:
openhands committed 2026-09-17 15:10:15 +02:00
1 parent 2c0439db6a
commit 5d7c9fccdc
2 files changed
+153 -68

No files matched your search

+68 -21
View File
@@ -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 () => {