IcyZip

src/snapshot.js

Alle Dateien · Rohdatei öffnen

23738 bytes · SHA-256: e488c8f43e708935ca448b623174bc838738d0b06c948523f588343cbd548dcf

// Copyright 2026 Richard Andresik. SPDX-License-Identifier: Apache-2.0
// Review snapshot: exact excerpts from IcyZip's browser client.
// These functions require application state or the supplied test adapter.
// See THREAT-MODEL.md before relying on this protocol.

// BEGIN exact source section: module-integration
function textE2eeSupported()
{
    return !!(window.IcyZipE2EE && window.crypto && window.crypto.subtle
        && window.crypto.getRandomValues && window.TextEncoder && window.TextDecoder);
}

function readPairSecret(id)
{
    if (pairSecretId === id && window.IcyZipE2EE.validSecret(pairSecret)) return pairSecret;
    try
    {
        const saved = sessionStorage.getItem(window.IcyZipE2EE.SECRET_PREFIX + id);
        return window.IcyZipE2EE.validSecret(saved) ? saved : null;
    }
    catch (error) { return null; }
}

function usePairSecret(id, secret)
{
    if (!id || !window.IcyZipE2EE.validSecret(secret)) return false;
    if (pairSecretId !== id || pairSecret !== secret) resetTextE2eeSecret();
    pairSecretId = id;
    pairSecret = secret;
    try { sessionStorage.setItem(window.IcyZipE2EE.SECRET_PREFIX + id, secret); }
    catch (error) {}
    return true;
}

function ensureTextE2eeSession(id)
{
    const secret = readPairSecret(id);
    if (!id || !secret || !textE2eeSupported()) throw new Error("Pair key missing");
    if (!e2eeSession || e2eeSessionId !== id)
    {
        let storage = null;
        try { storage = sessionStorage; } catch (error) {}
        e2eeSession = window.IcyZipE2EE.createSession({
            pairId: id, role: isPrimaryView() ? "primary" : "secondary",
            secret, crypto: window.crypto, storage
        });
        e2eeSessionId = id;
    }
    return e2eeSession;
}

function clearUrlFragment()
{
    if (!location.hash) return;
    try { history.replaceState(null, document.title, location.pathname + location.search); }
    catch (error) {}
}

function clearPairIdFromUrl()
{
    try
    {
        const next = new URL(location.href);
        next.searchParams.delete("i");
        history.replaceState(null, document.title, next.pathname + next.search + next.hash);
    }
    catch (error) {}
}

function clearTextE2eeDerivedSecret()
{
    if (e2eeSession) e2eeSession.resetPeer();
}

function resetTextE2eeSecret()
{
    if (e2eeSession) e2eeSession.close();
    e2eeSession = null;
    e2eeSessionId = "";
    pairSecret = null;
    pairSecretId = "";
    textE2eeReadyCallbacks = [];
}

function clearTextE2eeSecretForId(id)
{
    if (!id) return;
    try
    {
        for (const prefix of [window.IcyZipE2EE.SECRET_PREFIX, window.IcyZipE2EE.KEYPAIR_PREFIX,
            "icyzip.textE2ee.v1:", "icyzip.textE2ee.ecdh.v2:"])
            sessionStorage.removeItem(prefix + id);
    }
    catch (error) {}
    if (pairSecretId === id || e2eeSessionId === id) resetTextE2eeSecret();
}

function textE2eeReady()
{
    return !!(e2eeSession && e2eeSessionId === currentTextE2eePairId() && e2eeSession.ready());
}

function onTextE2eeReady(callback)
{
    if (typeof callback !== "function") return;
    if (textE2eeReady()) callback();
    else textE2eeReadyCallbacks.push(callback);
}

function notifyTextE2eeReady()
{
    if (!textE2eeReady()) return;
    const callbacks = textE2eeReadyCallbacks;
    textE2eeReadyCallbacks = [];
    callbacks.forEach(function (callback) { callback(); });
}

function currentTextE2eePairId()
{
    return pairId || i || (requestedResumePair && requestedResumePair.id) || "";
}

function pairUrlForId(id)
{
    const secret = id && readPairSecret(id);
    return secret ? publicBaseUrl + "/?i=" + encodeURIComponent(id)
        + window.IcyZipE2EE.fragmentForSecret(secret) : "";
}

function showTextE2eeFailure(state, diagnosticReason)
{
    const status = state === "key-mismatch" ? "Pair key mismatch" : "Pair key missing";
    sendClientDiagnostic(diagnosticReason || state);
    setStatusMessage(status);
    setUiState(state, status);
}

async function receiveTextE2eePublic(message)
{
    const session = ensureTextE2eeSession(currentTextE2eePairId());
    const accepted = await session.acceptPublicMessage(message);
    if (accepted && session === e2eeSession) notifyTextE2eeReady();
    return accepted;
}

async function sendTextE2eePublic()
{
    const id = currentTextE2eePairId();
    const targetId = isPrimaryView() ? aid : i;
    if (!id || !targetId) return false;
    try
    {
        const session = ensureTextE2eeSession(id);
        const message = await session.publicMessage();
        return session === e2eeSession && sendCommand("textPub", [targetId].concat(message));
    }
    catch (error)
    {
        showTextE2eeFailure("key-missing", "e2ee-unsupported");
        return false;
    }
}

async function encryptTextPayload(text, updatedAt, origin)
{
    const revision = normalizedTextUpdatedAt(updatedAt) || TEXT_REVISION_BASE;
    const sender = typeof origin === "string" && origin ? origin : localTextUpdatedOrigin;
    return ensureTextE2eeSession(currentTextE2eePairId()).encryptText(String(text || ""), revision, sender);
}

async function decryptTextPayload(payload)
{
    return ensureTextE2eeSession(currentTextE2eePairId()).decryptText(payload);
}

async function encryptFileChunk(buffer, transferId, seq)
{
    return ensureTextE2eeSession(currentTextE2eePairId()).encryptFile(arrayBufferCopy(buffer), transferId, seq);
}

async function decryptFileChunk(buffer, transferId, seq)
{
    return ensureTextE2eeSession(currentTextE2eePairId()).decryptFile(arrayBufferCopy(buffer), transferId, seq);
}

function isPrimaryView()
{
    return i == null;
}

// END exact source section: module-integration

// BEGIN exact source section: file-buffers
function fileByteLength(data)
{
    if (data instanceof ArrayBuffer)
    {
        return data.byteLength;
    }
    if (ArrayBuffer.isView(data))
    {
        return data.byteLength;
    }
    return 0;
}

function arrayBufferCopy(data)
{
    if (data instanceof ArrayBuffer)
    {
        return data.slice(0);
    }
    if (ArrayBuffer.isView(data))
    {
        return data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength);
    }
    return new ArrayBuffer(0);
}

function isBinaryMessageData(data)
{
    return data instanceof ArrayBuffer || ArrayBuffer.isView(data);
}

function readBlobAsArrayBuffer(blob)
{
    return new Promise(function (resolve, reject)
    {
        const reader = new FileReader();
        reader.onload = function () { resolve(reader.result); };
        reader.onerror = function () { reject(reader.error || new Error("file read failed")); };
        reader.readAsArrayBuffer(blob);
    });
}

function safeDownloadName(value)
{
    const name = String(value || "")
        .replace(/[\\\/]/g, " ")
        .replace(/[\x00-\x1f\x7f]/g, " ")
        .trim()
        .slice(0, 180);
    return name || "download";
}

// END exact source section: file-buffers

// BEGIN exact source section: file-controller
function createFileTransferController()
{
    const input = document.getElementById("fileInput");
    const sendButton = document.getElementById("fileActionSend");
    const cancelButton = document.getElementById("fileActionCancel");
    const progress = document.getElementById("fileProgress");
    const status = document.getElementById("fileStatus");
    const download = document.getElementById("fileReceivedDownload");
    const state = {
        selectedFile: null,
        outgoing: null,
        incoming: null,
        pendingOffer: null,
        receivedUrl: ""
    };

    function controlsAvailable()
    {
        return fileTransferRuntimeEnabled && input && sendButton && cancelButton && progress && status && download;
    }

    function setFileStatus(text)
    {
        if (status)
        {
            const key = textKey(text);
            if (key)
            {
                status.dataset.i18nKey = key;
                status.textContent = t(key);
            }
            else
            {
                delete status.dataset.i18nKey;
                status.textContent = text || "";
            }
        }
        updateFileActionButtons();
    }

    function updateFileActionButtons()
    {
        const available = Boolean(fileTransferRuntimeEnabled && input && sendButton && cancelButton && progress && status && download);
        const active = Boolean(state.outgoing || state.incoming || state.pendingOffer);
        if (sendButton)
        {
            sendButton.disabled = !available || active;
            sendButton.setAttribute("aria-disabled", sendButton.disabled ? "true" : "false");
        }
        if (cancelButton)
        {
            cancelButton.disabled = !available || !active;
            cancelButton.setAttribute("aria-disabled", cancelButton.disabled ? "true" : "false");
        }
    }

    function setFileProgress(value, max)
    {
        if (!progress)
        {
            return;
        }
        progress.max = Math.max(1, Number(max) || 100);
        progress.value = Math.max(0, Math.min(progress.max, Number(value) || 0));
    }

    function clearDownload()
    {
        if (state.receivedUrl)
        {
            URL.revokeObjectURL(state.receivedUrl);
        }
        state.receivedUrl = "";
        if (download)
        {
            download.hidden = true;
            download.removeAttribute("href");
            download.removeAttribute("download");
            download.textContent = t("action.download");
        }
    }

    function showDownload(name, blob)
    {
        clearDownload();
        state.receivedUrl = URL.createObjectURL(blob);
        download.href = state.receivedUrl;
        download.download = safeDownloadName(name);
        download.textContent = t("action.download");
        download.hidden = false;
    }

    function activeTransferId()
    {
        if (state.outgoing)
        {
            return state.outgoing.id;
        }
        if (state.incoming)
        {
            return state.incoming.id;
        }
        if (state.pendingOffer) return state.pendingOffer.id;
        return "";
    }

    function clearActiveTransfer(text)
    {
        state.outgoing = null;
        state.incoming = null;
        state.pendingOffer = null;
        setFileProgress(0, 100);
        setFileStatus(text || "Ready");
    }

    function cancelActiveTransfer(notifyPeer, text)
    {
        const transferId = activeTransferId();
        if (notifyPeer && transferId)
        {
            sendCommand("fileCancel", [transferId, "cancelled"]);
        }
        clearActiveTransfer(text || "Cancelled");
    }

    function selectedFileLabel(file)
    {
        if (!file)
        {
            return "file.ready";
        }
        return safeDownloadName(file.name) + " selected";
    }

    function filePlainChunkSize(file)
    {
        const maxPlainChunk = Math.max(1, fileChunkBytes - FILE_E2EE_FRAME_OVERHEAD);
        return Math.max(1, Math.min(maxPlainChunk, file.size));
    }

    function encryptedWireSize(fileSize, plainChunkSize)
    {
        const chunks = Math.ceil(fileSize / plainChunkSize);
        return fileSize + chunks * FILE_E2EE_FRAME_OVERHEAD;
    }

    function updateSelectedFile()
    {
        if (!controlsAvailable())
        {
            return;
        }
        state.selectedFile = input.files && input.files.length > 0 ? input.files[0] : null;
        if (!state.outgoing && !state.incoming && !state.pendingOffer)
        {
            setFileProgress(0, 100);
            setFileStatus(selectedFileLabel(state.selectedFile));
        }
    }

    async function sendNextChunk(transfer)
    {
        if (!state.outgoing || state.outgoing.id !== transfer.id)
        {
            return;
        }
        if (transfer.offset >= transfer.file.size)
        {
            sendCommand("fileDone", [transfer.id, String(transfer.totalChunks), String(transfer.wireSize)]);
            setFileStatus("Finishing");
            return;
        }
        const end = Math.min(transfer.offset + transfer.chunkSize, transfer.file.size);
        const buffer = await readBlobAsArrayBuffer(transfer.file.slice(transfer.offset, end));
        const encryptedBuffer = await encryptFileChunk(buffer, transfer.id, transfer.seq);
        if (!state.outgoing || state.outgoing.id !== transfer.id)
        {
            return;
        }
        transfer.pendingPlainBytes = fileByteLength(buffer);
        transfer.pendingBytes = fileByteLength(encryptedBuffer);
        transfer.awaitingAck = true;
        if (!sendCommand("fileChunk", [transfer.id, String(transfer.seq), String(transfer.pendingBytes)])
            || !sendBinaryFrame(encryptedBuffer))
        {
            clearActiveTransfer("Connection lost");
            return;
        }
        setFileStatus("Sending");
    }

    async function sendSelectedFile()
    {
        if (!controlsAvailable())
        {
            return;
        }
        clearDownload();
        updateSelectedFile();
        const file = state.selectedFile;
        if (!file)
        {
            setFileStatus("Choose a file");
            return;
        }
        if (file.size <= 0)
        {
            setFileStatus("Empty file");
            return;
        }
        if (file.size > fileMaxBytes)
        {
            setFileStatus("File too large");
            return;
        }
        if (document.body.dataset.wsState !== "connected")
        {
            setFileStatus("Peer offline");
            return;
        }
        if (!textE2eeReady())
        {
            setFileStatus("Transfer failed");
            return;
        }
        if (state.outgoing || state.incoming || state.pendingOffer)
        {
            setFileStatus("Transfer active");
            return;
        }
        const chunkSize = filePlainChunkSize(file);
        const wireSize = encryptedWireSize(file.size, chunkSize);
        if (wireSize <= file.size)
        {
            setFileStatus("Transfer failed");
            return;
        }
        const transfer = {
            id: newTransferId(),
            file,
            chunkSize,
            wireSize,
            encryptedChunkSize: chunkSize + FILE_E2EE_FRAME_OVERHEAD,
            totalChunks: Math.ceil(file.size / chunkSize),
            offset: 0,
            seq: 0,
            pendingBytes: 0,
            pendingPlainBytes: 0,
            awaitingAck: false
        };
        state.outgoing = transfer;
        setFileProgress(0, file.size);
        setFileStatus("Waiting for peer");
        const session = ensureTextE2eeSession(currentTextE2eePairId());
        const offer = await session.signFileOffer([
            transfer.id,
            safeDownloadName(file.name),
            String(file.size),
            file.type || "application/octet-stream",
            String(transfer.encryptedChunkSize),
            String(wireSize),
            "e2ee-v3"
        ]);
        if (state.outgoing === transfer && session === e2eeSession)
            sendCommand("fileOffer", offer);
    }

    async function handleFileOffer(args)
    {
        if (!controlsAvailable())
        {
            sendCommand("fileReject", [args[0] || "", "disabled"]);
            return;
        }
        const transferId = args[0] || "";
        const name = safeDownloadName(args[1]);
        const size = Number(args[2]);
        const mime = args[3] || "application/octet-stream";
        const chunkSize = Number(args[4]);
        const wireSize = Number(args[5]);
        if (state.outgoing || state.incoming || state.pendingOffer)
        {
            sendCommand("fileReject", [transferId, "busy"]);
            return;
        }
        if (args[6] !== "e2ee-v3" || !textE2eeReady())
        {
            sendCommand("fileReject", [transferId, "encryption-required"]);
            setFileStatus("file.encryptionRequired");
            return;
        }
        if (!transferId || !Number.isSafeInteger(size) || size <= 0 || size > fileMaxBytes
            || !Number.isSafeInteger(chunkSize) || chunkSize <= FILE_E2EE_FRAME_OVERHEAD || chunkSize > fileChunkBytes
            || !Number.isSafeInteger(wireSize) || wireSize <= size)
        {
            sendCommand("fileReject", [transferId, "invalid"]);
            return;
        }
        const pending = { id: transferId };
        state.pendingOffer = pending;
        updateFileActionButtons();
        const session = ensureTextE2eeSession(currentTextE2eePairId());
        let verified = false;
        try { verified = await session.verifyFileOffer(args); }
        catch (error) {}
        if (state.pendingOffer !== pending) return;
        state.pendingOffer = null;
        if (!verified || session !== e2eeSession || !textE2eeReady())
        {
            sendCommand("fileReject", [transferId, "encryption-required"]);
            setFileStatus("file.encryptionRequired");
            return;
        }
        clearDownload();
        state.incoming = {
            id: transferId,
            name,
            size,
            wireSize,
            mime,
            chunkSize,
            expectedSeq: 0,
            receivedBytes: 0,
            receivedPlainBytes: 0,
            parts: [],
            pending: null
        };
        setFileProgress(0, size);
        setFileStatus("Receiving");
        sendCommand("fileAccept", [transferId]);
    }

    function handleFileAccept(args)
    {
        const transferId = args[0] || "";
        if (!state.outgoing || state.outgoing.id !== transferId)
        {
            return;
        }
        sendNextChunk(state.outgoing).catch(function ()
        {
            cancelActiveTransfer(true, "Read failed");
        });
    }

    function handleFileChunk(args)
    {
        const transferId = args[0] || "";
        const seq = Number(args[1]);
        const byteLength = Number(args[2]);
        const transfer = state.incoming;
        if (!transfer || transfer.id !== transferId || !Number.isInteger(seq) || seq !== transfer.expectedSeq
            || !Number.isInteger(byteLength) || byteLength <= 0 || byteLength > transfer.chunkSize
            || transfer.receivedBytes + byteLength > transfer.wireSize)
        {
            cancelActiveTransfer(true, "Transfer failed");
            return;
        }
        transfer.pending = {
            seq,
            byteLength
        };
        setFileStatus("Receiving");
    }

    function handleBinary(data)
    {
        const transfer = state.incoming;
        if (!transfer || !transfer.pending)
        {
            return;
        }
        const byteLength = fileByteLength(data);
        if (byteLength !== transfer.pending.byteLength)
        {
            cancelActiveTransfer(true, "Transfer failed");
            return;
        }
        const pending = transfer.pending;
        decryptFileChunk(data, transfer.id, pending.seq).then(function (plain)
        {
            const current = state.incoming;
            if (!current || current.id !== transfer.id || current.pending !== pending)
            {
                return;
            }
            const plainBytes = fileByteLength(plain);
            if (plainBytes <= 0 || current.receivedPlainBytes + plainBytes > current.size)
            {
                cancelActiveTransfer(true, "Transfer failed");
                return;
            }
            current.parts.push(arrayBufferCopy(plain));
            current.receivedBytes += byteLength;
            current.receivedPlainBytes += plainBytes;
            current.expectedSeq += 1;
            current.pending = null;
            setFileProgress(current.receivedPlainBytes, current.size);
            sendCommand("fileChunkAck", [current.id, String(pending.seq)]);
        }).catch(function ()
        {
            cancelActiveTransfer(true, "Transfer failed");
        });
    }

    function handleFileChunkAck(args)
    {
        const transferId = args[0] || "";
        const seq = Number(args[1]);
        const transfer = state.outgoing;
        if (!transfer || transfer.id !== transferId || seq !== transfer.seq || !transfer.awaitingAck)
        {
            return;
        }
        transfer.offset += transfer.pendingPlainBytes;
        transfer.seq += 1;
        transfer.pendingBytes = 0;
        transfer.pendingPlainBytes = 0;
        transfer.awaitingAck = false;
        setFileProgress(transfer.offset, transfer.file.size);
        sendNextChunk(transfer).catch(function ()
        {
            cancelActiveTransfer(true, "Read failed");
        });
    }

    function handleFileDone(args)
    {
        const transferId = args[0] || "";
        const totalBytes = Number(args[2]);
        const transfer = state.incoming;
        if (!transfer || transfer.id !== transferId || transfer.pending || totalBytes !== transfer.wireSize
            || transfer.receivedBytes !== transfer.wireSize || transfer.receivedPlainBytes !== transfer.size)
        {
            cancelActiveTransfer(true, "Transfer failed");
            return;
        }
        const blob = new Blob(transfer.parts, { type: transfer.mime || "application/octet-stream" });
        showDownload(transfer.name, blob);
        sendCommand("fileReceived", [transfer.id]);
        state.incoming = null;
        setFileProgress(totalBytes, transfer.size);
        setFileStatus("Received");
    }

    function handleFileReceived(args)
    {
        const transferId = args[0] || "";
        if (!state.outgoing || state.outgoing.id !== transferId)
        {
            return;
        }
        state.outgoing = null;
        setFileProgress(100, 100);
        setFileStatus("Sent");
    }

    function handleFileFinishedByPeer(text)
    {
        clearActiveTransfer(text);
    }

    function handleCommand(cmd, args)
    {
        switch (cmd)
        {
            case "fileOffer":
                handleFileOffer(args);
                return true;
            case "fileAccept":
                handleFileAccept(args);
                return true;
            case "fileChunk":
                handleFileChunk(args);
                return true;
            case "fileChunkAck":
                handleFileChunkAck(args);
                return true;
            case "fileDone":
                handleFileDone(args);
                return true;
            case "fileReceived":
                handleFileReceived(args);
                return true;
            case "fileReject":
                handleFileFinishedByPeer("Rejected");
                return true;
            case "fileCancel":
                handleFileFinishedByPeer("Cancelled");
                return true;
            case "fileError":
                handleFileFinishedByPeer("Transfer failed");
                return true;
            default:
                return false;
        }
    }

    function bind()
    {
        if (!controlsAvailable())
        {
            setFileProgress(0, 100);
            setFileStatus(fileTransferRuntimeEnabled ? "Unavailable" : "Disabled");
            return;
        }
        input.onchange = updateSelectedFile;
        sendButton.onclick = function ()
        {
            sendSelectedFile().catch(function ()
            {
                cancelActiveTransfer(true, "Read failed");
            });
        };
        cancelButton.onclick = function ()
        {
            cancelActiveTransfer(true, "Cancelled");
        };
        setFileProgress(0, 100);
        setFileStatus(selectedFileLabel(state.selectedFile));
    }

    return {
        bind,
        handleCommand,
        handleBinary,
        cancelLocal: function (text)
        {
            clearActiveTransfer(text || "Ready");
        }
    };
}

// END exact source section: file-controller