import { PASSWORD_MAX_LENGTH, PASSWORD_MIN_LENGTH, PASSWORD_POLICY_MESSAGE, } from "../constants/index.js"; export interface PasswordContext { email?: string | null; name?: string | null; } export type PasswordCheckResult = { ok: true } | { ok: false; reason: string }; // Curated list of >=12-char passwords that pass the length gate but are // known weak (rockyou top entries, predictable seasonal patterns, common // admin defaults). Stored lower-cased for case-insensitive matching. The // vast majority of weak passwords are <12 chars and already rejected by // length, so this list focuses on what would otherwise *pass* min-length. const COMMON_PASSWORDS: ReadonlySet = new Set([ "passwordpassword", "password1234", "password12345", "password123456", "password1234567", "passwordpassword1", "passw0rdpassw0rd", "1234567890ab", "1234567890abc", "12345678901", "123456789012", "1234567891234", "qwertyuiop12", "qwertyuiop123", "qwertyuiop1234", "qwertyuiopas", "asdfghjkl123", "asdfghjkl1234", "iloveyou1234", "iloveyou12345", "iloveyouabc1", "iloveyouforever", "ilovemybabies", "letmein123456", "letmein12345", "welcome12345", "welcome123456", "welcome1234567", "admin1234567", "admin12345678", "administrator1", "administrator123", "changeme1234", "changeme12345", "default1234567", "football1234", "baseball1234", "trustno112345", "summer2023!", "summer2024!", "summer2025!", "summer2026!", "winter2023!", "winter2024!", "winter2025!", "winter2026!", "spring2023!", "spring2024!", "spring2025!", "spring2026!", "autumn2023!", "autumn2024!", "autumn2025!", "autumn2026!", "welcome2023!", "welcome2024!", "welcome2025!", "welcome2026!", "password2023!", "password2024!", "password2025!", "password2026!", "p@ssw0rd1234", "p@ssword1234", ]); function isSingleCharRepeated(pw: string): boolean { if (pw.length === 0) return false; const first = pw[0]!; for (let i = 1; i < pw.length; i++) { if (pw[i] !== first) return false; } return true; } // Detect monotonically +/-1 character runs. A password where >=60% of // characters belong to a run of length >=5 (e.g. "abcdefgh1234") is // rejected as a keyboard / numeric sequence. The 60% threshold lets // passwords with a sequential prefix and a long random suffix pass. function isSequentialPassword(pw: string): boolean { if (pw.length < 8) return false; const lower = pw.toLowerCase(); let runChars = 0; let i = 0; while (i < lower.length) { let runLen = 1; while (i + runLen < lower.length) { const delta = lower.charCodeAt(i + runLen) - lower.charCodeAt(i + runLen - 1); if (delta === 1 || delta === -1) { runLen++; } else { break; } } if (runLen >= 5) runChars += runLen; i += runLen; } return runChars * 100 >= lower.length * 60; } // Reject passwords that are a 2-6 char pattern repeated >=3 times // (e.g. "abcabcabcabc", "12121212"). Length divisibility check first // keeps the constant-factor work tiny. function isShortPatternRepeated(pw: string): boolean { for (let patLen = 2; patLen <= 6; patLen++) { if (pw.length % patLen !== 0) continue; const repeats = pw.length / patLen; if (repeats < 3) continue; const pattern = pw.slice(0, patLen); let allMatch = true; for (let r = 1; r < repeats; r++) { if (pw.slice(r * patLen, (r + 1) * patLen) !== pattern) { allMatch = false; break; } } if (allMatch) return true; } return false; } // Substrings shorter than 4 chars are ignored: short names ("ed") or // generic email locals ("ab") would otherwise reject too many legitimate // passwords by coincidence. function containsIdentity(pw: string, ctx: PasswordContext | undefined): boolean { if (!ctx) return false; const lower = pw.toLowerCase(); if (ctx.email) { const local = ctx.email.split("@")[0]?.toLowerCase().trim() ?? ""; if (local.length >= 4 && lower.includes(local)) return true; } if (ctx.name) { const lowered = ctx.name.toLowerCase().trim(); const fullNoSpaces = lowered.replace(/\s+/g, ""); if (fullNoSpaces.length >= 4 && lower.includes(fullNoSpaces)) return true; // Also reject if the password embeds an individual name component // (e.g. given-name or surname). "Hartmut Noerenberg" → check both // "hartmut" and "noerenberg" so a password like "MyHartmutPass1!" // is rejected, not only the unrealistic "MyHartmutNoerenbergPass1!". for (const part of lowered.split(/\s+/)) { if (part.length >= 4 && lower.includes(part)) return true; } } return false; } /** * Validate a password against the policy. Pure synchronous function so it * can be called from server (tRPC mutations, server actions) and client * (pre-submit validation) with identical results. */ export function checkPasswordPolicy(password: string, ctx?: PasswordContext): PasswordCheckResult { if (password.length < PASSWORD_MIN_LENGTH) { return { ok: false, reason: PASSWORD_POLICY_MESSAGE }; } if (password.length > PASSWORD_MAX_LENGTH) { return { ok: false, reason: `Password must be no more than ${PASSWORD_MAX_LENGTH} characters.`, }; } if (isSingleCharRepeated(password)) { return { ok: false, reason: "Password must not be a single character repeated." }; } if (isShortPatternRepeated(password)) { return { ok: false, reason: "Password must not be a short pattern repeated." }; } if (isSequentialPassword(password)) { return { ok: false, reason: "Password must not be a keyboard or numeric sequence.", }; } if (COMMON_PASSWORDS.has(password.toLowerCase())) { return { ok: false, reason: "Password is in the list of commonly used or breached passwords.", }; } if (containsIdentity(password, ctx)) { return { ok: false, reason: "Password must not contain your email or name.", }; } return { ok: true }; }