187 lines
6.2 KiB
TypeScript
187 lines
6.2 KiB
TypeScript
import { constantTimeEqual } from "../crypto.ts";
|
|
|
|
const ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
|
|
const MIN_DIGITS = 6;
|
|
const MAX_DIGITS = 10;
|
|
const MAX_WINDOW = 20;
|
|
|
|
function assertPeriod(period: number): number {
|
|
if (!Number.isInteger(period) || period <= 0 || period > 86_400) {
|
|
throw new RangeError("TOTP period must be an integer between 1 and 86400 seconds");
|
|
}
|
|
return period;
|
|
}
|
|
|
|
function assertDigits(digits: number): number {
|
|
if (!Number.isInteger(digits) || digits < MIN_DIGITS || digits > MAX_DIGITS) {
|
|
throw new RangeError(`TOTP digits must be an integer between ${MIN_DIGITS} and ${MAX_DIGITS}`);
|
|
}
|
|
return digits;
|
|
}
|
|
|
|
function assertTimestamp(timestamp: number): number {
|
|
if (!Number.isFinite(timestamp) || timestamp < 0) {
|
|
throw new RangeError("TOTP timestamp must be a finite non-negative number");
|
|
}
|
|
return timestamp;
|
|
}
|
|
|
|
function assertWindow(window: number): number {
|
|
if (!Number.isInteger(window) || window < 0 || window > MAX_WINDOW) {
|
|
throw new RangeError(`TOTP window must be an integer between 0 and ${MAX_WINDOW}`);
|
|
}
|
|
return window;
|
|
}
|
|
|
|
export function encodeBase32(bytes: Uint8Array): string {
|
|
let bits = 0;
|
|
let value = 0;
|
|
let output = "";
|
|
for (const byte of bytes) {
|
|
value = (value << 8) | byte;
|
|
bits += 8;
|
|
while (bits >= 5) {
|
|
output += ALPHABET[(value >>> (bits - 5)) & 31];
|
|
bits -= 5;
|
|
}
|
|
}
|
|
if (bits > 0) output += ALPHABET[(value << (5 - bits)) & 31];
|
|
return output;
|
|
}
|
|
|
|
export function decodeBase32(value: string): Uint8Array {
|
|
const compact = value.toUpperCase().replace(/[\s-]/g, "");
|
|
if (!compact || !/^[A-Z2-7]+={0,6}$/.test(compact)) {
|
|
throw new TypeError("Invalid base32 secret");
|
|
}
|
|
const firstPadding = compact.indexOf("=");
|
|
const normalized = firstPadding < 0 ? compact : compact.slice(0, firstPadding);
|
|
if (!normalized) throw new TypeError("Invalid base32 secret");
|
|
|
|
let bits = 0;
|
|
let buffer = 0;
|
|
const output: number[] = [];
|
|
for (const character of normalized) {
|
|
const index = ALPHABET.indexOf(character);
|
|
if (index < 0) throw new TypeError("Invalid base32 secret");
|
|
buffer = (buffer << 5) | index;
|
|
bits += 5;
|
|
if (bits >= 8) {
|
|
output.push((buffer >>> (bits - 8)) & 255);
|
|
bits -= 8;
|
|
}
|
|
}
|
|
if (!output.length) throw new TypeError("Invalid base32 secret");
|
|
return new Uint8Array(output);
|
|
}
|
|
|
|
function counterBytes(counter: number): Uint8Array {
|
|
if (!Number.isSafeInteger(counter) || counter < 0) {
|
|
throw new RangeError("HOTP counter must be a non-negative safe integer");
|
|
}
|
|
const bytes = new Uint8Array(8);
|
|
let value = BigInt(counter);
|
|
for (let index = 7; index >= 0; index -= 1) {
|
|
bytes[index] = Number(value & 255n);
|
|
value >>= 8n;
|
|
}
|
|
return bytes;
|
|
}
|
|
|
|
async function hotp(secret: string, counter: number, digits = 6): Promise<string> {
|
|
const normalizedDigits = assertDigits(digits);
|
|
const key = await crypto.subtle.importKey(
|
|
"raw",
|
|
decodeBase32(secret) as BufferSource,
|
|
{ name: "HMAC", hash: "SHA-1" },
|
|
false,
|
|
["sign"],
|
|
);
|
|
const digest = new Uint8Array(
|
|
await crypto.subtle.sign("HMAC", key, counterBytes(counter) as BufferSource),
|
|
);
|
|
const offset = digest[digest.length - 1] & 15;
|
|
const binary =
|
|
((digest[offset] & 127) << 24) |
|
|
((digest[offset + 1] & 255) << 16) |
|
|
((digest[offset + 2] & 255) << 8) |
|
|
(digest[offset + 3] & 255);
|
|
return String(binary % 10 ** normalizedDigits).padStart(normalizedDigits, "0");
|
|
}
|
|
|
|
export interface TotpOptions {
|
|
period?: number;
|
|
digits?: number;
|
|
timestamp?: number;
|
|
}
|
|
|
|
export function generateTotpSecret(randomBytes?: (length: number) => Uint8Array): string {
|
|
const bytes = randomBytes ? randomBytes(20) : crypto.getRandomValues(new Uint8Array(20));
|
|
if (!(bytes instanceof Uint8Array) || bytes.length !== 20) {
|
|
throw new TypeError("TOTP random byte provider must return exactly 20 bytes");
|
|
}
|
|
return encodeBase32(bytes);
|
|
}
|
|
|
|
export async function generateTotp(secret: string, options: TotpOptions = {}): Promise<string> {
|
|
const period = assertPeriod(options.period ?? 30);
|
|
const timestamp = assertTimestamp(options.timestamp ?? Date.now());
|
|
const digits = assertDigits(options.digits ?? 6);
|
|
return hotp(secret, Math.floor(timestamp / 1000 / period), digits);
|
|
}
|
|
|
|
export async function verifyTotp(
|
|
secret: string,
|
|
token: string,
|
|
options: TotpOptions & { window?: number; lastCounter?: number } = {},
|
|
): Promise<{ valid: boolean; counter?: number }> {
|
|
const period = assertPeriod(options.period ?? 30);
|
|
const timestamp = assertTimestamp(options.timestamp ?? Date.now());
|
|
const digits = assertDigits(options.digits ?? 6);
|
|
const window = assertWindow(options.window ?? 1);
|
|
const normalizedToken = token.replace(/\s/g, "");
|
|
if (!new RegExp(`^\\d{${digits}}$`).test(normalizedToken)) return { valid: false };
|
|
|
|
const counter = Math.floor(timestamp / 1000 / period);
|
|
const lastCounter = options.lastCounter ?? -1;
|
|
if (!Number.isSafeInteger(lastCounter) || lastCounter < -1) {
|
|
throw new RangeError("TOTP lastCounter must be a safe integer greater than or equal to -1");
|
|
}
|
|
|
|
for (let offset = -window; offset <= window; offset += 1) {
|
|
const candidateCounter = counter + offset;
|
|
if (candidateCounter < 0 || candidateCounter <= lastCounter) continue;
|
|
const candidate = await hotp(secret, candidateCounter, digits);
|
|
if (await constantTimeEqual(candidate, normalizedToken)) {
|
|
return { valid: true, counter: candidateCounter };
|
|
}
|
|
}
|
|
return { valid: false };
|
|
}
|
|
|
|
export function totpUri(input: {
|
|
issuer: string;
|
|
accountName: string;
|
|
secret: string;
|
|
period?: number;
|
|
digits?: number;
|
|
}): string {
|
|
const issuer = input.issuer.trim();
|
|
const accountName = input.accountName.trim();
|
|
if (!issuer || !accountName) {
|
|
throw new TypeError("TOTP issuer and account name are required");
|
|
}
|
|
decodeBase32(input.secret);
|
|
const period = assertPeriod(input.period ?? 30);
|
|
const digits = assertDigits(input.digits ?? 6);
|
|
const label = encodeURIComponent(`${issuer}:${accountName}`);
|
|
const params = new URLSearchParams({
|
|
secret: input.secret.toUpperCase().replace(/[\s-]/g, "").replace(/=+$/g, ""),
|
|
issuer,
|
|
period: String(period),
|
|
digits: String(digits),
|
|
algorithm: "SHA1",
|
|
});
|
|
return `otpauth://totp/${label}?${params.toString()}`;
|
|
}
|