IcyZip

src/e2ee.js

Alle Dateien · Rohdatei öffnen

14319 bytes · SHA-256: d4572f2cbba9792274cef840ba4417aea66ca008302b8d031b90045fcfba8254

// 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 });
});