mirror of
https://github.com/screentinker/screentinker.git
synced 2026-06-22 05:54:02 -06:00
feat(server): TOTP primitives - encrypted secret, hashed recovery codes, verify lockout (#100)
lib/totp.js: otplib wrapper; secret stored via secretbox (must be reversible to recompute codes); recovery codes SHA-256-hashed (api_tokens discipline); verifyCode returns the matched step and blocks intra-window replay via totp_last_step; decrypt failures return null (no throw). lib/totp-lockout.js: per-user lockout for /totp/verify (#87 model). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
e1cd8591bb
commit
c02086e305
30
server/lib/totp-lockout.js
Normal file
30
server/lib/totp-lockout.js
Normal file
|
|
@ -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 };
|
||||||
56
server/lib/totp.js
Normal file
56
server/lib/totp.js
Normal file
|
|
@ -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,
|
||||||
|
};
|
||||||
Loading…
Reference in a new issue