zerokey
Version:
Zero-knowledge cross-domain secret sharing library using ECDH encryption
209 lines (207 loc) • 6.09 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 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 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();
}
export { clearSecret, getSecret, initSecretClient };
//# sourceMappingURL=client.js.map
//# sourceMappingURL=client.js.map