// ============================================================ // SIGN-IN CODES // ============================================================ // A code emailed to you, offered beside the password rather than instead of it. // The rules here are what keep it from being a second, weaker front door. const test = require('node:test'); const assert = require('node:assert'); const fs = require('fs'); const path = require('path'); const lc = require('../src/utils/loginCodes'); const read = p => fs.readFileSync(path.join(__dirname, '..', p), 'utf8'); test('codes are six digits and uniformly drawn', () => { const seen = new Set(); for (let i = 0; i < 3000; i++) { const code = lc.generate(); assert.match(code, /^\d{6}$/); seen.add(code); } // Rejection sampling, not modulo on a random byte, which would make low // digits likelier. assert.match(read('src/utils/loginCodes.js'), /while \(value >= Math\.floor\(0xFFFFFFFF \/ max\) \* max\)/); assert.ok(seen.size > 2800, 'not repeating itself'); assert.equal(lc.normalise(' 12 34-56 '), '123456'); }); test('a code is stored as a hash, used once, and superseded by the next', () => { const src = read('src/utils/loginCodes.js'); // A code read out of the database must not be a working credential. assert.match(src, /bcrypt\.hash\(code, 10\)/); // The value bound to code_hash is the hash, never the code itself. assert.match(src, /\[userId, await bcrypt\.hash\(code, 10\)\]/); assert.doesNotMatch(src, /\[userId, code\]/, 'the plaintext is never inserted'); // Asking for a new one must not leave the old one working. assert.match(src, /DELETE FROM login_codes WHERE user_id = \?/); // Marked used before a session is issued, so a replay cannot race it. assert.match(src, /UPDATE login_codes SET used_at = NOW\(\) WHERE id = \? AND used_at IS NULL RETURNING id/); assert.match(src, /expires_at > NOW\(\)/); assert.match(src, /row\.attempts >= MAX_ATTEMPTS/); assert.equal(lc.MAX_ATTEMPTS, 5); assert.equal(lc.TTL_MINUTES, 10); }); test('neither endpoint says whether an account exists', () => { const route = read('src/routes/auth.js'); // A sign-in screen that answers "no such account" is a way of finding out who // has one, which is worth more to an attacker than the code is. assert.match(route, /var GENERIC = \{ success: true, message: 'If that account exists, a code is on its way\.' \};/); assert.match(route, /if \(!user \|\| user\.disabled\) \{\s*\n\s*console\.warn\('\[Auth\] login-code: no deliverable account/); // One refusal for every failure on verify, too. assert.match(route, /var REFUSED = \{ error: 'That code is not valid\./); // A code proves you can read the mailbox. An account that asked for a second // factor still wants it. assert.match(route, /if \(user\.totp_enabled\) \{\s*\n\s*if \(!totpCode\) return res\.json\(\{ requires2FA: true \}\);/); }); test('the new endpoints are rate limited, and reachable while signed out', () => { const server = read('server.js'); // Express matches app.use paths on segment boundaries, so the /api/auth/login // limiter does not cover /api/auth/login-code — verified against a real // router, not assumed. assert.match(server, /app\.use\('\/api\/auth\/login-code\/request', rateLimit\(\{/); assert.match(server, /app\.use\('\/api\/auth\/login-code\/verify', rateLimit\(\{/); // Asking for a code sends mail to someone else's address, so it is limited // more tightly than an attempt to sign in. assert.match(server, /LOGIN_CODE_RATE_LIMIT_MAX \|\| '5'/); // authFetch rejects any /api path not on its allowlist before it is sent, // which surfaced as "Connection error" with no request leaving the browser. const wrapper = read('public/js/authFetch.js'); assert.match(wrapper, /'\/api\/auth\/login-code\/request', '\/api\/auth\/login-code\/verify'/); assert.match(wrapper, /url\.pathname === '\/api\/auth\/login-code\/verify'/, 'and it counts as a login'); }); test('the screen asks for the email first and always offers both ways in', () => { const html = read('public/index.html'); assert.match(html, /id="btn-login-continue"/); assert.match(html, /id="btn-login-send-code"/); assert.match(html, /id="btn-login-use-password"/); assert.match(html, /id="login-change-email"/); // Creating an account stays on the first step. assert.match(html, /id="show-register"/); const js = read('public/js/auth.js'); // A code needs mail to arrive and a password does not, so the password option // sits beside the code option rather than behind it. assert.match(js, /reveal\('login-choice', step === 'choice'\)/); assert.match(js, /loginMode === 'code' \? submitCode : submitLogin/); // classList, not a string replace: hiding twice appended 'hidden' twice and a // non-global replace stripped only one, so the element stayed hidden forever. assert.match(js, /if \(on\) el\.classList\.remove\('hidden'\); else el\.classList\.add\('hidden'\);/); // One response handler, so a code sign-in gets the same 2FA prompt and the // same welcome as a password one. assert.match(js, /function handleSignIn\(data, attempt\)/); });