import type { Kysely } from 'kysely'; import type { Database, User } from '../db/schema.js'; /** * Two-factor authentication, wiring up the TOTP core. * * That core has been implemented and verified against the RFC 6238 test vectors since Phase 0, and * reachable from nothing: no enrolment, no challenge at sign-in, so every export in `totp.ts` was * dead. It matters more now than it did then — email and password is the front door, and a * password is one secret. * * Three pieces beyond the algorithm, each of which is what separates a demo from something worth * turning on: * * - **Enrolment is two-step.** The secret is stored unverified, and only a correct code marks it * live. Trusting a scan would let someone lock themselves out by mis-scanning, and the account * would demand a code from an authenticator that never got the secret. * - **A spent code cannot be replayed** — see `last_used_step`. * - **Recovery codes exist.** The failure mode 2FA introduces is a lost phone, and without * recovery that means an administrator resetting it, or a database console for the last admin. */ /** How many recovery codes are issued at enrolment. */ export declare const RECOVERY_CODE_COUNT = 10; /** How long a half-finished sign-in stays open. */ export declare const CHALLENGE_TTL_MS: number; export declare class TwoFactorError extends Error { readonly code: 'invalid_code' | 'not_enrolled' | 'already_enrolled'; name: string; constructor(message: string, code: 'invalid_code' | 'not_enrolled' | 'already_enrolled'); } export interface TwoFactorStatus { /** Enrolment finished: sign-in will ask for a code. */ enabled: boolean; /** A secret exists but was never confirmed. Offering to start again is the right response. */ pending: boolean; recoveryCodesRemaining: number; } export declare function twoFactorStatus(db: Kysely, userId: string): Promise; /** * Start enrolment: mint a secret and return what an authenticator needs. * * Replaces any unconfirmed secret, so abandoning a half-finished attempt and starting again works * rather than wedging. A *confirmed* one is refused — turning 2FA off is a separate, deliberate * action, and silently replacing a working secret would be a way to take an account over from a * session someone left open. */ export declare function beginTwoFactorEnrolment(db: Kysely, user: User): Promise<{ secret: string; uri: string; }>; /** * Finish enrolment with a code from the authenticator, and issue recovery codes. * * The codes are returned once, in plain text, and stored only as hashes — so the screen showing * them is the only chance to keep them. That is the point rather than a limitation: a set of * recovery codes the server could read back is a set an attacker with database access could read * back too. */ export declare function confirmTwoFactorEnrolment(db: Kysely, userId: string, code: string): Promise; /** Issue a fresh set, replacing any that remain. */ export declare function regenerateRecoveryCodes(db: Kysely, userId: string): Promise; /** * Check a code at sign-in — either from the authenticator or a recovery code. * * One function for both because the caller should not have to decide which the user typed, and * because a caller that checked them in two places would eventually check only one. */ export declare function verifyTwoFactor(db: Kysely, userId: string, code: string): Promise; /** Turn two-factor off, dropping the secret and every remaining recovery code. */ export declare function disableTwoFactor(db: Kysely, userId: string): Promise; export declare function createLoginChallenge(db: Kysely, userId: string): Promise<{ token: string; expiresAt: Date; }>; /** The user a challenge is for, if it is still open. Does not consume it. */ export declare function resolveLoginChallenge(db: Kysely, token: string): Promise; export declare function consumeLoginChallenge(db: Kysely, token: string): Promise; /** Drop challenges that have expired. Safe to call on a schedule. */ export declare function purgeExpiredChallenges(db: Kysely): Promise; /** Accept a code however it was written down: spaced, hyphenated, lower case. */ export declare function normalizeRecoveryCode(code: string): string;