feat(auth): support combined and salted digest schemes from any CMS
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:
1 parent
2c0439db6a
commit
5d7c9fccdc
2 files changed
+153
-68
No files matched your search
@@ -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 () => {
|
||||
|
||||
+85
-47
@@ -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<boolean> {
|
||||
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: `<hash>:<salt>`, `<salt>:<hash>`, `<hash>$<salt>`,
|
||||
* `<salt>$<hash>` (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<boolean> {
|
||||
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<string>;
|
||||
}> = [
|
||||
{ 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<string>;
|
||||
outer: (input: string) => Promise<string>;
|
||||
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<boolean> {
|
||||
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: `<hash>:<salt>`, `<salt>:<hash>`, `<hash>$<salt>`,
|
||||
* `<salt>$<hash>` (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<boolean> {
|
||||
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) };
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user