diff --git a/server/test/totp-keyrotation.test.js b/server/test/totp-keyrotation.test.js new file mode 100644 index 0000000..5d63e8a --- /dev/null +++ b/server/test/totp-keyrotation.test.js @@ -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 */ } + } +}); diff --git a/server/test/totp-unit.test.js b/server/test/totp-unit.test.js new file mode 100644 index 0000000..cc53280 --- /dev/null +++ b/server/test/totp-unit.test.js @@ -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'); +}); diff --git a/server/test/totp.test.js b/server/test/totp.test.js new file mode 100644 index 0000000..c2de1c5 --- /dev/null +++ b/server/test/totp.test.js @@ -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'); +});