// Browser WebAuthn and secret encryption helpers. The host verifies every proof. const bytes = text => Uint8Array.from(atob(text.replace(/-/g, '+').replace(/_/g, '/')), c => c.charCodeAt(0)); const encoded = buffer => btoa(String.fromCharCode(...new Uint8Array(buffer))).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); const post = async (path, body) => { const response = await fetch('/api/' + path.replace(/^\//, ''), {method: 'POST', headers: {'content-type': 'application/json'}, body: JSON.stringify(body)}); const result = await response.json(); if (!response.ok) throw new Error(result.message); return result; }; function options(challenge) { const value = challenge.options; value.publicKey.challenge = bytes(value.publicKey.challenge); if (value.publicKey.user) value.publicKey.user.id = bytes(value.publicKey.user.id); for (const key of ['allowCredentials', 'excludeCredentials']) { for (const credential of value.publicKey[key] || []) credential.id = bytes(credential.id); } return value; } function serialize(credential) { const response = {}; for (const key of ['clientDataJSON', 'attestationObject', 'authenticatorData', 'signature', 'userHandle']) { if (credential.response[key]) response[key] = encoded(credential.response[key]); } return {id: credential.id, rawId: encoded(credential.rawId), type: credential.type, response, clientExtensionResults: credential.getClientExtensionResults()}; } async function assertion(challenge) { return {challenge_id: challenge.id, assertion: serialize(await navigator.credentials.get(options(challenge)))}; } async function encrypt(publicKey, secret) { const host = await crypto.subtle.importKey('raw', new Uint8Array(publicKey), {name: 'ECDH', namedCurve: 'P-256'}, false, []); const pair = await crypto.subtle.generateKey({name: 'ECDH', namedCurve: 'P-256'}, true, ['deriveBits']); const shared = await crypto.subtle.deriveBits({name: 'ECDH', public: host}, pair.privateKey, 256); const material = await crypto.subtle.importKey('raw', shared, 'HKDF', false, ['deriveKey']); const key = await crypto.subtle.deriveKey({name: 'HKDF', hash: 'SHA-256', salt: new Uint8Array(), info: new TextEncoder().encode('sausage/secret-entry/v1')}, material, {name: 'AES-GCM', length: 256}, false, ['encrypt']); const iv = crypto.getRandomValues(new Uint8Array(12)); const ciphertext = await crypto.subtle.encrypt({name: 'AES-GCM', iv}, key, new TextEncoder().encode(secret)); const ephemeral_public_key = new Uint8Array(await crypto.subtle.exportKey('raw', pair.publicKey)); const envelope = {ephemeral_public_key: [...ephemeral_public_key], iv: [...iv], ciphertext: [...new Uint8Array(ciphertext)]}; const hash = new Uint8Array(await crypto.subtle.digest('SHA-256', new Uint8Array([...envelope.ephemeral_public_key, ...envelope.iv, ...envelope.ciphertext]))); return {envelope, ciphertext_sha256: [...hash].map(byte => byte.toString(16).padStart(2, '0')).join('')}; }