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