Optional time-based 2FA on top of password login. TOTP core is hand-rolled
on node:crypto (RFC 6238) — no runtime dep — and verified against the RFC
test vectors.
- migration 027: users.totp_secret/totp_enabled + user_recovery_codes
- src/auth/totp.js: base32, secret gen, RFC 6238 verify, otpauth URI,
recovery codes
- src/auth/mfa-tickets.js: short-lived single-use tickets bridging the two
login steps (in-memory, single-instance like the rate-limiter)
- auth routes: /totp/setup, /totp/enable (returns recovery codes once),
/totp/disable (password-confirmed); login returns {mfa_required, ticket}
when enabled, /login/totp completes with a code or recovery code
- /auth/me and loadUser surface totp_enabled
- web-ui: login second-factor step; Settings -> Account TOTP enroll (QR +
manual secret + recovery codes + disable)
- qrcode added as an optional dep; setup degrades to manual entry if absent
- tests: totp unit (RFC vectors) + integration (enable/login/recovery/disable)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
82 lines
3.9 KiB
JavaScript
82 lines
3.9 KiB
JavaScript
import { test } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import {
|
|
base32Encode, base32Decode, generateSecret, generateToken,
|
|
verifyToken, otpauthURI, generateRecoveryCodes,
|
|
} from '../../src/auth/totp.js';
|
|
|
|
// ── base32 round-trips ──────────────────────────────────────────────────────
|
|
test('base32 encode/decode round-trips arbitrary bytes', () => {
|
|
for (const s of ['', 'f', 'fo', 'foo', 'foob', 'fooba', 'foobar']) {
|
|
const buf = Buffer.from(s);
|
|
assert.deepEqual(base32Decode(base32Encode(buf)), buf);
|
|
}
|
|
});
|
|
|
|
// ── RFC 6238 Appendix B test vectors (SHA-1, 8 digits in the RFC; we use the
|
|
// low 6 here, so compare the last 6 digits of each published value). ──────────
|
|
// The RFC uses the ASCII secret "12345678901234567890". We base32-encode it and
|
|
// check the 6-digit code at each published timestamp.
|
|
test('matches RFC 6238 SHA-1 vectors (low 6 digits)', () => {
|
|
const secret = base32Encode(Buffer.from('12345678901234567890'));
|
|
// [unix seconds, full 8-digit code from the RFC] → expect last 6 digits.
|
|
const vectors = [
|
|
[59, '94287082'],
|
|
[1111111109, '07081804'],
|
|
[1111111111, '14050471'],
|
|
[1234567890, '89005924'],
|
|
[2000000000, '69279037'],
|
|
[20000000000, '65353130'],
|
|
];
|
|
for (const [secs, full8] of vectors) {
|
|
const got = generateToken(secret, secs * 1000);
|
|
assert.equal(got, full8.slice(-6), `t=${secs}`);
|
|
}
|
|
});
|
|
|
|
// ── verify with drift window ────────────────────────────────────────────────
|
|
test('verifyToken accepts the current code and ±1 step of drift', () => {
|
|
const secret = generateSecret();
|
|
const now = 1_700_000_000_000;
|
|
const code = generateToken(secret, now);
|
|
assert.equal(verifyToken(secret, code, now), true);
|
|
// 30s earlier / later still inside ±1 window.
|
|
assert.equal(verifyToken(secret, code, now + 30_000), true);
|
|
assert.equal(verifyToken(secret, code, now - 30_000), true);
|
|
// 2 steps away → rejected.
|
|
assert.equal(verifyToken(secret, code, now + 90_000), false);
|
|
});
|
|
|
|
test('verifyToken rejects malformed / empty input without throwing', () => {
|
|
const secret = generateSecret();
|
|
assert.equal(verifyToken(secret, ''), false);
|
|
assert.equal(verifyToken(secret, 'abcdef'), false);
|
|
assert.equal(verifyToken(secret, '12345'), false); // too short
|
|
assert.equal(verifyToken(secret, '1234567'), false); // too long
|
|
assert.equal(verifyToken('', '123456'), false);
|
|
});
|
|
|
|
test('verifyToken tolerates spaces in the user-entered code', () => {
|
|
const secret = generateSecret();
|
|
const now = 1_700_000_000_000;
|
|
const code = generateToken(secret, now);
|
|
assert.equal(verifyToken(secret, code.slice(0, 3) + ' ' + code.slice(3), now), true);
|
|
});
|
|
|
|
// ── otpauth URI ─────────────────────────────────────────────────────────────
|
|
test('otpauthURI embeds secret, issuer, and account', () => {
|
|
const uri = otpauthURI('JBSWY3DPEHPK3PXP', 'alice', 'Dragonflight');
|
|
assert.match(uri, /^otpauth:\/\/totp\/Dragonflight%3Aalice\?/);
|
|
assert.match(uri, /secret=JBSWY3DPEHPK3PXP/);
|
|
assert.match(uri, /issuer=Dragonflight/);
|
|
assert.match(uri, /digits=6/);
|
|
assert.match(uri, /period=30/);
|
|
});
|
|
|
|
// ── recovery codes ──────────────────────────────────────────────────────────
|
|
test('generateRecoveryCodes returns N distinct formatted codes', () => {
|
|
const codes = generateRecoveryCodes(10);
|
|
assert.equal(codes.length, 10);
|
|
assert.equal(new Set(codes).size, 10);
|
|
for (const c of codes) assert.match(c, /^[0-9a-f]{5}-[0-9a-f]{5}$/);
|
|
});
|