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