mirror of
https://github.com/screentinker/screentinker.git
synced 2026-06-18 20:22:42 -06:00
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:
parent
1d3e9acea4
commit
728f03beba
77
server/test/totp-keyrotation.test.js
Normal file
77
server/test/totp-keyrotation.test.js
Normal 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 */ }
|
||||
}
|
||||
});
|
||||
85
server/test/totp-unit.test.js
Normal file
85
server/test/totp-unit.test.js
Normal 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
111
server/test/totp.test.js
Normal 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');
|
||||
});
|
||||
Loading…
Reference in a new issue