release: WRNexusJS 0.5.0
This commit is contained in:
@@ -0,0 +1,186 @@
|
||||
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()}`;
|
||||
}
|
||||
Reference in New Issue
Block a user