An end-to-end encryption test should do more than prove that one browser can decrypt what another browser encrypted. It should impersonate an active relay, change authenticated metadata, move ciphertext into the wrong context, attempt a downgrade, interrupt state transitions, and then verify two outcomes: every attack is rejected, and honest communication still works afterward. This article develops that method against a small browser E2EE package that runs locally with Node.js and no server.
The smallest useful encryption test looks familiar:
const wire = await sender.encryptText("hello", 1, "primary");
const value = await receiver.decryptText(wire);
assert.equal(value.text, "hello");
This detects broken wiring. It does not establish that an attacker cannot replace a public key, alter a revision number, replay a file chunk in another transfer, or leave the session permanently unusable after a rejected message.
There is another trap: an implementation and its test helper can share the same mistake. If both derive the wrong key in the same way, the round trip remains green. A credible suite therefore needs three different views of the protocol:
The worked example is the Apache-2.0 IcyZip E2EE review package. Its browser module uses P-256 ECDH, HKDF-SHA-256, HMAC-SHA-256, and AES-256-GCM through the Web Cryptography API. The design is specific to this application, not a general-purpose cryptography library.
The threat boundary matters. A random 32-byte secret travels in the pairing URL fragment. It authenticates each peer's public key before that key is imported for ECDH. A relay can still observe operational metadata such as pair identifiers, public keys, authentication tags, ciphertext sizes, timing, connection data, and file-offer fields including the filename, MIME hint, and sizes. These tests address the documented E2EE boundary; they do not turn metadata into ciphertext or make a leaked pairing link harmless.
The browser module derives four separate keys: public-key authentication, text, file chunks, and file offers. The test suite independently repeats that calculation with Node's node:crypto API rather than calling the module's derivation helper.
The essential shape is:
const ecdh = createECDH("prime256v1");
ecdh.setPrivateKey(privateScalar);
const shared = ecdh.computeSecret(peerPublicKey);
const material = Buffer.concat([shared, pairingSecret]);
const salt = sha256(canonical(["v3", pairId, primaryKey, secondaryKey]));
const textKey = hkdfSync(
"sha256",
material,
salt,
"icyzip/e2ee/text/v3",
32
);
The oracle separately derives all four contexts and asserts that all four results differ. It then verifies a public-key HMAC, decrypts a text envelope with Node's AES-GCM implementation, checks a file-offer HMAC, and decrypts a file frame.
This does not create an independent security proof. It does remove a common false green: the production helper cannot agree with itself and call that agreement validation. The protocol contexts are also visible enough for a reviewer to compare them with the HKDF definition in RFC 5869 and the Web Cryptography API.
An unauthenticated ECDH exchange is vulnerable to an active intermediary. The relay can establish one key with each browser and read or replace everything between them. Encrypting the transport does not repair this; the peers must authenticate what they import.
In this design, the URL-fragment secret derives an HMAC key. Each browser signs a canonical value containing the protocol version, pair identifier, sender role, and public key. The receiving browser verifies that tag before importing the peer key.
The regression test creates two honest endpoints and two relay endpoints that do not possess the fragment secret:
await assert.rejects(
primary.acceptPublicMessage(await relayTowardPrimary.publicMessage())
);
await assert.rejects(
secondary.acceptPublicMessage(await relayTowardSecondary.publicMessage())
);
assert.equal(primary.ready(), false);
assert.equal(secondary.ready(), false);
A second test instruments subtle.importKey. After the invalid HMAC is rejected, the counter for raw ECDH public-key imports must still be zero. That ordering assertion is stronger than merely checking that a later operation throws: the untrusted key never reaches the ECDH import step.
The mutation table then changes one item at a time: role, pair, version, public point, tag, encoding, array length, and reflected self-message. Every variation must fail.
Finally, the honest peers connect and exchange a new encrypted message. This last assertion is not decoration. It proves that rejecting the relay did not poison the state machine.
Applications often encrypt the visible text but leave control fields beside it. A relay that cannot read the content may still be able to change which edit appears newest or which endpoint appears to have originated it.
The text plaintext in this protocol is one authenticated object:
{
text: "unchanged plaintext",
t: 4503599627370497,
o: "original-origin"
}
AES-GCM encrypts that object. The envelope contains only the version, algorithm identifier, nonce, and ciphertext. The pair and text-protocol identifiers are additional authenticated data (AAD):
additionalData: canonical(["icyzip/text/v2", pairId])
The test first confirms that neither the revision nor origin is visible in the serialized envelope. It then adds forged t and o properties outside the ciphertext. The parser rejects the envelope because its shape is no longer exact. Bit flips, a substituted nonce, truncated ciphertext, a changed algorithm, a changed version, another pair's key, and authenticated-but-malformed plaintext metadata also fail.
There is an important distinction here. AES-GCM can authenticate bytes, but it does not decide whether a decoded revision is a non-negative safe integer or whether an origin string is within the application's limits. Cryptographic validation and schema validation are separate gates, and the test suite exercises both.
After every group of mutations, the original envelope must still decrypt. Again, failure is only half the contract.
A valid ciphertext is not necessarily valid everywhere. Consider a chunk from sequence 3 of transfer A. If the receiver also accepts it as sequence 2, or as part of transfer B, an attacker may reorder or splice authenticated data without breaking the tag.
The file-chunk AAD binds four values:
["icyzip/file/chunk/v3", pairId, transferId, sequence]
The mutation test encrypts 65,508 bytes, producing a 65,536-byte frame after the 12-byte nonce and 16-byte GCM tag overhead. It verifies the exact plaintext, then tries:
Every variation rejects. The untouched frame then decrypts again in both directions.
The same principle applies before any chunks arrive. A file offer exposes operational fields needed by the transfer UI and relay. The offer HMAC covers the transfer identifier, filename, plaintext size, MIME hint, wire sizes, and encryption marker together with the pair and sender role. The suite changes every visible field separately, reflects an offer back to its sender, and removes the signature. None is accepted.
This is a useful review question for any encrypted file protocol: which values control allocation, progress, ordering, or the final download, and exactly where are they authenticated?
An encryption marker is only a claim. If a receiver sees encrypted=true and then treats the following bytes as trustworthy, a relay may be able to label plaintext as encrypted or downgrade a session to a legacy path.
The receive-controller tests therefore include more than cryptographic primitives. They use the real published file-receive integration and assert that:
That last property guards against a crude fail-closed implementation that responds to one bad frame by disabling file transfer forever. Availability after rejection is part of the security behavior because permanent failure invites unsafe recovery workarounds.
Browser cryptography is asynchronous. A user can cancel, reload, lose storage access, or reset a peer while authentication is still resolving. Tests that await each clean step in isolation miss those races.
The session maintains an epoch. Accepting a public message records the current epoch; a peer reset increments it. If authentication finishes after the reset, the operation returns without installing the stale keys.
The suite deliberately starts public-message acceptance, resets the peer before completion, and asserts that the pending operation cannot revive the old state. Separate cases cover:
These are not exotic browser trivia. They test whether authenticated data crosses a lifecycle boundary that the user has already closed.
Random mutation is easy to overstate. A fuzzer that prints success after throwing 1,000 malformed inputs at a parser may only prove that the parser threw 1,000 times. It may not notice that an attack was accepted, that valid traffic stopped working, or that a run cannot be reproduced.
The review package uses a deterministic plan seed for synthetic inputs and mutations. The seed does not control cryptographic entropy. Each iteration performs five negative checks:
Between and after those failures, it checks valid text, file, offer, fragment, reply, and forward-progress paths. The tool records the mutation plan's SHA-256 digest so another run can confirm that it exercised the same plan.
A fresh default run for this article reported:
{
"result": "fuzz-pass",
"seed": 1387,
"iterations": 256,
"rejected": 1280,
"roundTrips": 1024,
"planSha256": "297acd86f43795a7e1c13c1af6c7df43f1d454a4ca9333fc9f24cdda0e864a75"
}
The full suite passed 31 of 31 tests in the same fresh run. These counts are evidence for the published cases and inputs, not evidence that no vulnerability exists.
An anti-false-green test intentionally weakens the fuzzer's cryptographic endpoint and confirms that the harness fails instead of counting an accepted attack as a success. Testing the test is especially valuable when a compact summary number will be copied into documentation.
A beautifully tested reimplementation can say nothing about the code users actually receive. The package therefore records source anchors, byte counts, and SHA-256 hashes for the complete browser module and security-relevant integration sections. Provenance tests reject changed module bytes, changed extracted sections, missing boundaries, and duplicate boundaries.
This is still not an independent audit, and a passing hash cannot find a design flaw. It answers a narrower but necessary question: are reviewers and tests looking at the intended bytes, or at a hand-cleaned example that drifted away from the application?
The package also offers an optional live comparison restricted to two fixed HTTPS assets. The ordinary cryptographic and regression tests themselves need no account, server, package installation, or network connection beyond obtaining the source. They run with Node.js 20 or newer and built-in modules, using the standard Node test runner.
git clone https://icyzip.com/open-source/icyzip-e2ee.git
cd icyzip-e2ee
npm test
npm run fuzz
For browser E2EE, I no longer accept “encrypt, decrypt, compare” as the whole test story. A useful review suite should make these statements executable:
A green suite cannot prove the absence of weaknesses, just as an empty issue tracker cannot. What it can do is make specific security claims falsifiable. That is the useful standard: name the attack, encode it, require rejection, and then prove the system can continue safely.