diff --git a/server/lib/totp-lockout.js b/server/lib/totp-lockout.js new file mode 100644 index 0000000..b10db4f --- /dev/null +++ b/server/lib/totp-lockout.js @@ -0,0 +1,30 @@ +'use strict'; + +// #100 (tightening #2): brute-force lockout for POST /api/auth/totp/verify. A 6-digit +// code is only 1e6 wide, and an attacker who has the password already holds a valid +// mfa_pending token - the verify endpoint is the real attack surface. Lock a key +// (the mfa_pending user id) after MAX_FAILS bad codes, on top of the per-route 10/min +// rate-limit. Same shape as lib/pair-lockout.js (#87). In-memory; resets on restart. + +const MAX_FAILS = 5; // consecutive bad codes before lockout +const LOCKOUT_MS = 15 * 60 * 1000; // how long the key is then blocked + +const failures = new Map(); // key -> { count, lockedUntil } + +function isLocked(key, now = Date.now()) { + const rec = failures.get(key); + return !!(rec && rec.lockedUntil > now); +} + +function recordFailure(key, now = Date.now()) { + const rec = failures.get(key) || { count: 0, lockedUntil: 0 }; + rec.count += 1; + if (rec.count >= MAX_FAILS) { rec.lockedUntil = now + LOCKOUT_MS; rec.count = 0; } + failures.set(key, rec); + return rec; +} + +// A successful verify (or any reason to forgive) clears the key. +function reset(key) { failures.delete(key); } + +module.exports = { isLocked, recordFailure, reset, MAX_FAILS, LOCKOUT_MS }; diff --git a/server/lib/totp.js b/server/lib/totp.js new file mode 100644 index 0000000..18c912a --- /dev/null +++ b/server/lib/totp.js @@ -0,0 +1,56 @@ +'use strict'; + +// #100: TOTP (RFC 6238) helper. The shared secret is REVERSIBLE (the server must +// recompute codes), so it's stored via secretbox (AES-256-GCM) - NOT hashed like the +// API token / recovery codes. Recovery codes ARE hashed (SHA-256, same discipline as +// api_tokens) - see generateRecoveryCodes / hashRecoveryCode. + +const { authenticator } = require('otplib'); +const crypto = require('crypto'); +const secretbox = require('./secretbox'); +const { hashToken } = require('../middleware/apiToken'); + +const STEP_SEC = 30; +const ISSUER = 'ScreenTinker'; +authenticator.options = { window: 1 }; // accept ±1 step (±30s) for clock skew + +function generateSecret() { return authenticator.generateSecret(); } // base32 plaintext +function keyuri(email, secret) { return authenticator.keyuri(email, ISSUER, secret); } // otpauth:// for the QR +function encryptSecret(secret) { return secretbox.encrypt(secret); } // for storage +function decryptSecret(enc) { return secretbox.decrypt(enc); } // for verification + +function currentStep(now = Date.now()) { return Math.floor(now / 1000 / STEP_SEC); } + +// Verify a 6-digit code against the PLAINTEXT secret, blocking intra-window replay +// via lastStep. Returns the matched step (always > lastStep) on success, else null. +// The caller persists the returned step as the user's new totp_last_step. +function verifyCode(token, secret, lastStep = 0, now = Date.now()) { + if (!secret || !/^[0-9]{6}$/.test(String(token || '').trim())) return null; + const delta = authenticator.checkDelta(String(token).trim(), secret); // -1|0|1 or null + if (delta == null) return null; + const step = currentStep(now) + delta; + if (step <= lastStep) return null; // a code from an already-consumed step (replay) + return step; +} + +// 10 single-use recovery codes. Returns plaintext (shown ONCE) + SHA-256 hashes (stored). +function generateRecoveryCodes(n = 10) { + const plain = [], hashes = []; + for (let i = 0; i < n; i++) { + const code = crypto.randomBytes(5).toString('hex').toUpperCase(); // 10 hex chars + plain.push(code); + hashes.push(hashToken(code)); + } + return { plain, hashes }; +} + +// Normalize user input (strip spaces/hyphens, uppercase) then hash, so a code typed +// with stray formatting still matches the stored hash. +function hashRecoveryCode(input) { + return hashToken(String(input || '').toUpperCase().replace(/[^0-9A-F]/g, '')); +} + +module.exports = { + generateSecret, keyuri, encryptSecret, decryptSecret, + verifyCode, currentStep, generateRecoveryCodes, hashRecoveryCode, STEP_SEC, +};