Review of the v2 auth landing turned up four weak spots in the MFA path. All four are now fixed; behaviour is unchanged for the password-correct + correct-TOTP happy path. 1. TOTP brute-force gate (the big one). /login was calling ipBackoff.recordSuccess(ip) the instant the password hashed correctly, *before* the second factor was proven. That cleared the per-IP failure counter, so each /login retry let an attacker with a known password hammer the 6-digit /login/totp space (10^6) at full speed. Now recordSuccess fires only inside establishSession() — i.e. after every required factor has actually passed (password [+TOTP] or OAuth [+TOTP]). 2. MFA ticket binding. Tickets issued by /login (and the Google callback) were unbound — a stolen ticket replayed from a different origin still worked. Tickets now carry SHA-256 hashes of the issuing request's IP and User-Agent; redeemTicket rejects on mismatch. The ticket is burned even on mismatch so a wrong-binding probe can't be retried. 3. TOTP replay within the same 30s step (RFC 6238 §5.2). The verifier accepted the same code as many times as you submitted it. Now verifyToken returns the matched counter, and /login/totp does a CAS UPDATE on users.totp_last_counter — codes at counters <= the last accepted value are rejected. New migration 030 adds totp_last_counter, seeded on /totp/enable so the enrollment code itself can't be reused at first login, and zeroed on /totp/disable. 4. Google OAuth domain check no longer falls back to the email suffix when the hd (hosted-domain) claim is missing. Email-suffix matching let consumer (non-Workspace) Google accounts whose email happens to end in the allowed domain through; if GOOGLE_ALLOWED_DOMAIN is set, the operator means "only this Workspace", so accounts without a verified hd must be rejected. Tests: new mfa-tickets.test.js covers ip/UA binding, single-use on mismatch, and bindings-absent back-compat. totp.test.js updated for the new verifyToken return shape (counter on success, null on failure; truthiness still works at call sites) and adds an explicit matched-counter check. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
96 lines
4.6 KiB
JavaScript
96 lines
4.6 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 ────────────────────────────────────────────────
|
|
// verifyToken returns the matched counter (truthy) or null (falsy).
|
|
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);
|
|
const baseCounter = Math.floor(now / 1000 / 30);
|
|
assert.equal(verifyToken(secret, code, now), baseCounter);
|
|
// 30s earlier / later still inside ±1 window — the *issued* code matches the
|
|
// baseCounter, but at now+30s we're in step baseCounter+1, so the issued
|
|
// code matches as drift = -1 step → returns baseCounter.
|
|
assert.equal(verifyToken(secret, code, now + 30_000), baseCounter);
|
|
assert.equal(verifyToken(secret, code, now - 30_000), baseCounter);
|
|
// 2 steps away → rejected.
|
|
assert.equal(verifyToken(secret, code, now + 90_000), null);
|
|
});
|
|
|
|
test('verifyToken rejects malformed / empty input without throwing', () => {
|
|
const secret = generateSecret();
|
|
assert.equal(verifyToken(secret, ''), null);
|
|
assert.equal(verifyToken(secret, 'abcdef'), null);
|
|
assert.equal(verifyToken(secret, '12345'), null); // too short
|
|
assert.equal(verifyToken(secret, '1234567'), null); // too long
|
|
assert.equal(verifyToken('', '123456'), null);
|
|
});
|
|
|
|
test('verifyToken tolerates spaces in the user-entered code', () => {
|
|
const secret = generateSecret();
|
|
const now = 1_700_000_000_000;
|
|
const code = generateToken(secret, now);
|
|
const expected = Math.floor(now / 1000 / 30);
|
|
assert.equal(verifyToken(secret, code.slice(0, 3) + ' ' + code.slice(3), now), expected);
|
|
});
|
|
|
|
test('verifyToken returns the matched counter (for replay protection)', () => {
|
|
const secret = generateSecret();
|
|
const now = 1_700_000_000_000;
|
|
const code = generateToken(secret, now);
|
|
const counter = verifyToken(secret, code, now);
|
|
assert.ok(typeof counter === 'number' && counter > 0);
|
|
assert.equal(counter, Math.floor(now / 1000 / 30));
|
|
});
|
|
|
|
// ── 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}$/);
|
|
});
|