Files
site-gateway/src/totp.js
T

75 lines
3.1 KiB
JavaScript

import crypto from "node:crypto";
// Minimal RFC 4648 base32 (no padding), and RFC 6238 TOTP on top of RFC 4226 HOTP.
// Implemented against Node's built-in crypto only — no third-party dependency.
const BASE32_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
export function base32Encode(buffer) {
let bits = "";
for (const byte of buffer) bits += byte.toString(2).padStart(8, "0");
let output = "";
for (let i = 0; i + 5 <= bits.length; i += 5) output += BASE32_ALPHABET[parseInt(bits.slice(i, i + 5), 2)];
const remainder = bits.length % 5;
if (remainder) output += BASE32_ALPHABET[parseInt(bits.slice(bits.length - remainder).padEnd(5, "0"), 2)];
return output;
}
export function base32Decode(value) {
const cleaned = String(value || "").toUpperCase().replace(/[^A-Z2-7]/g, "");
let bits = "";
for (const char of cleaned) {
const index = BASE32_ALPHABET.indexOf(char);
if (index < 0) continue;
bits += index.toString(2).padStart(5, "0");
}
const bytes = [];
for (let i = 0; i + 8 <= bits.length; i += 8) bytes.push(parseInt(bits.slice(i, i + 8), 2));
return Buffer.from(bytes);
}
export function generateTotpSecret() {
return base32Encode(crypto.randomBytes(20)); // 160-bit key, standard for authenticator apps
}
function hotp(secretBuffer, counter, digits = 6) {
const counterBuffer = Buffer.alloc(8);
counterBuffer.writeBigUInt64BE(BigInt(counter));
const hmac = crypto.createHmac("sha1", secretBuffer).update(counterBuffer).digest();
const offset = hmac[hmac.length - 1] & 0x0f;
const binary = ((hmac[offset] & 0x7f) << 24) | ((hmac[offset + 1] & 0xff) << 16) | ((hmac[offset + 2] & 0xff) << 8) | (hmac[offset + 3] & 0xff);
return String(binary % 10 ** digits).padStart(digits, "0");
}
export function totpAt(base32Secret, forTime = Date.now(), step = 30, digits = 6) {
const counter = Math.floor(forTime / 1000 / step);
return hotp(base32Decode(base32Secret), counter, digits);
}
// Accepts a code from the current step or one step on either side, to tolerate normal clock drift.
export function verifyTotp(base32Secret, code, { step = 30, digits = 6, window = 1, forTime = Date.now() } = {}) {
const candidate = String(code || "").trim().replace(/\s+/g, "");
if (!/^\d{6,8}$/.test(candidate)) return false;
const secretBuffer = base32Decode(base32Secret);
const baseCounter = Math.floor(forTime / 1000 / step);
for (let offset = -window; offset <= window; offset++) {
const expected = hotp(secretBuffer, baseCounter + offset, digits);
if (crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(candidate.padStart(digits, "0")))) return true;
}
return false;
}
export function otpauthUri({ secret, username, issuer = "Site Gateway" }) {
const label = `${encodeURIComponent(issuer)}:${encodeURIComponent(username)}`;
return `otpauth://totp/${label}?secret=${secret}&issuer=${encodeURIComponent(issuer)}&algorithm=SHA1&digits=6&period=30`;
}
export function generateRecoveryCodes(count = 10) {
const codes = [];
for (let i = 0; i < count; i++) {
const raw = crypto.randomBytes(5).toString("hex").toUpperCase(); // 10 hex chars
codes.push(`${raw.slice(0, 5)}-${raw.slice(5, 10)}`);
}
return codes;
}