225 lines
8.1 KiB
TypeScript
225 lines
8.1 KiB
TypeScript
// Central password validation. Every password-write endpoint MUST go through
|
|
// validatePassword(); inline length / character-class checks are forbidden.
|
|
// Configuration is env-driven so operators can tighten the policy without a
|
|
// code deploy.
|
|
//
|
|
// Supersedes the earlier prototype (validatePasswordPolicy / isPasswordPwned).
|
|
// Those were never wired up to any caller; the new API is the canonical one.
|
|
|
|
import { createHash } from "node:crypto";
|
|
|
|
const HIBP_API_BASE = "https://api.pwnedpasswords.com/range/";
|
|
const HIBP_TIMEOUT_MS = 3000;
|
|
const ABSOLUTE_MAX_LENGTH = 1024;
|
|
|
|
export type PasswordPolicyConfig = {
|
|
minLength: number;
|
|
requireUpper: boolean;
|
|
requireLower: boolean;
|
|
requireDigit: boolean;
|
|
requireSpecial: boolean;
|
|
hibpCheck: boolean;
|
|
hibpMaxOccurrences: number;
|
|
};
|
|
|
|
export type PasswordPolicyContext = {
|
|
email?: string;
|
|
};
|
|
|
|
/** Bounded i18n keys for each failure mode. */
|
|
export type PasswordPolicyErrorCode =
|
|
| "auth.password.errors.tooShort"
|
|
| "auth.password.errors.tooLong"
|
|
| "auth.password.errors.missingUpper"
|
|
| "auth.password.errors.missingLower"
|
|
| "auth.password.errors.missingDigit"
|
|
| "auth.password.errors.missingSpecial"
|
|
| "auth.password.errors.containsEmail"
|
|
| "auth.password.errors.pwned"
|
|
| "auth.password.errors.empty";
|
|
|
|
export type PasswordPolicyError = {
|
|
code: PasswordPolicyErrorCode;
|
|
/** Non-secret args for the i18n template (e.g. minLength). */
|
|
args?: Record<string, string | number>;
|
|
};
|
|
|
|
export type PasswordPolicyValidation = {
|
|
valid: boolean;
|
|
errors: PasswordPolicyError[];
|
|
/**
|
|
* True when the HIBP check was skipped because the API call failed. Route
|
|
* code may want to log this so operators can monitor the bypass rate.
|
|
*/
|
|
hibpBypassed?: boolean;
|
|
};
|
|
|
|
const SPECIAL_CHAR_RE = /[^A-Za-z0-9]/;
|
|
|
|
export function loadPasswordPolicy(env: Record<string, string | undefined> = process.env): PasswordPolicyConfig {
|
|
return {
|
|
minLength: parseIntWithDefault(env.AUTH_PASSWORD_MIN_LENGTH, 12),
|
|
requireUpper: parseBool(env.AUTH_PASSWORD_REQUIRE_UPPER, true),
|
|
requireLower: parseBool(env.AUTH_PASSWORD_REQUIRE_LOWER, true),
|
|
requireDigit: parseBool(env.AUTH_PASSWORD_REQUIRE_DIGIT, true),
|
|
requireSpecial: parseBool(env.AUTH_PASSWORD_REQUIRE_SPECIAL, false),
|
|
hibpCheck: parseBool(env.AUTH_PASSWORD_HIBP_CHECK, true),
|
|
hibpMaxOccurrences: parseIntWithDefault(env.AUTH_PASSWORD_HIBP_MAX_OCCURRENCES, 0),
|
|
};
|
|
}
|
|
|
|
export type ValidatePasswordOptions = {
|
|
policy?: PasswordPolicyConfig;
|
|
/** Test seam. Defaults to globalThis.fetch. */
|
|
fetcher?: typeof fetch;
|
|
};
|
|
|
|
export async function validatePassword(
|
|
plain: string,
|
|
ctx: PasswordPolicyContext = {},
|
|
options: ValidatePasswordOptions = {}
|
|
): Promise<PasswordPolicyValidation> {
|
|
const policy = options.policy ?? loadPasswordPolicy();
|
|
const errors: PasswordPolicyError[] = [];
|
|
|
|
if (typeof plain !== "string" || plain.length === 0) {
|
|
return { valid: false, errors: [{ code: "auth.password.errors.empty" }] };
|
|
}
|
|
if (plain.length < policy.minLength) {
|
|
errors.push({ code: "auth.password.errors.tooShort", args: { minLength: policy.minLength } });
|
|
}
|
|
if (plain.length > ABSOLUTE_MAX_LENGTH) {
|
|
errors.push({ code: "auth.password.errors.tooLong", args: { maxLength: ABSOLUTE_MAX_LENGTH } });
|
|
}
|
|
if (policy.requireUpper && !/[A-Z]/.test(plain)) {
|
|
errors.push({ code: "auth.password.errors.missingUpper" });
|
|
}
|
|
if (policy.requireLower && !/[a-z]/.test(plain)) {
|
|
errors.push({ code: "auth.password.errors.missingLower" });
|
|
}
|
|
if (policy.requireDigit && !/\d/.test(plain)) {
|
|
errors.push({ code: "auth.password.errors.missingDigit" });
|
|
}
|
|
if (policy.requireSpecial && !SPECIAL_CHAR_RE.test(plain)) {
|
|
errors.push({ code: "auth.password.errors.missingSpecial" });
|
|
}
|
|
if (ctx.email && containsEmailLocalPart(plain, ctx.email)) {
|
|
errors.push({ code: "auth.password.errors.containsEmail" });
|
|
}
|
|
|
|
let hibpBypassed = false;
|
|
if (policy.hibpCheck) {
|
|
const result = await checkHibp(plain, policy.hibpMaxOccurrences, options.fetcher);
|
|
if (result === "pwned") {
|
|
errors.push({ code: "auth.password.errors.pwned" });
|
|
} else if (result === "bypassed") {
|
|
hibpBypassed = true;
|
|
}
|
|
}
|
|
|
|
return { valid: errors.length === 0, errors, ...(hibpBypassed ? { hibpBypassed } : {}) };
|
|
}
|
|
|
|
function containsEmailLocalPart(plain: string, email: string): boolean {
|
|
const local = email.split("@")[0]?.toLowerCase();
|
|
if (!local || local.length < 3) return false;
|
|
return plain.toLowerCase().includes(local);
|
|
}
|
|
|
|
async function checkHibp(plain: string, maxOccurrences: number, fetcher?: typeof fetch): Promise<"clean" | "pwned" | "bypassed"> {
|
|
const sha1 = createHash("sha1").update(plain).digest("hex").toUpperCase();
|
|
const prefix = sha1.slice(0, 5);
|
|
const suffix = sha1.slice(5);
|
|
const url = `${HIBP_API_BASE}${prefix}`;
|
|
const usedFetcher = fetcher ?? globalThis.fetch;
|
|
if (typeof usedFetcher !== "function") return "bypassed";
|
|
|
|
let response: Response;
|
|
try {
|
|
response = await withTimeout(usedFetcher(url, { headers: { "Add-Padding": "true" } }), HIBP_TIMEOUT_MS);
|
|
} catch {
|
|
return "bypassed";
|
|
}
|
|
if (!response.ok) return "bypassed";
|
|
|
|
let body: string;
|
|
try {
|
|
body = await response.text();
|
|
} catch {
|
|
return "bypassed";
|
|
}
|
|
for (const line of body.split(/\r?\n/)) {
|
|
const [candidateSuffix, countStr] = line.trim().split(":");
|
|
if (candidateSuffix?.toUpperCase() !== suffix) continue;
|
|
const count = Number(countStr);
|
|
if (!Number.isFinite(count)) continue;
|
|
if (count > maxOccurrences) return "pwned";
|
|
return "clean";
|
|
}
|
|
return "clean";
|
|
}
|
|
|
|
async function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
|
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
const timeout = new Promise<T>((_, reject) => {
|
|
timer = setTimeout(() => reject(new Error("hibp_timeout")), ms);
|
|
});
|
|
try {
|
|
return await Promise.race([promise, timeout]);
|
|
} finally {
|
|
if (timer) clearTimeout(timer);
|
|
}
|
|
}
|
|
|
|
function parseIntWithDefault(raw: string | undefined, fallback: number): number {
|
|
if (!raw) return fallback;
|
|
const n = Number.parseInt(raw, 10);
|
|
return Number.isFinite(n) && n > 0 ? n : fallback;
|
|
}
|
|
|
|
function parseBool(raw: string | undefined, fallback: boolean): boolean {
|
|
if (raw === undefined) return fallback;
|
|
const lower = raw.trim().toLowerCase();
|
|
if (["true", "1", "yes", "on"].includes(lower)) return true;
|
|
if (["false", "0", "no", "off"].includes(lower)) return false;
|
|
return fallback;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Backwards-compatibility shims for the prototype API. Nothing in-tree calls
|
|
// these (rg -n "validatePasswordPolicy|assertPasswordPolicy|isPasswordPwned|
|
|
// MIN_PASSWORD_LENGTH" returns zero matches at the time of this rewrite), but
|
|
// they stay exported as one-liners around validatePassword so out-of-tree
|
|
// scripts that imported them don't break.
|
|
|
|
export const MIN_PASSWORD_LENGTH = 12;
|
|
|
|
export async function isPasswordPwned(password: string, fetchImpl: typeof fetch = fetch): Promise<boolean> {
|
|
const result = await checkHibp(password, 0, fetchImpl);
|
|
return result === "pwned";
|
|
}
|
|
|
|
export type PasswordPolicyIssue = "too-short" | "pwned" | "pwned-check-unavailable";
|
|
|
|
export type PasswordPolicyResult = {
|
|
ok: boolean;
|
|
issues: PasswordPolicyIssue[];
|
|
};
|
|
|
|
export async function validatePasswordPolicy(password: string, options: { fetchImpl?: typeof fetch } = {}): Promise<PasswordPolicyResult> {
|
|
const result = await validatePassword(password, {}, { fetcher: options.fetchImpl });
|
|
const issues: PasswordPolicyIssue[] = [];
|
|
if (result.errors.some((e) => e.code === "auth.password.errors.tooShort")) issues.push("too-short");
|
|
if (result.errors.some((e) => e.code === "auth.password.errors.pwned")) issues.push("pwned");
|
|
if (result.hibpBypassed) issues.push("pwned-check-unavailable");
|
|
return { ok: result.valid && !result.hibpBypassed, issues };
|
|
}
|
|
|
|
export async function assertPasswordPolicy(password: string, options: { fetchImpl?: typeof fetch } = {}) {
|
|
const result = await validatePasswordPolicy(password, options);
|
|
if (!result.ok) {
|
|
throw new Error(`Password policy failed: ${result.issues.join(", ")}`);
|
|
}
|
|
return result;
|
|
}
|