import { createHash, createHmac, randomBytes } from "node:crypto";
// Time-based one-time passwords (RFC 6238) for authenticator apps: HMAC-SHA1, 6 digits, a new code
// every 30 seconds. The secret is shared once, as a QR code of an otpauth:// URI.
const BASE32 = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
const PERIOD = 30;
const DIGITS = 6;
export function base32Encode(bytes: Uint8Array) {
let out = "";
let bits = 0;
let value = 0;
for (const byte of bytes) {
value = (value << 8) | byte;
bits += 8;
while (bits >= 5) {
out += BASE32[(value >>> (bits - 5)) & 31];
bits -= 5;
}
}
if (bits > 0) out += BASE32[(value << (5 - bits)) & 31];
return out;
}
export function base32Decode(text: string) {
const clean = text.toUpperCase().replace(/[\s=-]/g, "");
const bytes: number[] = [];
let bits = 0;
let value = 0;
for (const char of clean) {
const index = BASE32.indexOf(char);
if (index < 0) throw new Error("Invalid base32.");
value = (value << 5) | index;
bits += 5;
if (bits >= 8) {
bytes.push((value >>> (bits - 8)) & 255);
bits -= 8;
}
}
return Buffer.from(bytes);
}
/** A new secret: 160 random bits, the size RFC 4226 recommends for SHA-1. */
export const newTotpSecret = () => base32Encode(randomBytes(20));
/** The HOTP code (RFC 4226) for a counter. */
export function hotp(key: Uint8Array, counter: number, digits = DIGITS) {
const message = Buffer.alloc(8);
message.writeUInt32BE(Math.floor(counter / 2 ** 32), 0);
message.writeUInt32BE(counter >>> 0, 4);
const mac = createHmac("sha1", key).update(message).digest();
const offset = mac[mac.length - 1] & 15;
const number = mac.readUInt32BE(offset) & 0x7fffffff;
return String(number % 10 ** digits).padStart(digits, "0");
}
export const totpStep = (now = Date.now()) => Math.floor(now / 1000 / PERIOD);
/**
* The time step a code belongs to, or null if it's wrong. Codes from the step before and after
* count too, for clocks that are a little off and codes typed just as they changed.
*/
export function matchTotp(secret: string, code: string, now = Date.now()) {
if (!/^\d{6}$/.test(code)) return null;
const key = base32Decode(secret);
const current = totpStep(now);
for (const step of [current, current - 1, current + 1]) {
if (hotp(key, step) === code) return step;
}
return null;
}
/** What authenticator apps scan: otpauth://totp/Issuer:account?secret=…&issuer=Issuer */
export function otpauthUri(secret: string, issuer: string, account: string) {
const label = encodeURIComponent(`${issuer}:${account}`);
const params = new URLSearchParams({ secret, issuer, algorithm: "SHA1", digits: String(DIGITS), period: String(PERIOD) });
return `otpauth://totp/${label}?${params}`;
}
/** The secret in groups of four, easier to type into an app by hand. */
export const groupSecret = (secret: string) => secret.match(/.{1,4}/g)?.join(" ") ?? secret;
/** What the user typed, without the spaces and hyphens people add, lowercased. */
export const normalizeCode = (input: string) => input.replace(/[\s-]/g, "").toLowerCase();
/** One-time recovery codes, like "k3m9q-7xw2p": 50 random bits each. */
export function newRecoveryCodes(count = 10) {
return Array.from({ length: count }, () => {
const code = base32Encode(randomBytes(7)).slice(0, 10).toLowerCase();
return `${code.slice(0, 5)}-${code.slice(5)}`;
});
}
/** Recovery codes are stored as hashes of their normalized form. */
export const hashRecoveryCode = (code: string) => createHash("sha256").update(normalizeCode(code)).digest("hex");