// 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; }