Files
HealthLog/src/lib/auth/mfa/totp.ts
T
Marc-André Bombeck b446a66753 feat(auth): add TOTP second factor and recovery codes
Add the TOTP enroll/confirm/disable surface and Argon2id-hashed recovery
codes as the v1.23 second factor.

- totp.ts wraps otpauth (zero-dep) pinned to the interop floor (SHA-1, 6
  digits, 30 s) with a +/-1 drift window. Replay protection persists the last
  accepted time-step per account (User.totpLastStep, migration
  0202_v123_totp_laststep) and rejects any step that is not strictly newer,
  so a captured code dies the instant it is accepted once.
- recovery-codes.ts issues ten single-use, high-entropy codes, Argon2id-
  hashed at rest, shown once; regeneration replaces the entire prior set in a
  transaction and verification burns the matched code with a guarded update.
- The TOTP secret is stored AES-256-GCM encrypted (already registered in the
  encrypted-columns registry + rotation script); decrypt is fail-closed.
- Routes: setup (pending secret), confirm (activate + issue recovery codes),
  disable (step-up + current factor), recovery-codes/regenerate (step-up).
  Setup/confirm are cookie-only; disable/regenerate add requireFreshMfa.
- scripts/disable-mfa.ts is the operator escape hatch for a locked-out
  self-hoster, audited, run via pnpm dlx tsx.
2026-06-27 19:56:16 +02:00

132 lines
4.7 KiB
TypeScript

/**
* TOTP (RFC 6238) second-factor primitives.
*
* Thin wrapper over `otpauth` (zero runtime deps) pinning the de-facto
* interop floor — **SHA-1, 6 digits, 30-second period** — because
* SHA-256/512 break the common authenticator apps and hardware tokens.
* Drift window is **±1 step** (previous / current / next), the RFC's
* recommended bound; every extra step widens the replay surface.
*
* Replay protection (the commonly-omitted control) is enforced by the
* caller via the monotonic last-step counter: `verifyTotp` resolves the
* submitted code to its absolute time-step and returns it, and the caller
* rejects any step that is not strictly greater than the persisted
* `User.totpLastStep`. A captured code therefore dies the instant it is
* accepted once, even while it is still inside its 30-second life.
*
* The shared secret never leaves this module as anything but Base32; the
* `otpauth://` URI carries the secret and is built here only to hand to
* the enrolling client — it is never logged or persisted.
*/
import * as OTPAuth from "otpauth";
export const TOTP_ISSUER = "HealthLog";
export const TOTP_ALGORITHM = "SHA1";
export const TOTP_DIGITS = 6;
export const TOTP_PERIOD_SECONDS = 30;
/** ±1 step (≈ ±30 s clock skew). Do not widen without cause. */
export const TOTP_WINDOW = 1;
/** 160-bit secret per OWASP MFA / NIST 800-63B (`size` is bytes). */
const TOTP_SECRET_BYTES = 20;
/**
* Generate a fresh CSPRNG Base32 secret (160-bit). The returned string is
* the only form persisted (AES-256-GCM encrypted) and reconstituted for
* verification.
*/
export function generateTotpSecret(): string {
return new OTPAuth.Secret({ size: TOTP_SECRET_BYTES }).base32;
}
/**
* Build the `otpauth://totp/...` enrollment URI for a Base32 secret.
* Issuer is the fixed app name; `account` is the user-facing label
* (email or username). The URI carries the secret — hand it to the client,
* never log or persist it.
*/
export function buildOtpauthUri(secretBase32: string, account: string): string {
const totp = new OTPAuth.TOTP({
issuer: TOTP_ISSUER,
label: account,
algorithm: TOTP_ALGORITHM,
digits: TOTP_DIGITS,
period: TOTP_PERIOD_SECONDS,
secret: OTPAuth.Secret.fromBase32(secretBase32),
});
return totp.toString();
}
/** The absolute RFC 6238 time-step for a moment in time. */
export function currentTotpStep(atMs: number = Date.now()): number {
return Math.floor(atMs / 1000 / TOTP_PERIOD_SECONDS);
}
export interface TotpVerifyResult {
/** True only when the code is in-window AND not a replay. */
valid: boolean;
/**
* The absolute time-step the code resolved to (current step + drift
* delta). Present whenever the code matched the secret in-window — even
* when rejected as a replay — so the caller can log the reason. Persist
* this as the new `totpLastStep` on a successful, non-replay accept.
*/
step: number | null;
/** True when the code matched in-window but its step was already used. */
replay: boolean;
}
/**
* Verify a 6-digit code against a Base32 secret with ±1 drift and
* monotonic replay rejection.
*
* @param secretBase32 the user's stored TOTP secret
* @param code the submitted 6-digit code (non-digits → invalid)
* @param lastStep the user's persisted `totpLastStep` (null = none yet)
* @param atMs clock injection point for tests
*/
export function verifyTotp(
secretBase32: string,
code: string,
lastStep: number | null,
atMs: number = Date.now(),
): TotpVerifyResult {
const normalised = code.trim();
if (!/^\d{6}$/.test(normalised)) {
return { valid: false, step: null, replay: false };
}
const totp = new OTPAuth.TOTP({
issuer: TOTP_ISSUER,
label: TOTP_ISSUER,
algorithm: TOTP_ALGORITHM,
digits: TOTP_DIGITS,
period: TOTP_PERIOD_SECONDS,
secret: OTPAuth.Secret.fromBase32(secretBase32),
});
// `validate` returns the signed step delta (e.g. -1, 0, +1) when the
// code matches inside the window, or null when it does not. The compare
// is over the numeric HOTP truncation, not a string equality on the
// secret, so there is no early-exit timing oracle on the secret bytes.
const delta = totp.validate({
token: normalised,
timestamp: atMs,
window: TOTP_WINDOW,
});
if (delta === null) {
return { valid: false, step: null, replay: false };
}
const step = currentTotpStep(atMs) + delta;
// Monotonic replay guard: the resolved step must be strictly newer than
// the last one we accepted. Reusing an already-burned step — even one
// still inside its 30-second life — is rejected.
if (lastStep !== null && step <= lastStep) {
return { valid: false, step, replay: true };
}
return { valid: true, step, replay: false };
}