release: WRNexusJS 0.5.0

This commit is contained in:
2026-07-29 12:51:10 +05:30
parent 76c768099d
commit 6afe32f63f
456 changed files with 40879 additions and 8850 deletions
+186
View File
@@ -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()}`;
}