const BASE32_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
export function base32Encode(bytes: Uint8Array): string {
let bits = 0;
let value = 0;
let output = "";
for (let i = 0; i < bytes.length; i++) {
value = (value << 8) | bytes[i]!;
bits += 8;
while (bits >= 5) {
output += BASE32_ALPHABET[(value >>> (bits - 5)) & 31];
bits -= 5;
}
}
if (bits > 0) {
output += BASE32_ALPHABET[(value << (5 - bits)) & 31];
}
return output;
}
export function base32Decode(input: string): Uint8Array {
const clean = input
.toUpperCase()
.replace(/=+$/g, "")
.replace(/\s+/g, "");
let bits = 0;
let value = 0;
const out: number[] = [];
for (let i = 0; i < clean.length; i++) {
const idx = BASE32_ALPHABET.indexOf(clean[i]!);
if (idx === -1) {
throw new Error(`Invalid Base32 character: ${clean[i]}`);
}
value = (value << 5) | idx;
bits += 5;
if (bits >= 8) {
bits -= 8;
out.push((value >>> bits) & 0xff);
}
}
return new Uint8Array(out);
}
export function generateTotpSecret(): string {
return base32Encode(crypto.getRandomValues(new Uint8Array(20)));
}
async function hmacSha1(
keyBytes: Uint8Array,
msgBytes: Uint8Array
): Promise<Uint8Array> {
const key = await crypto.subtle.importKey(
"raw",
keyBytes as BufferSource,
{ name: "HMAC", hash: "SHA-1" },
false,
["sign"]
);
const sig = await crypto.subtle.sign("HMAC", key, msgBytes as BufferSource);
return new Uint8Array(sig);
}
function hotpCode(hmac: Uint8Array): string {
const offset = hmac[hmac.length - 1]! & 0x0f;
const bin =
((hmac[offset]! & 0x7f) << 24) |
((hmac[offset + 1]! & 0xff) << 16) |
((hmac[offset + 2]! & 0xff) << 8) |
(hmac[offset + 3]! & 0xff);
return String(bin % 1_000_000).padStart(6, "0");
}
export async function totpCode(
secretBase32: string,
timeSec: number = Math.floor(Date.now() / 1000)
): Promise<string> {
const step = Math.floor(timeSec / 30);
const msg = new Uint8Array(8);
new DataView(msg.buffer).setBigUint64(0, BigInt(step), false);
const hmac = await hmacSha1(base32Decode(secretBase32), msg);
return hotpCode(hmac);
}
export async function verifyTotpCode(
secretBase32: string,
code: string,
timeSec: number = Math.floor(Date.now() / 1000)
): Promise<boolean> {
if (!/^\d{6}$/.test(code)) return false;
const candidates = await Promise.all([
totpCode(secretBase32, timeSec - 30),
totpCode(secretBase32, timeSec),
totpCode(secretBase32, timeSec + 30),
]);
let ok = false;
for (const c of candidates) {
if (constantTimeEqual(c, code)) ok = true;
}
return ok;
}
function constantTimeEqual(a: string, b: string): boolean {
if (a.length !== b.length) return false;
let diff = 0;
for (let i = 0; i < a.length; i++) {
diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
}
return diff === 0;
}
export function otpauthUrl(opts: {
secret: string;
accountName: string;
issuer: string;
}): string {
const label = encodeURIComponent(`${opts.issuer}:${opts.accountName}`);
const params = new URLSearchParams({
secret: opts.secret,
issuer: opts.issuer,
algorithm: "SHA1",
digits: "6",
period: "30",
});
return `otpauth://totp/${label}?${params.toString()}`;
}
export function generateRecoveryCodes(count = 10): string[] {
const codes: string[] = [];
for (let i = 0; i < count; i++) {
const parts: string[] = [];
for (let j = 0; j < 3; j++) {
const bytes = crypto.getRandomValues(new Uint8Array(3));
parts.push(
Array.from(bytes)
.map((b) => b.toString(36).padStart(2, "0"))
.join("")
.slice(0, 4)
);
}
codes.push(parts.join("-"));
}
return codes;
}
export async function hashRecoveryCode(code: string): Promise<string> {
const bytes = new TextEncoder().encode(code.trim().toLowerCase());
const digest = await crypto.subtle.digest("SHA-256", bytes);
return Array.from(new Uint8Array(digest))
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
}
|