import type { Kysely } from 'kysely'; import type { Database } from '../db/schema.js'; /** * Sign-in throttling. * * There was none while password sign-in was a local convenience, which was defensible: an attacker * who can reach your laptop's dev server has already won. As the production front door it is the * difference between a password and no password at all — PBKDF2 at 100k iterations makes each * guess expensive for the *server*, and does nothing to stop an attacker making millions of them. * * Two identifiers, counted separately and both enforced: * * - **by email**, which stops one account being ground down; and * - **by IP**, which stops the same client spraying one guess across every account it can name. * Per-email limiting alone is the classic gap — "password123" against a thousand addresses trips * no per-account counter anywhere. * * Deliberately in the database rather than in memory. A Worker isolate is per-request and short * lived, so an in-process map would reset constantly and protect nothing; and two isolates would * each keep their own count. One indexed query per attempt is the price of a limit that is real. */ /** Failures allowed per identifier before sign-in is refused. */ export declare const MAX_ATTEMPTS = 10; /** How far back failures are counted, and therefore how long a lockout lasts. */ export declare const WINDOW_MS: number; export interface ThrottleStatus { blocked: boolean; /** Failures counted in the window, for the caller to log. Never shown to the client. */ attempts: number; /** When the oldest counted failure ages out, so a caller can say how long to wait. */ retryAfterSeconds: number; } export declare function emailKey(email: string): string; export declare function ipKey(ip: string): string; /** Failed reset requests allowed per identifier. Lower than sign-in: nobody mistypes this form. */ export declare const MAX_RESET_REQUESTS = 5; /** * Password-reset requests count in their own keyspace, not against sign-in. * * Sharing the `email:` key would hand anyone a denial of service: fire ten reset requests at an * address and its owner can no longer sign in for fifteen minutes, having done nothing and * received nothing but junk mail. The counters have to be separate for the limit on one to not be * a weapon against the other. */ export declare function resetEmailKey(email: string): string; export declare function resetIpKey(ip: string): string; /** * Whether any of these identifiers is currently over the limit. * * Checked *before* verifying the password, so a locked-out attempt costs one indexed count rather * than a 100,000-iteration key derivation. That matters: without it, the throttle would make the * server do the expensive work anyway and become its own denial-of-service amplifier. */ export declare function checkThrottle(db: Kysely, identifiers: string[], limit?: number): Promise; /** Record a failure against each identifier. */ export declare function recordFailedAttempt(db: Kysely, identifiers: string[]): Promise; /** * Clear the counters after a successful sign-in. * * Only for the identifiers that just succeeded. Someone who mistypes their password four times and * then gets it right should start from zero — but the IP is cleared too, deliberately, because an * attacker who guesses one password correctly has a session and no longer needs the login form. */ export declare function clearAttempts(db: Kysely, identifiers: string[]): Promise; /** Drop attempts that have aged out. Safe to call on a schedule. */ export declare function purgeExpiredAttempts(db: Kysely): Promise;