UNPKG

zerokey

Version:

Zero-knowledge cross-domain secret sharing library using ECDH encryption

306 lines (303 loc) 9.36 kB
// src/crypto.ts function base64urlEncode(buffer) { const base64 = btoa(String.fromCharCode(...new Uint8Array(buffer))); return base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, ""); } function base64urlDecode(str) { const base64 = str.replace(/-/g, "+").replace(/_/g, "/").padEnd(str.length + (4 - str.length % 4) % 4, "="); const binary = atob(base64); const bytes = new Uint8Array(binary.length); for (let i = 0; i < binary.length; i++) { bytes[i] = binary.charCodeAt(i); } return bytes.buffer; } async function generateKeyPair() { const keyPair = await crypto.subtle.generateKey( { name: "ECDH", namedCurve: "P-256" }, true, ["deriveKey"] ); const publicKeyExported = await exportPublicKey(keyPair.publicKey); return { publicKey: publicKeyExported, privateKey: keyPair.privateKey }; } async function exportPublicKey(publicKey) { const exported = await crypto.subtle.exportKey("raw", publicKey); return base64urlEncode(exported); } async function importPublicKey(publicKeyString) { const keyData = base64urlDecode(publicKeyString); return await crypto.subtle.importKey( "raw", keyData, { name: "ECDH", namedCurve: "P-256" }, false, [] ); } async function serializePrivateKey(privateKey) { const exported = await crypto.subtle.exportKey("jwk", privateKey); return JSON.stringify(exported); } async function deserializePrivateKey(privateKeyString) { const jwk = JSON.parse(privateKeyString); return await crypto.subtle.importKey( "jwk", jwk, { name: "ECDH", namedCurve: "P-256" }, true, ["deriveKey"] ); } async function deriveSharedSecret(privateKey, publicKey) { return await crypto.subtle.deriveKey( { name: "ECDH", public: publicKey }, privateKey, { name: "AES-GCM", length: 256 }, false, ["encrypt", "decrypt"] ); } async function aesEncrypt(key, data) { const encoder = new TextEncoder(); const iv = crypto.getRandomValues(new Uint8Array(12)); const encrypted = await crypto.subtle.encrypt( { name: "AES-GCM", iv }, key, encoder.encode(data) ); const combined = new Uint8Array(iv.length + encrypted.byteLength); combined.set(iv, 0); combined.set(new Uint8Array(encrypted), iv.length); return combined.buffer; } async function aesDecrypt(key, combinedData) { const data = new Uint8Array(combinedData); const iv = data.slice(0, 12); const encrypted = data.slice(12); const decrypted = await crypto.subtle.decrypt( { name: "AES-GCM", iv }, key, encrypted ); const decoder = new TextDecoder(); return decoder.decode(decrypted); } async function encryptWithPublicKey(secret, publicKeyString) { const ephemeralKeyPair = await generateKeyPair(); const recipientPublicKey = await importPublicKey(publicKeyString); const sharedKey = await deriveSharedSecret(ephemeralKeyPair.privateKey, recipientPublicKey); const encryptedData = await aesEncrypt(sharedKey, secret); const payload = { ephemeralPublicKey: ephemeralKeyPair.publicKey, encryptedData: base64urlEncode(encryptedData) }; return base64urlEncode(new TextEncoder().encode(JSON.stringify(payload))); } async function decryptWithPrivateKey(encryptedString, privateKey) { try { const payloadBytes = base64urlDecode(encryptedString); const payloadText = new TextDecoder().decode(payloadBytes); const payload = JSON.parse(payloadText); const ephemeralPublicKey = await importPublicKey(payload.ephemeralPublicKey); const sharedKey = await deriveSharedSecret(privateKey, ephemeralPublicKey); const encryptedData = base64urlDecode(payload.encryptedData); return await aesDecrypt(sharedKey, encryptedData); } catch (error) { console.error("Decryption failed:", error); throw new Error("Failed to decrypt secret"); } } // src/client.ts var STORAGE_KEY_SECRET = "zerokey_secret"; var STORAGE_KEY_PENDING = "zerokey_pending"; var PENDING_KEY_EXPIRY = 5 * 60 * 1e3; function isReturningFromAuth() { return window.location.hash?.includes("secret="); } function parseFragment() { const fragment = window.location.hash.substring(1); const params = new URLSearchParams(fragment); return { secret: params.get("secret"), state: params.get("state") }; } function clearFragment() { window.history.replaceState(null, "", window.location.pathname + window.location.search); } function storePendingKey(privateKey, state) { const data = { privateKey, state, timestamp: Date.now() }; localStorage.setItem(STORAGE_KEY_PENDING, JSON.stringify(data)); } function getPendingKey() { const data = localStorage.getItem(STORAGE_KEY_PENDING); if (!data) return null; try { const parsed = JSON.parse(data); const age = Date.now() - parsed.timestamp; if (age > PENDING_KEY_EXPIRY) { localStorage.removeItem(STORAGE_KEY_PENDING); return null; } return parsed; } catch (error) { localStorage.removeItem(STORAGE_KEY_PENDING); return null; } } function clearPendingKey() { localStorage.removeItem(STORAGE_KEY_PENDING); } async function initSecretClient(authUrl) { if (isReturningFromAuth()) { const { secret: encryptedSecret, state } = parseFragment(); clearFragment(); if (!encryptedSecret) { console.error("No encrypted secret in URL fragment"); return; } const pending = getPendingKey(); if (!pending) { console.error("No pending key found or key expired"); return; } if (pending.state !== state) { console.error("State mismatch - possible CSRF"); clearPendingKey(); return; } try { const privateKey = await deserializePrivateKey(pending.privateKey); const decryptedSecret = await decryptWithPrivateKey(encryptedSecret, privateKey); localStorage.setItem(STORAGE_KEY_SECRET, decryptedSecret); clearPendingKey(); window.dispatchEvent(new Event("zerokey:ready")); } catch (error) { console.error("Failed to decrypt secret:", error); clearPendingKey(); } } else { try { const { publicKey, privateKey } = await generateKeyPair(); const state = crypto.randomUUID(); const serializedPrivateKey = await serializePrivateKey(privateKey); storePendingKey(serializedPrivateKey, state); const url = new URL(authUrl); url.searchParams.set("publicKey", publicKey); url.searchParams.set("redirect", window.location.href); url.searchParams.set("state", state); window.location.href = url.toString(); } catch (error) { console.error("Failed to initiate secret transfer:", error); throw error; } } } function getSecret() { return localStorage.getItem(STORAGE_KEY_SECRET); } function clearSecret() { localStorage.removeItem(STORAGE_KEY_SECRET); clearPendingKey(); } // src/server.ts var pendingSecret = null; var isInitialized = false; function parseQueryParams() { const params = new URLSearchParams(window.location.search); return { publicKey: params.get("publicKey"), redirect: params.get("redirect"), state: params.get("state") }; } function isValidRedirect(url) { try { const parsedUrl = new URL(url); return parsedUrl.protocol === "http:" || parsedUrl.protocol === "https:"; } catch { return false; } } async function performRedirect(secret, publicKey, redirectUrl, state) { try { const encryptedSecret = await encryptWithPublicKey(secret, publicKey); const url = new URL(redirectUrl); const fragment = new URLSearchParams({ secret: encryptedSecret, state }).toString(); window.location.href = `${url.origin}${url.pathname}${url.search}#${fragment}`; } catch (error) { console.error("Failed to encrypt and redirect:", error); const url = new URL(redirectUrl); window.location.href = `${url.origin}${url.pathname}${url.search}#error=encryption_failed&state=${state}`; } } function initSecretServer(options = {}) { if (isInitialized) { console.warn("Secret server already initialized"); return; } isInitialized = true; const { publicKey, redirect, state } = parseQueryParams(); if (!publicKey || !redirect || !state) { console.error("Missing required parameters"); return; } if (!isValidRedirect(redirect)) { console.error("Invalid redirect URL"); return; } if (options.validateCallbackUrl && !options.validateCallbackUrl(redirect)) { console.error("Redirect URL failed custom validation"); return; } window.zerokeyParams = { publicKey, redirect, state }; if (pendingSecret) { performRedirect(pendingSecret, publicKey, redirect, state); } } function setSecret(secret) { if (!secret) { console.error("Secret cannot be empty"); return; } pendingSecret = secret; if (window.zerokeyParams) { const { publicKey, redirect, state } = window.zerokeyParams; performRedirect(secret, publicKey, redirect, state); } } export { clearSecret, decryptWithPrivateKey, deserializePrivateKey, encryptWithPublicKey, exportPublicKey, generateKeyPair, getSecret, importPublicKey, initSecretClient, initSecretServer, serializePrivateKey, setSecret }; //# sourceMappingURL=index.js.map //# sourceMappingURL=index.js.map