tools/fuzz.mjs
All files · Open raw file
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;
}
}