diff --git a/app/server/src/auth/__tests__/totp.test.ts b/app/server/src/auth/__tests__/totp.test.ts new file mode 100644 index 0000000..66b4b7f --- /dev/null +++ b/app/server/src/auth/__tests__/totp.test.ts @@ -0,0 +1,60 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { totp, generateBase32Secret, verifyTotp, otpauthUrl } from "../totp.ts"; + +describe("TOTP (RFC 6238)", () => { + const secret = generateBase32Secret(); + + it("generateBase32Secret produces a base32 string for a 20-byte secret", () => { + // 20 bytes × 8 / 5 bits = 32 base32 chars, no padding needed. + assert.equal(secret.length, 32); + assert.match(secret, /^[A-Z2-7=]+$/); + }); + + it("totp returns exactly 6 digits", () => { + const code = totp(secret); + assert.match(code, /^\d{6}$/); + }); + + it("totp with fixed time is deterministic — same call → same code", () => { + const fixedTime = 1_700_000_000; + const a = totp(secret, fixedTime); + const b = totp(secret, fixedTime); + assert.equal(a, b); + }); + + it("verifyTotp accepts a freshly generated code", () => { + // Use a specific time so we can verify deterministically. + const t = Math.floor(Date.now() / 1000); + const expected = totp(secret, t); + assert.equal(verifyTotp(expected, secret), true); + }); + + it('verifyTotp("000000", secret) returns false', () => { + assert.equal(verifyTotp("000000", secret), false); + }); + + it("verifyTotp code from ±1 step away is still accepted (default window=1)", () => { + const now = Math.floor(Date.now() / 1000); + const prevCode = totp(secret, now - 30); // one step back + const nextCode = totp(secret, now + 30); // one step forward + + assert.equal(verifyTotp(prevCode, secret), true, "step-1 code should verify"); + assert.equal(verifyTotp(nextCode, secret), true, "step+1 code should verify"); + }); + + it("otpauthUrl builds a valid URI", () => { + const url = otpauthUrl(secret, "MyApp", "user@example.com"); + assert.ok(url.startsWith("otpauth://TOTP/")); + assert.match(url, /&issuer=MyApp/); + assert.match(url, /issuer=MyApp/); + }); + + it("generateBase32Secret produces unique secrets each call", () => { + const seen = new Set(); + for (let i = 0; i < 50; i++) { + seen.add(generateBase32Secret()); + } + assert.equal(seen.size, 50); + }); +}); diff --git a/app/server/src/auth/totp.ts b/app/server/src/auth/totp.ts new file mode 100644 index 0000000..9fdfd5c --- /dev/null +++ b/app/server/src/auth/totp.ts @@ -0,0 +1,150 @@ +// RFC 6238 TOTP — pure functions, ZERO external deps (node:crypto only). +import { createHmac, randomFillSync } from "node:crypto"; + + +// ── Helpers ────────────────────────────────────────────────────────────────── + +const BASE32_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; + +function decodeBase32(input: string): Uint8Array { + const cleaned = input.toUpperCase().replace(/[^A-Z2-7=]/g, ""); + + // Map each character to its 5-bit value. + const bits: number[] = []; + for (const ch of cleaned) { + if (ch === "=") continue; // padding — skip + const idx = BASE32_ALPHABET.indexOf(ch); + if (idx === -1) continue; + bits.push(idx); + } + + const byteCount = Math.floor(bits.length * 5 / 8); + const out = new Uint8Array(byteCount); + let bitIdx = 0; + for (let i = 0; i < out.length; i++) { + let byte = 0; + for (let b = 7; b >= 0; b--) { + const val = bits[bitIdx++]; + if (val !== undefined) byte |= ((val >> b) & 1) << b; + } + out[i] = byte; + } + return out; +} + +function encodeBase32(bytes: Uint8Array): string { + const chars: string[] = []; + let buffer = 0; + let bitsInBuffer = 0; + + for (const b of bytes) { + buffer = (buffer << 8) | b; + bitsInBuffer += 8; + while (bitsInBuffer >= 5) { + bitsInBuffer -= 5; + chars.push(BASE32_ALPHABET[(buffer >> bitsInBuffer) & 0x1F]); + } + } + + if (bitsInBuffer > 0) { + chars.push(BASE32_ALPHABET[(buffer << (5 - bitsInBuffer)) & 0x1F]); + } + + // Add padding to reach multiple of 8 characters. + while (chars.length % 8 !== 0) { + chars.push("="); + } + return chars.join(""); +} + +function counterToBytes(counter: number): Uint8Array { + const buf = new Uint8Array(8); + buf[0] = (counter >>> 56) & 0xFF; + buf[1] = (counter >>> 48) & 0xFF; + buf[2] = (counter >>> 40) & 0xFF; + buf[3] = (counter >>> 32) & 0xFF; + buf[4] = (counter >>> 24) & 0xFF; + buf[5] = (counter >>> 16) & 0xFF; + buf[6] = (counter >>> 8) & 0xFF; + buf[7] = counter & 0xFF; + return buf; +} + +// ── Public API ─────────────────────────────────────────────────────────────── + +/** Generate a random 20-byte TOTP secret encoded as base32. */ +export function generateBase32Secret(): string { + const raw = new Uint8Array(20); + randomFillSync(raw); + return encodeBase32(raw); +} + +/** + * Compute a 6-digit TOTP code for the given base32 secret. + * `time` defaults to the current Unix timestamp (seconds). + */ +export function totp(secret: string, time?: number): string { + const bytes = decodeBase32(secret); + const counter = time === undefined + ? Math.floor(Date.now() / 1000) + : Math.floor(time); + + const hmac = computeHmacSha1(bytes, counterToBytes(counter)); + const offset = hmac[hmac.length - 1] & 0x0F; + const code = (hmac[offset] & 0x7F) << 24 + | hmac[offset + 1] << 16 + | hmac[offset + 2] << 8 + | hmac[offset + 3]; + + const digits = Math.pow(10, 6); + return String(code % digits).padStart(6, "0"); +} + +export interface VerifyOptions { + /** How many steps back/forward to check. Default: 1 (±1 × period). */ + window?: number; +} + +/** Verify a token against the secret within an optional time-window. */ +export function verifyTotp( + token: string, + secret: string, + opts?: VerifyOptions, +): boolean { + if (!/^\d{6}$/.test(token)) return false; + + const window = opts?.window ?? 1; + const now = Math.floor(Date.now() / 1000); + + for (let i = -window; i <= window; i++) { + if (totp(secret, now + i * 30) === token) { + return true; + } + } + return false; +} + +/** Build an otpauth:// URI for QR-code provisioning. */ +export function otpauthUrl( + secret: string, + issuer: string, + account: string, +): string { + const query = new URLSearchParams({ + secret, + issuer, + algorithm: "SHA1", + digits: "6", + period: "30", + }); + return `otpauth://TOTP/${encodeURIComponent(issuer)}:${encodeURIComponent(account)}?${query.toString()}`; +} + +// ── Internal ───────────────────────────────────────────────────────────────── + +function computeHmacSha1(key: Uint8Array, data: Uint8Array): Uint8Array { + const hmac = createHmac("sha1", key); + hmac.update(data); + const buf = hmac.digest(); + return new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength); +}