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:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user