Files
sencho/backend/src/services/MfaService.ts
T
Anso 7e65a2ae19 fix(mfa): enforce single-use backup codes under concurrent verification (#1262)
* fix(mfa): enforce single-use backup codes under concurrent verification

Backup-code consumption read the stored hash set, awaited bcrypt.compare,
then wrote the shrunk set back. Two concurrent /login/mfa requests carrying
the same code could both read the same set, both match, and both persist,
so a single backup code yielded two authenticated sessions.

Move consumption into a synchronous transaction: verifyBackupCode now returns
the matched hash without mutating state, and a new consumeBackupCodeHash
re-reads and shrinks the set atomically, returning whether the hash was still
present. Login gates success on that result, so exactly one concurrent request
wins. Reshape the verify result into a discriminated union so a match always
carries its hash.

Add coverage: a deterministic consume test (same hash twice, distinct hashes,
absent hash), a concurrent same-code login race, backup-code exhaustion, and a
disable-via-backup-code path.

* test(mfa): make the concurrent backup-code test deterministic

The race test relied on incidental scheduling to interleave the two requests,
so it could false-green if they happened to serialize. Add a barrier that holds
both requests just after verification (once both have read the same stored set)
until both arrive, then releases them into the atomic consume. This forces the
race every run, so the test fails against a non-atomic consume and passes only
when exactly one request wins. A timeout releases the barrier if only one
request arrives, so a setup fault fails loudly instead of hanging.
2026-05-31 20:28:48 -04:00

175 lines
6.7 KiB
TypeScript

import crypto from 'crypto';
import bcrypt from 'bcrypt';
import { OTP } from 'otplib';
import { DatabaseService } from './DatabaseService';
import { MFA_REPLAY_TTL_MS, MFA_REPLAY_PURGE_INTERVAL_MS } from '../helpers/constants';
import { isDebugEnabled } from '../utils/debug';
// TOTP configuration: 6 digits, 30-second step, SHA-1, ±1 step tolerance.
// SHA-1 is the universally supported default for authenticator apps (RFC 6238).
const totp = new OTP({ strategy: 'totp' });
const TOTP_PARAMS = { algorithm: 'sha1' as const, digits: 6, period: 30 };
const BACKUP_CODE_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; // Crockford-like, no 0/O/1/I/L
const BACKUP_CODE_LENGTH = 10;
const BACKUP_CODE_COUNT = 10;
const BACKUP_HASH_COST = 10;
/**
* Result of checking a backup code. On a match it carries the exact stored
* hash so the caller can consume that entry atomically (see
* DatabaseService.consumeBackupCodeHash); the discriminated shape makes the
* "matched implies a hash to consume" invariant unrepresentable otherwise.
*/
export type BackupVerifyResult =
| { matched: true; matchedHash: string }
| { matched: false };
export class MfaService {
private static instance: MfaService;
private purgeTimer: NodeJS.Timeout | null = null;
public static getInstance(): MfaService {
if (!MfaService.instance) MfaService.instance = new MfaService();
return MfaService.instance;
}
/**
* Start the periodic purge of used-MFA-code rows. The replay blacklist
* holds (user, code, window) tuples for the last ~2 minutes; older rows
* are safe to drop. Idempotent: calling start() twice is a no-op.
*/
public start(): void {
if (this.purgeTimer) return;
this.purgeTimer = setInterval(() => {
try {
const deleted = DatabaseService.getInstance().purgeOldMfaCodes(Date.now() - MFA_REPLAY_TTL_MS);
if (isDebugEnabled() && deleted > 0) {
console.log('[MFA:diag] replay purge deleted=', deleted);
}
} catch (err) {
console.warn('[MFA] Replay purge failed:', (err as Error).message);
}
}, MFA_REPLAY_PURGE_INTERVAL_MS);
this.purgeTimer.unref();
}
public stop(): void {
if (this.purgeTimer) {
clearInterval(this.purgeTimer);
this.purgeTimer = null;
}
}
/**
* Generate a fresh base32 TOTP secret ready for `buildOtpauthUri` and
* `verifyTotp`. Each user should receive a unique secret.
*/
public static generateSecret(): string {
return totp.generateSecret();
}
/**
* Build an `otpauth://` URI for QR-code rendering or manual entry. The
* label follows the RFC 6238 format `Issuer:account` so the authenticator
* app can label the entry clearly.
*/
public static buildOtpauthUri(secret: string, username: string, issuer = 'Sencho'): string {
return totp.generateURI({ issuer, label: username, secret, ...TOTP_PARAMS });
}
/**
* Verify a TOTP code against the stored secret. Uses the window tolerance
* configured above, so a code is accepted if it matches the previous,
* current, or next 30-second step.
*/
public static verifyTotp(secret: string, code: string): boolean {
if (!secret || !code) return false;
const trimmed = code.trim().replace(/\s+/g, '');
if (!/^\d{6}$/.test(trimmed)) return false;
try {
return totp.verifySync({ secret, token: trimmed, ...TOTP_PARAMS, epochTolerance: 30 }).valid;
} catch {
return false;
}
}
/**
* Return the integer Unix step for the current time. Used to key the
* replay-prevention blacklist so a given (user, code, window) combination
* can only be used once.
*/
public static currentWindow(nowMs: number = Date.now()): number {
return Math.floor(nowMs / 1000 / 30);
}
/**
* Generate a fresh set of backup codes in cleartext. Callers should pass
* these through `hashBackupCodes` before persistence and show the
* cleartext to the user exactly once.
*/
public static generateBackupCodes(count: number = BACKUP_CODE_COUNT): string[] {
const codes: string[] = [];
for (let i = 0; i < count; i++) {
codes.push(this.randomBackupCode());
}
return codes;
}
/**
* Hash each backup code with bcrypt so the stored form cannot be replayed
* even if the database is leaked.
*/
public static async hashBackupCodes(codes: string[]): Promise<string[]> {
return Promise.all(codes.map((code) => bcrypt.hash(this.normalizeBackupCode(code), BACKUP_HASH_COST)));
}
/**
* Check a user-supplied backup code against the stored hashes. Returns
* `{ matched, matchedHash }`. The caller must consume `matchedHash`
* atomically to enforce single-use; this method does not mutate state, so
* two concurrent verifications of the same code cannot both win at the
* write (see DatabaseService.consumeBackupCodeHash).
*/
public static async verifyBackupCode(hashes: string[], code: string): Promise<BackupVerifyResult> {
const normalized = this.normalizeBackupCode(code);
if (!normalized) return { matched: false };
for (const hash of hashes) {
// bcrypt.compare is constant-time per hash; we return on the first
// match. The matched slot's position carries no useful signal: the
// codes are random and single-use, so leaking "which slot" via an
// early return tells an attacker nothing.
if (await bcrypt.compare(normalized, hash)) {
return { matched: true, matchedHash: hash };
}
}
return { matched: false };
}
/**
* Display helper: group a 10-character backup code as `ABCDE-FGHIJ` so
* it is easier for the user to read and transcribe.
*/
public static formatBackupCodeForDisplay(code: string): string {
const normalized = this.normalizeBackupCode(code);
if (normalized.length !== BACKUP_CODE_LENGTH) return normalized;
return `${normalized.slice(0, 5)}-${normalized.slice(5)}`;
}
/** Uppercase, strip non-alphanumeric separators (e.g. dashes, spaces). */
public static normalizeBackupCode(code: string): string {
if (!code) return '';
return code.toUpperCase().replace(/[^A-Z0-9]/g, '');
}
private static randomBackupCode(): string {
const bytes = crypto.randomBytes(BACKUP_CODE_LENGTH);
let out = '';
for (let i = 0; i < BACKUP_CODE_LENGTH; i++) {
out += BACKUP_CODE_ALPHABET[bytes[i] % BACKUP_CODE_ALPHABET.length];
}
return out;
}
}