IcyZip

tools/fuzz.mjs

Alle Dateien · Rohdatei öffnen

6617 bytes · SHA-256: d797dec27e2dcdc830946f950c819b4dd07bb941e0ac29f3fff35df4ba3d442e

// Copyright 2026 Richard Andresik. SPDX-License-Identifier: Apache-2.0
// Seed controls synthetic inputs and mutations, never cryptographic entropy.
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { paired, E2EE } from "../test/endpoint.mjs";

export async function fuzz({ seed = 1387, iterations = 256, makePair = paired } = {}) {
    assert.ok(Number.isInteger(seed) && seed > 0 && seed <= 0xffffffff, "seed must be an integer from 1 to 4294967295");
    assert.ok(Number.isInteger(iterations) && iterations > 0 && iterations <= 100000,
        "iterations must be an integer from 1 to 100000");
    let state = seed >>> 0;
    const next = () => {
        state ^= state << 13; state ^= state >>> 17; state ^= state << 5;
        return state >>> 0;
    };
    const pick = size => next() % size;
    const plan = createHash("sha256");
    const peers = await makePair();
    let rejected = 0;
    let roundTrips = 0;
    const changeBytes = (value, offset, bit) => {
        const bytes = Buffer.from(value, "base64url");
        bytes[offset % bytes.length] ^= 1 << bit;
        return bytes.toString("base64url");
    };
    try {
        for (let index = 0; index < iterations; index++) {
            const [sender, receiver] = index % 2 ? [peers.secondary, peers.primary] : [peers.primary, peers.secondary];
            const selector = Array.from({ length: 12 }, () => next());
            const length = [0, 1, 7, 255, 4096, 65536][index % 6];
            const text = "🧊\u0000\n漢字" + Array.from({ length }, () => String.fromCharCode(32 + pick(95))).join("");
            const plain = Uint8Array.from({ length: 1 + selector[0] % 4096 }, () => pick(256));
            plan.update(JSON.stringify([index, selector, text, Buffer.from(plain).toString("base64")]));
            try {
                const pub = await sender.session.publicMessage();
                const alteredPub = pub.slice();
                alteredPub[2] = changeBytes(pub[2], selector[1], selector[2] % 8);
                await assert.rejects(receiver.session.acceptPublicMessage(alteredPub), "altered key authenticator accepted");
                rejected++;
                await receiver.session.acceptPublicMessage(pub);

                const wire = await sender.session.encryptText(text, index + 1, "fuzz-" + sender.role);
                const altered = JSON.parse(wire);
                switch (selector[3] % 6) {
                    case 0: altered.c = changeBytes(altered.c, selector[4], selector[5] % 8); break;
                    case 1: altered.n = changeBytes(altered.n, selector[4], selector[5] % 8); break;
                    case 2: altered.c = altered.c.slice(0, -4); break;
                    case 3: altered.v = 1; break;
                    case 4: altered.alg = "A128GCM"; break;
                    case 5: altered.o = "relay"; break;
                }
                await assert.rejects(receiver.session.decryptText(JSON.stringify(altered)), "altered text accepted");
                rejected++;
                assert.deepEqual(await receiver.session.decryptText(wire),
                    { text, updatedAt: index + 1, origin: "fuzz-" + sender.role });
                roundTrips++;

                const transfer = "fuzz-file-" + index;
                const frame = new Uint8Array(await sender.session.encryptFile(plain, transfer, index));
                const badFrame = frame.slice();
                badFrame[selector[6] % frame.length] ^= 1 << (selector[7] % 8);
                await assert.rejects(receiver.session.decryptFile(badFrame, transfer, index), "altered file accepted");
                rejected++;
                assert.deepEqual(new Uint8Array(await receiver.session.decryptFile(frame, transfer, index)), plain);
                roundTrips++;

                const fields = [transfer, "synthetic.bin", String(plain.length), "application/octet-stream",
                    String(frame.length), String(frame.length), "e2ee-v3"];
                const offer = await sender.session.signFileOffer(fields);
                const badOffer = offer.slice();
                const field = selector[8] % 8;
                badOffer[field] = field === 7 ? changeBytes(offer[7], selector[9], selector[10] % 8) : offer[field] + "1";
                assert.equal(await receiver.session.verifyFileOffer(badOffer).catch(() => false), false, "altered offer accepted");
                rejected++;
                assert.equal(await receiver.session.verifyFileOffer(offer), true);

                const fragment = E2EE.fragmentForSecret(sender.secret);
                const badFragment = [fragment + "&v=3", fragment + "&extra=1", fragment.replace("v=3", "v=2"),
                    fragment.replace("#k=", "#k=!")][selector[11] % 4];
                assert.equal(E2EE.parseFragment(badFragment), null, "malformed link accepted");
                rejected++;
                assert.equal(E2EE.parseFragment(fragment), sender.secret);

                const reply = await receiver.session.encryptText("reply " + index, index + 2, "fuzz-reply");
                assert.equal((await sender.session.decryptText(reply)).text, "reply " + index);
                const progress = await sender.session.encryptText("after rejection " + index, index + 3, "fuzz-progress");
                assert.equal((await receiver.session.decryptText(progress)).text, "after rejection " + index);
                roundTrips += 2;
            } catch (error) {
                throw new Error("fuzz seed=" + seed + " iteration=" + index + ": " + error.message, { cause: error });
            }
        }
    } finally {
        peers.primary.session.close(); peers.secondary.session.close();
    }
    return { seed, iterations, rejected, roundTrips, planSha256: plan.digest("hex") };
}

if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
    try {
        const options = {};
        for (let index = 2; index < process.argv.length; index += 2) {
            const key = process.argv[index];
            assert.ok(["--seed", "--iterations"].includes(key), "Use --seed INTEGER and --iterations INTEGER");
            assert.ok(process.argv[index + 1] !== undefined, key + " requires an integer");
            options[key.slice(2)] = Number(process.argv[index + 1]);
        }
        console.log(JSON.stringify({ result: "fuzz-pass", ...await fuzz(options) }));
    } catch (error) {
        console.error(error.message);
        process.exitCode = 1;
    }
}