// Copyright 2026 Richard Andresik. SPDX-License-Identifier: Apache-2.0 // The browser and independent tests use this same protocol implementation. (function (root, factory) { const api = factory(); if (typeof module === "object" && module.exports) module.exports = api; else root.IcyZipE2EE = api; })(typeof window !== "undefined" ? window : globalThis, function () { "use strict"; const KEYPAIR_PREFIX = "icyzip.e2ee.v3.ecdh:"; const SECRET_PREFIX = "icyzip.e2ee.v3.secret:"; const CURVE = { name: "ECDH", namedCurve: "P-256" }; const encoder = new TextEncoder(); function encode(bytes) { let raw = ""; for (let index = 0; index < bytes.length; index += 1) raw += String.fromCharCode(bytes[index]); return btoa(raw).split("+").join("-").split("/").join("_").split("=").join(""); } function decode(value) { if (typeof value !== "string" || !value || value.length % 4 === 1) return null; const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; for (const character of value) if (!alphabet.includes(character)) return null; try { const raw = atob(value.split("-").join("+").split("_").join("/")); const bytes = Uint8Array.from(raw, character => character.charCodeAt(0)); return encode(bytes) === value ? bytes : null; } catch (error) { return null; } } function validSecret(secret) { return typeof secret === "string" && secret.length === 43 && !!decode(secret); } function generateSecret(crypto) { return encode(crypto.getRandomValues(new Uint8Array(32))); } function fragmentForSecret(secret) { if (!validSecret(secret)) throw new Error("Invalid pairing secret"); return "#k=" + secret + "&v=3"; } function parseFragment(fragment) { if (typeof fragment !== "string" || !fragment.startsWith("#")) return null; const params = new URLSearchParams(fragment.slice(1)); if (Array.from(params).length !== 2 || params.getAll("k").length !== 1 || params.getAll("v").length !== 1 || params.get("v") !== "3") return null; const secret = params.get("k"); return validSecret(secret) ? secret : null; } function canonical(value) { return encoder.encode(JSON.stringify(value)); } function validPublic(value) { const bytes = typeof value === "string" && value.length === 87 ? decode(value) : null; return !!bytes && bytes.length === 65 && bytes[0] === 4; } function publicFromJwk(key) { if (!key || key.kty !== "EC" || key.crv !== "P-256") return ""; const x = decode(key.x); const y = decode(key.y); if (!x || !y || x.length !== 32 || y.length !== 32) return ""; const raw = new Uint8Array(65); raw[0] = 4; raw.set(x, 1); raw.set(y, 33); return encode(raw); } function storageGet(storage, key) { try { return storage && storage.getItem(key); } catch (error) { return null; } } function storageRemove(storage, key) { try { if (storage) storage.removeItem(key); } catch (error) { /* A storage-disabled tab still supports its in-memory session. */ } } function storageSet(storage, key, value) { try { if (storage) storage.setItem(key, value); } catch (error) { /* Reload then requires a fresh primary or the original link. */ } } function validText(value) { return value && typeof value === "object" && !Array.isArray(value) && Object.keys(value).length === 3 && typeof value.text === "string" && Number.isSafeInteger(value.t) && value.t >= 0 && typeof value.o === "string" && value.o.length > 0 && value.o.length <= 128; } function offerFields(args) { if (!Array.isArray(args) || args.length !== 7 || args.some(value => typeof value !== "string") || !args[0] || args[0].length > 80 || !args[1] || args[1].length > 180 || !args[3] || args[3].length > 120 || args[6] !== "e2ee-v3") throw new Error("Invalid file offer"); for (const index of [2, 4, 5]) { const number = Number(args[index]); if (!Number.isSafeInteger(number) || number <= 0 || String(number) !== args[index]) throw new Error("Invalid file offer size"); } return args.slice(); } function fileContext(pairId, transferId, seq) { if (typeof transferId !== "string" || !transferId || transferId.length > 80 || !Number.isSafeInteger(seq) || seq < 0) throw new Error("Invalid file context"); return canonical(["icyzip/file/chunk/v3", pairId, transferId, seq]); } function createSession(options) { const { pairId, role, secret, crypto, storage } = options; if (typeof pairId !== "string" || !pairId || pairId.length > 80 || !["primary", "secondary"].includes(role) || !validSecret(secret) || !crypto || !crypto.subtle || !crypto.getRandomValues) throw new Error("Invalid E2EE session"); const subtle = crypto.subtle; const peerRole = role === "primary" ? "secondary" : "primary"; const secretBytes = decode(secret); const storageKey = KEYPAIR_PREFIX + pairId; let keyPairPromise = null; let authPromise = null; let keys = null; let peerPublic = ""; let epoch = 0; let closed = false; async function hkdf(input, salt, info, hmac) { const material = await subtle.importKey("raw", input, "HKDF", false, ["deriveKey"]); return subtle.deriveKey({ name: "HKDF", hash: "SHA-256", salt, info: encoder.encode(info) }, material, hmac ? { name: "HMAC", hash: "SHA-256", length: 256 } : { name: "AES-GCM", length: 256 }, false, hmac ? ["sign", "verify"] : ["encrypt", "decrypt"]); } async function loadKeyPair() { try { const saved = JSON.parse(storageGet(storage, storageKey) || "null"); if (saved && validPublic(saved.publicKey) && publicFromJwk(saved.privateKey) === saved.publicKey) { const privateKey = await subtle.importKey("jwk", saved.privateKey, CURVE, false, ["deriveBits"]); return { privateKey, publicKey: saved.publicKey }; } } catch (error) { /* Corrupted state must regenerate a matching keypair. */ } storageRemove(storage, storageKey); const generated = await subtle.generateKey(CURVE, true, ["deriveBits"]); const privateJwk = await subtle.exportKey("jwk", generated.privateKey); const publicKey = encode(new Uint8Array(await subtle.exportKey("raw", generated.publicKey))); if (!closed) storageSet(storage, storageKey, JSON.stringify({ privateKey: privateJwk, publicKey })); return { privateKey: generated.privateKey, publicKey }; } function keyPair() { if (closed) return Promise.reject(new Error("Closed E2EE session")); if (!keyPairPromise) keyPairPromise = loadKeyPair(); return keyPairPromise; } function authKey() { if (!authPromise) authPromise = hkdf(secretBytes, encoder.encode(pairId), "icyzip/e2ee/pub-auth/v3", true); return authPromise; } function publicContext(senderRole, publicKey) { return canonical(["icyzip/e2ee/pub/v3", pairId, senderRole, publicKey]); } async function publicMessage() { const own = await keyPair(); const tag = await subtle.sign("HMAC", await authKey(), publicContext(role, own.publicKey)); if (closed) throw new Error("Closed E2EE session"); return ["v3", own.publicKey, encode(new Uint8Array(tag))]; } async function acceptPublicMessage(message) { const started = epoch; if (closed || !Array.isArray(message) || message.length !== 3 || message[0] !== "v3" || !validPublic(message[1]) || !validSecret(message[2])) throw new Error("Invalid public-key message"); const publicKey = message[1]; if (!await subtle.verify("HMAC", await authKey(), decode(message[2]), publicContext(peerRole, publicKey))) throw new Error("Public-key authentication failed"); if (closed || started !== epoch) return false; if (keys && peerPublic === publicKey) return true; const own = await keyPair(); const peer = await subtle.importKey("raw", decode(publicKey), CURVE, false, []); const shared = new Uint8Array(await subtle.deriveBits({ name: "ECDH", public: peer }, own.privateKey, 256)); const material = new Uint8Array(64); material.set(shared); material.set(secretBytes, 32); const primary = role === "primary" ? own.publicKey : publicKey; const secondary = role === "secondary" ? own.publicKey : publicKey; const salt = await subtle.digest("SHA-256", canonical(["v3", pairId, primary, secondary])); const derived = await Promise.all([ hkdf(material, salt, "icyzip/e2ee/text/v3", false), hkdf(material, salt, "icyzip/e2ee/file-chunk/v3", false), hkdf(material, salt, "icyzip/e2ee/file-offer/v3", true) ]); shared.fill(0); material.fill(0); if (closed || started !== epoch) return false; keys = { text: derived[0], file: derived[1], offer: derived[2] }; peerPublic = publicKey; return true; } function requireKeys() { if (closed || !keys) throw new Error("Authenticated peer key missing"); return keys; } async function encryptText(text, revision, origin) { const content = { text, t: revision, o: origin }; if (!validText(content)) throw new Error("Invalid text metadata"); const key = requireKeys().text; const nonce = crypto.getRandomValues(new Uint8Array(12)); const ciphertext = await subtle.encrypt({ name: "AES-GCM", iv: nonce, additionalData: canonical(["icyzip/text/v2", pairId]) }, key, canonical(content)); return JSON.stringify({ v: 2, alg: "A256GCM", n: encode(nonce), c: encode(new Uint8Array(ciphertext)) }); } async function decryptText(payload) { const key = requireKeys().text; const envelope = JSON.parse(payload); if (!envelope || Array.isArray(envelope) || Object.keys(envelope).length !== 4 || envelope.v !== 2 || envelope.alg !== "A256GCM") throw new Error("Invalid text envelope"); const nonce = decode(envelope.n); const ciphertext = decode(envelope.c); if (!nonce || nonce.length !== 12 || !ciphertext || ciphertext.length < 16) throw new Error("Invalid text ciphertext"); const plain = await subtle.decrypt({ name: "AES-GCM", iv: nonce, additionalData: canonical(["icyzip/text/v2", pairId]) }, key, ciphertext); const value = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(plain)); if (!validText(value)) throw new Error("Invalid text metadata"); return { text: value.text, updatedAt: value.t, origin: value.o }; } async function encryptFile(buffer, transferId, seq) { const key = requireKeys().file; const nonce = crypto.getRandomValues(new Uint8Array(12)); const ciphertext = new Uint8Array(await subtle.encrypt({ name: "AES-GCM", iv: nonce, additionalData: fileContext(pairId, transferId, seq) }, key, buffer)); const frame = new Uint8Array(12 + ciphertext.length); frame.set(nonce); frame.set(ciphertext, 12); return frame.buffer; } async function decryptFile(buffer, transferId, seq) { const key = requireKeys().file; const frame = ArrayBuffer.isView(buffer) ? new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength) : new Uint8Array(buffer); if (frame.length <= 28) throw new Error("Invalid file frame"); return subtle.decrypt({ name: "AES-GCM", iv: frame.slice(0, 12), additionalData: fileContext(pairId, transferId, seq) }, key, frame.slice(12)); } async function signFileOffer(args) { const fields = offerFields(args); const tag = await subtle.sign("HMAC", requireKeys().offer, canonical(["icyzip/file/offer/v3", pairId, role, fields])); return fields.concat(encode(new Uint8Array(tag))); } async function verifyFileOffer(args) { if (!Array.isArray(args) || args.length !== 8 || !validSecret(args[7])) return false; const fields = offerFields(args.slice(0, 7)); return subtle.verify("HMAC", requireKeys().offer, decode(args[7]), canonical(["icyzip/file/offer/v3", pairId, peerRole, fields])); } function resetPeer() { epoch += 1; keys = null; peerPublic = ""; } function close() { resetPeer(); closed = true; secretBytes.fill(0); authPromise = null; keyPairPromise = null; } return Object.freeze({ publicMessage, acceptPublicMessage, encryptText, decryptText, encryptFile, decryptFile, signFileOffer, verifyFileOffer, resetPeer, close, ready: () => !closed && !!keys }); } return Object.freeze({ KEYPAIR_PREFIX, SECRET_PREFIX, generateSecret, validSecret, fragmentForSecret, parseFragment, createSession }); });