// Single-use ledger for WebAuthn challenges (data/webauthn-replay.db, better-sqlite3). // // The web flow already gets single-use semantics from the challenge cookie being deleted on // consume. The native app carries the signed challenge token in the request BODY instead (it // gets no cookies), so a captured login-verify body could be replayed within the 5-minute TTL. // Every consumed challenge is recorded here and rejected on a second sight — shared across PM2 // workers because SQLite lives on disk. Same lazy-open / fail-soft pattern as fcmDb.ts: if the // DB cannot be opened the check degrades to "not seen" and logs a warning instead of breaking login. import Database from 'better-sqlite3' import path from 'path' import fs from 'fs' let db: any = null let initFailed = false const getDb = () => { if (db) return db if (initFailed) return null try { const dbPath = path.join(process.cwd(), 'data', 'webauthn-replay.db') const dataDir = path.dirname(dbPath) if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true }) const _db = new Database(dbPath) _db.pragma('journal_mode = WAL') _db.exec(`CREATE TABLE IF NOT EXISTS used_challenges ( challenge TEXT PRIMARY KEY, kind TEXT NOT NULL, used_at INTEGER NOT NULL, expires_at INTEGER NOT NULL ); CREATE INDEX IF NOT EXISTS idx_used_challenges_exp ON used_challenges(expires_at);`) db = _db return db } catch (e: any) { initFailed = true console.warn('[webauthnReplayDb] open failed — replay protection degraded:', e?.message) return null } } /** * Marks a challenge as used. Returns false when it was already recorded (replay). * `expiresAt` lets the row be pruned once the challenge could not verify anyway. */ export const markChallengeUsed = (challenge: string, kind: string, expiresAt: number): boolean => { const d = getDb() if (!d || !challenge) return true try { const now = Date.now() d.prepare('DELETE FROM used_challenges WHERE expires_at < ?').run(now - 60_000) const r = d.prepare( 'INSERT OR IGNORE INTO used_challenges (challenge, kind, used_at, expires_at) VALUES (?, ?, ?, ?)' ).run(challenge, kind, now, expiresAt) return r.changes === 1 } catch (e: any) { console.warn('[webauthnReplayDb] mark failed:', e?.message) return true } }