Phase 19: Local Auth (No-OIDC Mode) #23

Merged
luckberg merged 78 commits from gsd/phase-19-local-auth-no-oidc-mode into main 2026-06-18 06:25:00 -04:00
Showing only changes of commit f02521dd02 - Show all commits
+26
View File
@@ -97,6 +97,29 @@ export function resetLoginAttempts(username: string): void {
loginAttempts.delete(username.trim()); loginAttempts.delete(username.trim());
} }
/**
* IN-03: evict stale rate-limit entries to bound the in-memory map size.
*
* An entry is stale (and safe to drop) when it is neither inside an active rate-limit
* window nor inside an active lockout TTL:
* - locked-out entries expire LOCKOUT_TTL_MS after lockedAt
* - non-locked entries expire once their lockedUntil window has passed
*
* Called opportunistically at the top of each login request. Dropping an entry is
* behaviourally identical to the entry having naturally expired, so eviction never
* weakens the brute-force defense — it only reclaims memory for identities no longer
* under active rate-limiting.
*/
function evictStaleLoginAttempts(now: number): void {
for (const [k, v] of loginAttempts) {
const lockoutExpired = v.lockedOut ? now - v.lockedAt >= LOCKOUT_TTL_MS : true;
const windowExpired = now >= v.lockedUntil;
if (lockoutExpired && windowExpired) {
loginAttempts.delete(k);
}
}
}
// Pre-computed dummy hash used to run verifyPassword on unknown-username paths // Pre-computed dummy hash used to run verifyPassword on unknown-username paths
// (timing defense — prevents timing-oracle username enumeration, RESEARCH Pitfall 2). // (timing defense — prevents timing-oracle username enumeration, RESEARCH Pitfall 2).
// WR-03: hashPassword is now async. Kick off the computation once at module load and keep // WR-03: hashPassword is now async. Kick off the computation once at module load and keep
@@ -115,6 +138,9 @@ localAuthRouter.post('/local/login', zValidator('json', loginSchema, noEchoHook)
const key = username; // loginSchema .trim()s the username; the map key matches resetLoginAttempts const key = username; // loginSchema .trim()s the username; the map key matches resetLoginAttempts
const now = Date.now(); const now = Date.now();
// IN-03: opportunistically reclaim memory from entries that are no longer actively
// rate-limited or locked out. Cheap at household scale; bounds the map under input churn.
evictStaleLoginAttempts(now);
const attempt = loginAttempts.get(key); const attempt = loginAttempts.get(key);
// 423: account locked (>= LOCKOUT_FAILURES failures). CR-04: the lockout auto-expires // 423: account locked (>= LOCKOUT_FAILURES failures). CR-04: the lockout auto-expires