test(server): TOTP - bite, lockout, replay, recovery, st_ bypass, key-rotation (#100)

Unit: the mfa_pending BITE (db-injected so removing the rejection goes red), lockout, replay,
recovery-hash, decrypt-null graceful. Integration: enrollment, login->mfa_required, route-level
bite, recovery single-use, API-token bypass, verify lockout. Key-rotation: enroll under key A,
reboot under key B -> recovery still works, TOTP fails cleanly (no 500).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
ScreenTinker 2026-06-13 20:36:10 -05:00 committed by screentinker
parent 1d3e9acea4
commit 728f03beba
3 changed files with 273 additions and 0 deletions

View file

@ -0,0 +1,77 @@
'use strict';
// #100 key-rotation robustness: secretbox derives its key from JWT_SECRET, so an enrolled
// user's totp_secret_enc is bound to it. If the key changes (redeploy with a different
// JWT_SECRET, or a non-persisted .jwt_secret regenerated on a fresh Docker boot), the
// stored TOTP secret becomes undecryptable. Requirement: the user must NOT be hard-locked
// out - recovery codes (hashed, key-independent) must still work, and a TOTP attempt must
// fail CLEANLY (401), never 500. Boots under key A (enroll), reboots under key B (verify).
const { test } = require('node:test');
const assert = require('node:assert/strict');
const { spawn } = require('node:child_process');
const path = require('node:path');
const os = require('node:os');
const fs = require('node:fs');
const crypto = require('node:crypto');
const { authenticator } = require('otplib');
const PORT = 3980;
const BASE = `http://127.0.0.1:${PORT}`;
const DATA_DIR = path.join(os.tmpdir(), 'st-totp-rot-' + crypto.randomBytes(4).toString('hex'));
function bootServer(jwtSecret) {
const logFd = fs.openSync(path.join(os.tmpdir(), 'st-rot-' + crypto.randomBytes(3).toString('hex') + '.log'), 'w');
return spawn('node', ['server.js'], {
cwd: path.join(__dirname, '..'),
env: { ...process.env, DATA_DIR, SELF_HOSTED: 'true', PORT: String(PORT), NODE_ENV: 'test', JWT_SECRET: jwtSecret },
stdio: ['ignore', logFd, logFd],
});
}
async function waitUp() {
for (let i = 0; i < 80; i++) {
try { const r = await fetch(BASE + '/api/status'); if (r.ok) return; } catch { /* not yet */ }
await new Promise(r => setTimeout(r, 250));
}
throw new Error('server did not boot');
}
async function jfetch(p, opts = {}) {
const res = await fetch(BASE + p, opts);
let body = null; try { body = await res.json(); } catch { /* non-JSON */ }
return { status: res.status, body };
}
const post = (o) => ({ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(o || {}) });
const postAuth = (tok, o) => ({ method: 'POST', headers: { Authorization: 'Bearer ' + tok, 'Content-Type': 'application/json' }, body: JSON.stringify(o || {}) });
test('#100 key rotation does NOT brick TOTP: recovery survives; TOTP fails cleanly (no 500)', async () => {
let proc = bootServer('keyA-' + crypto.randomBytes(8).toString('hex'));
try {
await waitUp();
const email = 'rot' + crypto.randomBytes(4).toString('hex') + '@x.local';
const tok = (await jfetch('/api/auth/register', post({ email, password: 'Passw0rd123' }))).body.token;
const secret = (await jfetch('/api/auth/totp/setup', postAuth(tok, {}))).body.secret;
const recovery = (await jfetch('/api/auth/totp/enable', postAuth(tok, { code: authenticator.generate(secret) }))).body.recovery_codes;
assert.equal(recovery.length, 10, 'enrolled under key A');
proc.kill('SIGKILL'); await new Promise(r => setTimeout(r, 600));
// Reboot with a DIFFERENT key (same DATA_DIR) -> totp_secret_enc is now undecryptable.
proc = bootServer('keyB-' + crypto.randomBytes(8).toString('hex'));
await waitUp();
// password login still issues an MFA challenge
const l1 = await jfetch('/api/auth/login', post({ email, password: 'Passw0rd123' }));
assert.equal(l1.body.mfa_required, true, 'still challenged after the key change');
// a TOTP code can't be verified (secret undecryptable) -> CLEAN 401, NEVER 500
const totpTry = await jfetch('/api/auth/totp/verify', post({ mfa_token: l1.body.mfa_token, code: authenticator.generate(secret) }));
assert.equal(totpTry.status, 401, 'TOTP fails cleanly when the secret cannot be decrypted (not 500)');
// a RECOVERY code STILL works (hashed, key-independent) -> the user is not bricked
const l2 = await jfetch('/api/auth/login', post({ email, password: 'Passw0rd123' }));
const rec = await jfetch('/api/auth/totp/verify', post({ mfa_token: l2.body.mfa_token, code: recovery[0] }));
assert.ok(rec.body.token, 'recovery code survives the key change -> NOT hard-locked-out');
} finally {
try { proc.kill('SIGKILL'); } catch { /* ignore */ }
}
});

View file

@ -0,0 +1,85 @@
'use strict';
// #100 unit tests: the security-critical assertions that don't need a full server.
// The bite-test (#1) injects an in-memory db with a real user row so that REMOVING the
// mfa_pending rejection in requireAuth makes it go red (the pending token would then
// find the user and call next()).
const { test } = require('node:test');
const assert = require('node:assert/strict');
const crypto = require('node:crypto');
const Database = require('better-sqlite3');
const { authenticator } = require('otplib');
// Inject the db BEFORE requiring middleware/auth so requireAuth queries this one.
const mem = new Database(':memory:');
mem.exec(`CREATE TABLE users (id TEXT PRIMARY KEY, email TEXT, name TEXT, role TEXT,
auth_provider TEXT, avatar_url TEXT, plan_id TEXT, email_alerts INTEGER,
must_change_password INTEGER NOT NULL DEFAULT 0)`);
mem.prepare("INSERT INTO users (id,email,name,role,auth_provider) VALUES ('u1','u1@x','U1','user','local')").run();
require.cache[require.resolve('../db/database')] = {
id: require.resolve('../db/database'), loaded: true, exports: { db: mem },
};
const { requireAuth, generateMfaPendingToken, generateToken } = require('../middleware/auth');
const totp = require('../lib/totp');
const totpLockout = require('../lib/totp-lockout');
function runRequireAuth(token) {
const req = { headers: { authorization: 'Bearer ' + token }, originalUrl: '/api/devices' };
let status = 200, nexted = false;
const res = { status(s) { status = s; return this; }, json() { return this; } };
requireAuth(req, res, () => { nexted = true; });
return { status, nexted, req };
}
test('#100 BITE: requireAuth rejects an mfa_pending token (no password-only session)', () => {
const pending = runRequireAuth(generateMfaPendingToken({ id: 'u1' }));
assert.equal(pending.status, 401, 'mfa_pending token must be 401');
assert.equal(pending.nexted, false, 'must NOT call next() for an mfa_pending token');
// Contrast: a FULL token for the SAME user passes - so the user EXISTS in the db,
// which means removing the mfa_pending check would let the pending token through too
// (next() called). That's what makes this a real bite-test, not a vacuous 401.
const full = runRequireAuth(generateToken({ id: 'u1', email: 'u1@x', role: 'user' }, null));
assert.equal(full.nexted, true, 'a full token for the same user must pass requireAuth');
assert.equal(full.req.user.id, 'u1');
});
test('#100 lockout: locks after MAX_FAILS, lifts after the window, reset clears', () => {
const k = 'user-' + crypto.randomUUID();
for (let i = 0; i < totpLockout.MAX_FAILS - 1; i++) totpLockout.recordFailure(k, 1000);
assert.equal(totpLockout.isLocked(k, 1000), false);
totpLockout.recordFailure(k, 1000);
assert.equal(totpLockout.isLocked(k, 1000), true, 'locked at MAX_FAILS');
assert.equal(totpLockout.isLocked(k, 1000 + totpLockout.LOCKOUT_MS + 1), false, 'lifts after window');
totpLockout.reset(k);
assert.equal(totpLockout.isLocked(k, 1000), false, 'reset clears');
});
test('#100 replay: a TOTP code from an already-consumed step is rejected', () => {
const secret = totp.generateSecret();
const code = authenticator.generate(secret);
const step = totp.currentStep();
assert.equal(totp.verifyCode(code, secret, step - 1), step, 'fresh code accepted, returns the step');
assert.equal(totp.verifyCode(code, secret, step), null, 'same code at the consumed step is blocked');
});
test('#100 key-mismatch is graceful: decrypt failure -> null (no throw); verifyCode tolerates null', () => {
// If the secretbox key changes (rotated JWT_SECRET, non-persisted .jwt_secret), the
// stored TOTP secret becomes undecryptable. That must degrade, not 500.
assert.equal(totp.decryptSecret('!!!not-decryptable'), null, 'undecryptable -> null, not a throw');
assert.doesNotThrow(() => totp.verifyCode('123456', null, 0), 'null secret must not throw on the login path');
assert.equal(totp.verifyCode('123456', null, 0), null, 'null secret -> null (recovery path then handles it)');
});
test('#100 recovery codes: stored hashed, never plaintext; input normalized', () => {
const { plain, hashes } = totp.generateRecoveryCodes(10);
assert.equal(plain.length, 10);
assert.equal(hashes.length, 10);
assert.match(plain[0], /^[0-9A-F]{10}$/, 'plaintext is 10 hex chars (shown once)');
assert.match(hashes[0], /^[0-9a-f]{64}$/, 'stored value is a SHA-256 hash');
assert.notEqual(plain[0], hashes[0], 'the stored value is not the plaintext');
// typed with stray spaces/hyphens/lowercase still matches the stored hash
const messy = ' ' + plain[0].toLowerCase().slice(0, 5) + '-' + plain[0].toLowerCase().slice(5) + ' ';
assert.equal(totp.hashRecoveryCode(messy), hashes[0], 'normalized input matches');
});

111
server/test/totp.test.js Normal file
View file

@ -0,0 +1,111 @@
'use strict';
// #100 integration: boots the real server and drives the TOTP route flow end to end.
// /totp/verify completions use RECOVERY codes (deterministic) - the TOTP-code path +
// replay are covered in totp-unit.test.js (time-based codes are awkward over HTTP).
const { test, before, after } = require('node:test');
const assert = require('node:assert/strict');
const { spawn } = require('node:child_process');
const path = require('node:path');
const os = require('node:os');
const fs = require('node:fs');
const crypto = require('node:crypto');
const { authenticator } = require('otplib');
const PORT = 3979;
const BASE = `http://127.0.0.1:${PORT}`;
const DATA_DIR = path.join(os.tmpdir(), 'st-totp-test-' + crypto.randomBytes(4).toString('hex'));
const LOG = path.join(os.tmpdir(), 'st-totp-' + crypto.randomBytes(4).toString('hex') + '.log');
let proc;
async function jfetch(p, opts = {}) {
const res = await fetch(BASE + p, opts);
let body = null; try { body = await res.json(); } catch { /* non-JSON */ }
return { status: res.status, body };
}
const auth = (tok, extra = {}) => ({ headers: { Authorization: 'Bearer ' + tok, 'Content-Type': 'application/json', ...extra } });
const post = (tok, obj, extra) => ({ method: 'POST', ...auth(tok, extra), body: JSON.stringify(obj || {}) });
before(async () => {
const logFd = fs.openSync(LOG, 'w');
proc = spawn('node', ['server.js'], {
cwd: path.join(__dirname, '..'),
env: { ...process.env, DATA_DIR, SELF_HOSTED: 'true', PORT: String(PORT), NODE_ENV: 'test' },
stdio: ['ignore', logFd, logFd],
});
let up = false;
for (let i = 0; i < 80; i++) {
try { const r = await fetch(BASE + '/api/status'); if (r.ok) { up = true; break; } } catch { /* not yet */ }
await new Promise(r => setTimeout(r, 250));
}
if (!up) throw new Error('server did not boot:\n' + fs.readFileSync(LOG, 'utf8').slice(-2000));
});
after(() => { try { proc.kill('SIGKILL'); } catch { /* ignore */ } });
const PW = 'Passw0rd123';
async function newUser() {
const email = 'u' + crypto.randomBytes(5).toString('hex') + '@x.local';
const r = await jfetch('/api/auth/register', post(null, { email, password: PW }));
return { email, token: r.body.token };
}
async function enroll(token) {
const s = await jfetch('/api/auth/totp/setup', post(token, {}));
const e = await jfetch('/api/auth/totp/enable', post(token, { code: authenticator.generate(s.body.secret) }));
return { secret: s.body.secret, recovery: e.body.recovery_codes };
}
test('enrollment: setup -> enable issues 10 recovery codes; status reflects it', async () => {
const u = await newUser();
const { recovery } = await enroll(u.token);
assert.equal(recovery.length, 10);
const st = await jfetch('/api/auth/totp/status', auth(u.token));
assert.equal(st.body.enabled, true);
assert.equal(st.body.recovery_codes_remaining, 10);
});
test('login with TOTP -> mfa_required (no full token); route-level bite: mfa_token 401s a protected route', async () => {
const u = await newUser(); await enroll(u.token);
const login = await jfetch('/api/auth/login', post(null, { email: u.email, password: PW }));
assert.equal(login.body.mfa_required, true);
assert.ok(login.body.mfa_token, 'got an mfa_token');
assert.equal(login.body.token, undefined, 'NO full session token before the TOTP step');
const me = await jfetch('/api/auth/me', auth(login.body.mfa_token));
assert.equal(me.status, 401, 'mfa_pending token must 401 a protected route');
});
test('/totp/verify completes login via recovery code; single-use; surfaces remaining', async () => {
const u = await newUser(); const { recovery } = await enroll(u.token);
const l1 = await jfetch('/api/auth/login', post(null, { email: u.email, password: PW }));
const v1 = await jfetch('/api/auth/totp/verify', post(null, { mfa_token: l1.body.mfa_token, code: recovery[0] }));
assert.ok(v1.body.token, 'recovery code yields a full session token');
assert.equal(v1.body.via_recovery, true);
assert.equal(v1.body.recovery_codes_remaining, 9, 'one code consumed');
assert.equal((await jfetch('/api/auth/me', auth(v1.body.token))).status, 200, 'full token works');
// reuse the SAME recovery code -> rejected (single-use)
const l2 = await jfetch('/api/auth/login', post(null, { email: u.email, password: PW }));
const v2 = await jfetch('/api/auth/totp/verify', post(null, { mfa_token: l2.body.mfa_token, code: recovery[0] }));
assert.equal(v2.status, 401, 'used recovery code is rejected');
});
test('API token BYPASSES TOTP: an st_ token works while the owner has TOTP enabled', async () => {
const u = await newUser();
await enroll(u.token);
// the pre-existing session token (issued at register, before enroll) still works -
// enabling TOTP does NOT invalidate it - so it can mint an API token:
const t = await jfetch('/api/tokens', post(u.token, { name: 'ci', scope: 'read' }));
const secret = Object.values(t.body || {}).find(v => typeof v === 'string' && v.startsWith('st_'));
assert.ok(secret, 'got an st_ token (existing JWT still valid post-enroll)');
const r = await jfetch('/api/devices', auth(secret));
assert.equal(r.status, 200, 'st_ token reaches a protected route despite TOTP being on');
});
test('verify lockout: repeated bad codes -> 429 (per-user, atop the route rate-limit)', async () => {
const u = await newUser(); await enroll(u.token);
const mfa = (await jfetch('/api/auth/login', post(null, { email: u.email, password: PW }))).body.mfa_token;
let last;
for (let i = 0; i < 6; i++) {
last = await jfetch('/api/auth/totp/verify', post(null, { mfa_token: mfa, code: '000000' }));
}
assert.equal(last.status, 429, 'locked out after repeated bad codes');
});