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:
openhands
2026-09-17 15:10:15 +02:00
parent 2c0439db6a
commit 5d7c9fccdc
2 changed files with 153 additions and 68 deletions
+85 -47
View File
@@ -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) };
}