const encoder = new TextEncoder(); const ID_ALPHABET = "0123456789abcdefghijklmnopqrstuvwxyz"; const ID_LENGTH = 20; const PBKDF2_ITERATIONS = 100_000; const SALT_BYTES = 16; const HASH_BITS = 256; const AES_IV_BYTES = 12; export function randomId(length = ID_LENGTH): string { const bytes = crypto.getRandomValues(new Uint8Array(length)); let out = ""; for (const byte of bytes) { out += ID_ALPHABET[byte % ID_ALPHABET.length]; } return out; } export function toHex(bytes: ArrayBuffer | Uint8Array): string { const view = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes); let out = ""; for (const byte of view) { out += byte.toString(16).padStart(2, "0"); } return out; } export function fromHex(hex: string): Uint8Array { const out = new Uint8Array(hex.length / 2); for (let index = 0; index < out.length; index++) { out[index] = Number.parseInt(hex.slice(index * 2, index * 2 + 2), 16); } return out; } export function toBase64(bytes: Uint8Array): string { let binary = ""; for (const byte of bytes) { binary += String.fromCharCode(byte); } return btoa(binary); } export function fromBase64(value: string): Uint8Array { const binary = atob(value); const out = new Uint8Array(binary.length); for (let index = 0; index < binary.length; index++) { out[index] = binary.charCodeAt(index); } return out; } export function randomToken(prefix: string): string { return `${prefix}_${toHex(crypto.getRandomValues(new Uint8Array(20)))}`; } export async function sha256Hex(value: string): Promise { return toHex(await crypto.subtle.digest("SHA-256", encoder.encode(value))); } export function timingSafeEqualStrings(left: string, right: string): boolean { const leftBytes = encoder.encode(left); const rightBytes = encoder.encode(right); if (leftBytes.byteLength !== rightBytes.byteLength) return false; let difference = 0; for (let index = 0; index < leftBytes.byteLength; index++) { difference |= leftBytes[index] ^ rightBytes[index]; } return difference === 0; } async function pbkdf2(password: string, salt: Uint8Array, iterations: number): Promise { const key = await crypto.subtle.importKey("raw", encoder.encode(password), "PBKDF2", false, ["deriveBits"]); const bits = await crypto.subtle.deriveBits({ name: "PBKDF2", hash: "SHA-256", salt, iterations }, key, HASH_BITS); return new Uint8Array(bits); } export async function hashPassword(password: string): Promise { const salt = crypto.getRandomValues(new Uint8Array(SALT_BYTES)); const hash = await pbkdf2(password, salt, PBKDF2_ITERATIONS); return `pbkdf2$${PBKDF2_ITERATIONS}$${toBase64(salt)}$${toBase64(hash)}`; } export async function verifyPassword(password: string, stored: string): Promise { const [scheme, iterations, salt, hash] = stored.split("$"); if (scheme !== "pbkdf2" || !iterations || !salt || !hash) return false; const computed = await pbkdf2(password, fromBase64(salt), Number(iterations)); return timingSafeEqualStrings(toBase64(computed), hash); } export async function hmacSha256Hex(secret: string, body: string): Promise { const key = await crypto.subtle.importKey("raw", encoder.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, [ "sign", ]); return toHex(await crypto.subtle.sign("HMAC", key, encoder.encode(body))); } async function secretsKey(masterKey: string): Promise { const digest = await crypto.subtle.digest("SHA-256", encoder.encode(masterKey)); return crypto.subtle.importKey("raw", digest, "AES-GCM", false, ["encrypt", "decrypt"]); } export async function encryptSecret(masterKey: string, plaintext: string): Promise<{ ciphertext: string; iv: string }> { const iv = crypto.getRandomValues(new Uint8Array(AES_IV_BYTES)); const key = await secretsKey(masterKey); const encrypted = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, encoder.encode(plaintext)); return { ciphertext: toBase64(new Uint8Array(encrypted)), iv: toBase64(iv) }; } export async function decryptSecret(masterKey: string, ciphertext: string, iv: string): Promise { const key = await secretsKey(masterKey); const decrypted = await crypto.subtle.decrypt({ name: "AES-GCM", iv: fromBase64(iv) }, key, fromBase64(ciphertext)); return new TextDecoder().decode(decrypted); }