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.
This commit is contained in:
Anso
2026-05-31 20:28:48 -04:00
committed by GitHub
parent d03d97d964
commit 7e65a2ae19
4 changed files with 198 additions and 24 deletions
+9 -2
View File
@@ -129,8 +129,15 @@ mfaRouter.post('/login/mfa', authRateLimiter, async (req: Request, res: Response
if (isDebugEnabled()) console.log('[MFA:diag] login/mfa: branch=backup user=', user.username, 'matched=', result.matched, 'bcryptMs=', bcryptMs, 'hashesChecked=', hashes.length);
if (bcryptMs > 500) console.warn('[MFA] Slow backup-code verify for user=', user.username, 'durationMs=', bcryptMs);
if (result.matched) {
db.upsertUserMfa(decoded.user_id, { backup_codes_json: JSON.stringify(result.remainingHashes) });
verified = true;
// Consume the matched hash atomically. A concurrent request carrying
// the same code that already consumed it makes this return false, so
// exactly one login wins: single-use is enforced at the write, not from
// the in-memory snapshot read above.
if (db.consumeBackupCodeHash(decoded.user_id, result.matchedHash)) {
verified = true;
} else if (isDebugEnabled()) {
console.log('[MFA:diag] login/mfa: backup code already consumed (concurrent use) user=', user.username);
}
}
} else {
const trimmed = rawCode.trim().replace(/\s+/g, '');