zerokey
Version:
Zero-knowledge cross-domain secret sharing library using ECDH encryption
306 lines (303 loc) • 9.36 kB
JavaScript
// 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