mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-17 14:08:19 +00:00
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:
@@ -2752,6 +2752,33 @@ export class DatabaseService {
|
||||
return result.changes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomically consume a single backup-code hash. Re-reads the stored set,
|
||||
* removes `matchedHash` if still present, and persists the shrunk set in
|
||||
* one synchronous transaction. Returns true when the hash was present (and
|
||||
* is now consumed), false when it was already gone (e.g. a concurrent
|
||||
* /login/mfa request carrying the same code consumed it first). This is the
|
||||
* single-use enforcement point: callers verify the code, then gate success
|
||||
* on this returning true, so two concurrent verifications of the same code
|
||||
* cannot both succeed.
|
||||
*/
|
||||
public consumeBackupCodeHash(userId: number, matchedHash: string): boolean {
|
||||
const consume = this.db.transaction((): boolean => {
|
||||
const row = this.db
|
||||
.prepare('SELECT backup_codes_json FROM user_mfa WHERE user_id = ?')
|
||||
.get(userId) as { backup_codes_json: string | null } | undefined;
|
||||
const hashes: string[] = row?.backup_codes_json ? JSON.parse(row.backup_codes_json) : [];
|
||||
const idx = hashes.indexOf(matchedHash);
|
||||
if (idx === -1) return false;
|
||||
hashes.splice(idx, 1);
|
||||
this.db
|
||||
.prepare('UPDATE user_mfa SET backup_codes_json = ?, updated_at = ? WHERE user_id = ?')
|
||||
.run(JSON.stringify(hashes), Date.now(), userId);
|
||||
return true;
|
||||
});
|
||||
return consume();
|
||||
}
|
||||
|
||||
// --- Role Assignments ---
|
||||
|
||||
public getRoleAssignments(userId: number, resourceType: ResourceType, resourceId: string): RoleAssignment[] {
|
||||
|
||||
@@ -15,10 +15,15 @@ const BACKUP_CODE_LENGTH = 10;
|
||||
const BACKUP_CODE_COUNT = 10;
|
||||
const BACKUP_HASH_COST = 10;
|
||||
|
||||
export interface BackupVerifyResult {
|
||||
matched: boolean;
|
||||
remainingHashes: string[];
|
||||
}
|
||||
/**
|
||||
* 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;
|
||||
@@ -121,25 +126,25 @@ export class MfaService {
|
||||
|
||||
/**
|
||||
* Check a user-supplied backup code against the stored hashes. Returns
|
||||
* `{ matched, remainingHashes }`; when matched, the matched hash is
|
||||
* removed so callers can persist the shrunk set and enforce single-use
|
||||
* semantics.
|
||||
* `{ 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, remainingHashes: hashes };
|
||||
if (!normalized) return { matched: false };
|
||||
|
||||
for (let i = 0; i < hashes.length; i++) {
|
||||
// bcrypt.compare is constant-time for a given hash. We still check
|
||||
// every hash regardless of an early hit to avoid leaking which
|
||||
// slot matched via timing.
|
||||
const ok = await bcrypt.compare(normalized, hashes[i]);
|
||||
if (ok) {
|
||||
const remaining = hashes.slice(0, i).concat(hashes.slice(i + 1));
|
||||
return { matched: true, remainingHashes: remaining };
|
||||
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, remainingHashes: hashes };
|
||||
return { matched: false };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user