dragonflight/services/mam-api/src/auth/totp.js
Zac 72fc608d8a fix(mam-api): harden TOTP login flow + tighten Google domain check
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>
2026-05-30 12:52:53 +00:00

118 lines
4.3 KiB
JavaScript

// TOTP (RFC 6238) implemented on node:crypto — no runtime dependency.
//
// Why hand-rolled: the algorithm is small and stable, and avoiding a dep keeps
// the auth core auditable. Verified against the RFC 6238 Appendix B test vectors
// in test/auth/totp.test.js.
//
// Defaults match every mainstream authenticator app (Google Authenticator,
// Authy, 1Password): SHA-1, 6 digits, 30-second step.
import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
const DIGITS = 6;
const STEP_SECONDS = 30;
const RFC4648_B32 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
// ── base32 (RFC 4648, no padding) ──────────────────────────────────────────
export function base32Encode(buf) {
let bits = 0, value = 0, out = '';
for (const byte of buf) {
value = (value << 8) | byte;
bits += 8;
while (bits >= 5) {
out += RFC4648_B32[(value >>> (bits - 5)) & 31];
bits -= 5;
}
}
if (bits > 0) out += RFC4648_B32[(value << (5 - bits)) & 31];
return out;
}
export function base32Decode(str) {
const clean = str.replace(/=+$/,'').toUpperCase().replace(/\s+/g, '');
let bits = 0, value = 0;
const out = [];
for (const ch of clean) {
const idx = RFC4648_B32.indexOf(ch);
if (idx === -1) continue; // skip stray chars
value = (value << 5) | idx;
bits += 5;
if (bits >= 8) {
out.push((value >>> (bits - 8)) & 0xff);
bits -= 8;
}
}
return Buffer.from(out);
}
// Generate a new base32 secret (20 random bytes = 160 bits, the RFC-recommended
// SHA-1 key length).
export function generateSecret() {
return base32Encode(randomBytes(20));
}
// HOTP for a specific counter (RFC 4226).
function hotp(secretBuf, counter) {
const buf = Buffer.alloc(8);
// 64-bit big-endian counter.
buf.writeUInt32BE(Math.floor(counter / 0x100000000), 0);
buf.writeUInt32BE(counter >>> 0, 4);
const hmac = createHmac('sha1', secretBuf).update(buf).digest();
const offset = hmac[hmac.length - 1] & 0x0f;
const code = ((hmac[offset] & 0x7f) << 24)
| ((hmac[offset + 1] & 0xff) << 16)
| ((hmac[offset + 2] & 0xff) << 8)
| (hmac[offset + 3] & 0xff);
return String(code % (10 ** DIGITS)).padStart(DIGITS, '0');
}
// The TOTP code for a given time (defaults to now).
export function generateToken(base32Secret, atMs = Date.now()) {
const counter = Math.floor(atMs / 1000 / STEP_SECONDS);
return hotp(base32Decode(base32Secret), counter);
}
// Verify a user-supplied code, allowing ±`window` steps of clock drift
// (default ±1 = 90s total tolerance). Constant-time compare per candidate.
//
// Returns the matched counter on success (so callers can persist it for
// replay protection — RFC 6238 §5.2), or null on failure. Boolean truthiness
// still works for the common case (`if (verifyToken(...))`).
export function verifyToken(base32Secret, token, atMs = Date.now(), window = 1) {
if (!base32Secret || !token) return null;
const cleaned = String(token).replace(/\s+/g, '');
if (!/^\d{6}$/.test(cleaned)) return null;
const secretBuf = base32Decode(base32Secret);
const counter = Math.floor(atMs / 1000 / STEP_SECONDS);
const want = Buffer.from(cleaned);
for (let w = -window; w <= window; w++) {
const candidate = Buffer.from(hotp(secretBuf, counter + w));
if (candidate.length === want.length && timingSafeEqual(candidate, want)) return counter + w;
}
return null;
}
// The otpauth:// URI an authenticator app scans. label/issuer show in the app.
export function otpauthURI(base32Secret, accountName, issuer = 'Dragonflight') {
const label = encodeURIComponent(`${issuer}:${accountName}`);
const params = new URLSearchParams({
secret: base32Secret,
issuer,
algorithm: 'SHA1',
digits: String(DIGITS),
period: String(STEP_SECONDS),
});
return `otpauth://totp/${label}?${params.toString()}`;
}
// Generate N human-friendly one-time recovery codes (raw form). Caller hashes
// them before storage and shows the raw set to the user exactly once.
export function generateRecoveryCodes(n = 10) {
const codes = [];
for (let i = 0; i < n; i++) {
// 10 hex chars in two dash-separated groups, e.g. "a1b2c-3d4e5".
const hex = randomBytes(5).toString('hex');
codes.push(hex.slice(0, 5) + '-' + hex.slice(5));
}
return codes;
}