slice 2a (ornith): TOTP core (RFC 6238, zero-dep) + 8 tests

First ornith remote-model dispatch. Right-sized single-file prompt -> ornith produced
correct totp.ts (base32/HMAC-SHA1/dynamic-truncation/+-1 window), reviewed + approved.
44/44 backend tests green. Ornith ~8min/dispatch; small focused prompts succeed.
This commit is contained in:
Investor Flow Build
2026-06-29 19:14:21 -04:00
parent a8b95e36e3
commit 3b6d306e4c
2 changed files with 210 additions and 0 deletions
@@ -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<string>();
for (let i = 0; i < 50; i++) {
seen.add(generateBase32Secret());
}
assert.equal(seen.size, 50);
});
});
+150
View File
@@ -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);
}