IcyZip

test/crypto.test.mjs

All files · Open raw file

14259 bytes · SHA-256: 6b046c1a4d2011b5454ac7965827955dca8a76ed0298f9b9025c1d8f4f2d5168

// Copyright 2026 Richard Andresik. SPDX-License-Identifier: Apache-2.0
import assert from "node:assert/strict";
import { createCipheriv, createDecipheriv, createECDH, createHmac, createHash, hkdfSync } from "node:crypto";
import test from "node:test";
import { E2EE, webcrypto, client, connect, paired, memoryStorage } from "./endpoint.mjs";

const b64 = value => Buffer.from(value).toString("base64url");
const raw = value => Buffer.from(value, "base64url");
const json = value => Buffer.from(JSON.stringify(value));
const fields = (id = "synthetic-file") => [id, "example.txt", "5", "text/plain", "33", "33", "e2ee-v3"];

async function oracleKeys(primary, secondary)
{
    const own = JSON.parse(primary.storage.getItem(E2EE.KEYPAIR_PREFIX + primary.pairId));
    const peer = (await secondary.session.publicMessage())[1];
    const ecdh = createECDH("prime256v1");
    ecdh.setPrivateKey(raw(own.privateKey.d));
    const material = Buffer.concat([ecdh.computeSecret(raw(peer)), raw(primary.secret)]);
    const salt = createHash("sha256").update(json(["v3", primary.pairId, own.publicKey, peer])).digest();
    const derive = (context, ikm = material, selectedSalt = salt) => Buffer.from(hkdfSync("sha256", ikm, selectedSalt, context, 32));
    return { text: derive("icyzip/e2ee/text/v3"), file: derive("icyzip/e2ee/file-chunk/v3"),
        offer: derive("icyzip/e2ee/file-offer/v3"),
        auth: derive("icyzip/e2ee/pub-auth/v3", raw(primary.secret), Buffer.from(primary.pairId)) };
}

function seal(key, pairId, value)
{
    const nonce = webcrypto.getRandomValues(new Uint8Array(12));
    const cipher = createCipheriv("aes-256-gcm", key, nonce);
    cipher.setAAD(json(["icyzip/text/v2", pairId]));
    const content = Buffer.concat([cipher.update(json(value)), cipher.final(), cipher.getAuthTag()]);
    return JSON.stringify({ v: 2, alg: "A256GCM", n: b64(nonce), c: b64(content) });
}

test("pairing links contain only a canonical browser-fragment v3 secret", () => {
    const seen = new Set();
    for (let index = 0; index < 64; index += 1)
    {
        const secret = E2EE.generateSecret(webcrypto);
        assert.equal(raw(secret).length, 32);
        assert.equal(secret.length, 43);
        seen.add(secret);
        const link = new URL("https://icyzip.com/?i=synthetic" + E2EE.fragmentForSecret(secret));
        assert.equal(link.search, "?i=synthetic");
        assert.equal(E2EE.parseFragment(link.hash), secret);
        for (const invalid of ["", "#", "#k=" + secret, "#k=" + secret + "&v=2",
            "#k=" + secret + "=&v=3", "#k=" + secret.slice(1) + "&v=3",
            "#k=" + secret + "&v=3&v=3", "#k=" + secret + "&v=3&extra=1"])
            assert.equal(E2EE.parseFragment(invalid), null);
    }
    assert.equal(seen.size, 64);
    assert.throws(() => client({ secret: "" }));
    assert.throws(() => E2EE.fragmentForSecret("invalid"));
});

test("a relay key without the pairing secret is rejected before ECDH import", async () => {
    let peerImports = 0;
    const crypto = { getRandomValues: value => webcrypto.getRandomValues(value),
        subtle: new Proxy(webcrypto.subtle, { get(target, key) {
            if (key === "importKey") return (...args) => {
                if (args[0] === "raw" && args[2].name === "ECDH") peerImports += 1;
                return target.importKey(...args);
            };
            const value = target[key];
            return typeof value === "function" ? value.bind(target) : value;
        } }) };
    const honest = client({ crypto });
    const attacker = client({ role: "secondary" });
    await assert.rejects(honest.session.acceptPublicMessage(await attacker.session.publicMessage()));
    assert.equal(peerImports, 0);
    assert.equal(honest.session.ready(), false);
    await assert.rejects(honest.session.encryptText("blocked", 1, "origin"));
});

test("role, pair, version, public key and tag mutations cannot authenticate", async () => {
    const primary = client();
    const secondary = client({ role: "secondary", secret: primary.secret });
    const valid = await secondary.session.publicMessage();
    const reflected = await primary.session.publicMessage();
    const otherPair = await client({ pairId: "other-pair", role: "secondary", secret: primary.secret }).session.publicMessage();
    const changedPoint = await client({ role: "secondary", secret: primary.secret }).session.publicMessage();
    for (const altered of [reflected, otherPair, ["v2", ...valid.slice(1)], valid.slice(0, 2),
        [valid[0], changedPoint[1], valid[2]], [valid[0], valid[1], valid[2].slice(1)],
        [valid[0], valid[1], b64(Buffer.alloc(32))], [valid[0], valid[1] + "=", valid[2]], valid.concat("extra")])
    {
        await assert.rejects(primary.session.acceptPublicMessage(altered));
        assert.equal(primary.session.ready(), false);
    }
    await connect(primary, secondary);
    assert.equal((await secondary.session.decryptText(await primary.session.encryptText("later valid", 1, "a"))).text, "later valid");
});

test("independent Node oracle matches public authentication and all separated keys", async () => {
    const { primary, secondary } = await paired();
    const keys = await oracleKeys(primary, secondary);
    assert.equal(new Set(Object.values(keys).map(b64)).size, 4);
    const message = await primary.session.publicMessage();
    const expected = createHmac("sha256", keys.auth).update(json(["icyzip/e2ee/pub/v3", primary.pairId, "primary", message[1]])).digest();
    assert.equal(message[2], b64(expected));
    const envelope = JSON.parse(await primary.session.encryptText("independent 🧊", 4503599627370497, "oracle"));
    const cipher = raw(envelope.c);
    const decoder = createDecipheriv("aes-256-gcm", keys.text, raw(envelope.n));
    decoder.setAAD(json(["icyzip/text/v2", primary.pairId]));
    decoder.setAuthTag(cipher.subarray(-16));
    const plain = Buffer.concat([decoder.update(cipher.subarray(0, -16)), decoder.final()]);
    assert.deepEqual(JSON.parse(plain), { text: "independent 🧊", t: 4503599627370497, o: "oracle" });
    const offer = await primary.session.signFileOffer(fields());
    assert.equal(offer[7], b64(createHmac("sha256", keys.offer)
        .update(json(["icyzip/file/offer/v3", primary.pairId, "primary", fields()])).digest()));
    const frame = Buffer.from(await primary.session.encryptFile(new Uint8Array([0, 17, 255, 3, 128]), "oracle-file", 2));
    const fileDecoder = createDecipheriv("aes-256-gcm", keys.file, frame.subarray(0, 12));
    fileDecoder.setAAD(json(["icyzip/file/chunk/v3", primary.pairId, "oracle-file", 2]));
    fileDecoder.setAuthTag(frame.subarray(-16));
    assert.deepEqual(Buffer.concat([fileDecoder.update(frame.subarray(12, -16)), fileDecoder.final()]), Buffer.from([0, 17, 255, 3, 128]));
});

test("text round trips in both directions with hidden authenticated revision and origin", async () => {
    const { primary, secondary } = await paired();
    const nonces = new Set();
    for (const [sender, receiver] of [[primary, secondary], [secondary, primary]])
    {
        for (const text of ["", "Unicode 🧊 漢字\n\u0000", "a".repeat(65536)])
        {
            const wire = await sender.session.encryptText(text, 4503599627370555, "test-origin");
            const envelope = JSON.parse(wire);
            assert.deepEqual(Object.keys(envelope), ["v", "alg", "n", "c"]);
            assert.equal(envelope.v, 2);
            assert.equal(wire.includes("test-origin"), false);
            assert.equal(wire.includes("4503599627370555"), false);
            nonces.add(envelope.n);
            assert.deepEqual(await receiver.session.decryptText(wire), { text, updatedAt: 4503599627370555, origin: "test-origin" });
        }
    }
    assert.equal(nonces.size, 6);
});

test("revision, origin, envelope and ciphertext mutations reject, then valid text progresses", async () => {
    const { primary, secondary } = await paired();
    const wire = await primary.session.encryptText("unchanged content", 4503599627370500, "honest");
    const envelope = JSON.parse(wire);
    for (const change of [item => { item.t = Number.MAX_SAFE_INTEGER; }, item => { item.o = "relay"; },
        item => { item.v = 1; }, item => { item.alg = "A128GCM"; }, item => { item.n = b64(Buffer.alloc(12)); },
        item => { const bytes = raw(item.c); bytes[0] ^= 1; item.c = b64(bytes); },
        item => { item.c = item.c.slice(0, -4); }, item => { item.c += "="; }])
    {
        const altered = { ...envelope }; change(altered);
        await assert.rejects(secondary.session.decryptText(JSON.stringify(altered)));
    }
    for (const invalid of ["not json", "null", "[]", "{}"])
        await assert.rejects(secondary.session.decryptText(invalid));
    assert.equal((await secondary.session.decryptText(wire)).text, "unchanged content");
    const other = await paired();
    await assert.rejects(other.secondary.session.decryptText(wire));
});

test("authenticated malformed metadata and cross-domain keys still fail closed", async () => {
    const { primary, secondary } = await paired();
    const keys = await oracleKeys(primary, secondary);
    const valid = { text: "value", t: 4503599627370497, o: "oracle" };
    for (const altered of [{ ...valid, text: 3 }, { ...valid, t: "5" }, { ...valid, t: -1 },
        { ...valid, t: Number.MAX_SAFE_INTEGER + 1 }, { ...valid, t: 1.5 }, { ...valid, o: "" },
        { ...valid, o: "x".repeat(129) }, { ...valid, extra: 1 }])
        await assert.rejects(secondary.session.decryptText(seal(keys.text, primary.pairId, altered)));
    for (const key of [keys.file, keys.offer, keys.auth])
        await assert.rejects(secondary.session.decryptText(seal(key, primary.pairId, valid)));
    await assert.rejects(secondary.session.decryptText(seal(keys.text, "other-pair", valid)));
    assert.equal((await secondary.session.decryptText(seal(keys.text, primary.pairId, valid))).text, "value");
});

test("every visible file-offer field, role reflection and unsigned offer reject", async () => {
    const { primary, secondary } = await paired();
    const signed = await primary.session.signFileOffer(fields());
    assert.equal(await secondary.session.verifyFileOffer(signed), true);
    assert.equal(await secondary.session.verifyFileOffer(fields()), false);
    assert.equal(await primary.session.verifyFileOffer(signed), false);
    for (const [index, replacement] of [[0, "other"], [1, "renamed.txt"], [2, "6"], [3, "image/png"],
        [4, "34"], [5, "34"], [6, "e2ee-v1"], [7, b64(Buffer.alloc(32))]])
    {
        const altered = signed.slice(); altered[index] = replacement;
        assert.equal(await secondary.session.verifyFileOffer(altered).catch(() => false), false);
    }
    assert.equal(await secondary.session.verifyFileOffer(signed), true);
});

test("file chunks bind transfer and sequence, reject mutations, and preserve exact bytes", async () => {
    const { primary, secondary } = await paired();
    for (const [sender, receiver] of [[primary, secondary], [secondary, primary]])
    {
        const plain = Uint8Array.from({ length: 65508 }, (_, index) => (index * 37 + 19) % 256);
        const encrypted = await sender.session.encryptFile(plain, "synthetic-file", 3);
        assert.equal(encrypted.byteLength, 65536);
        assert.deepEqual(new Uint8Array(await receiver.session.decryptFile(encrypted, "synthetic-file", 3)), plain);
        for (const [id, sequence] of [["wrong", 3], ["synthetic-file", 2], ["synthetic-file", -1]])
            await assert.rejects(receiver.session.decryptFile(encrypted, id, sequence));
        for (const index of [0, 12, encrypted.byteLength - 1])
        {
            const altered = new Uint8Array(encrypted.slice(0)); altered[index] ^= 1;
            await assert.rejects(receiver.session.decryptFile(altered, "synthetic-file", 3));
        }
        await assert.rejects(receiver.session.decryptFile(new Uint8Array(28), "synthetic-file", 3));
        assert.deepEqual(new Uint8Array(await receiver.session.decryptFile(encrypted, "synthetic-file", 3)), plain);
    }
});

test("tab reload reuses matching keys; damaged storage rotates consistently; reset cannot revive stale work", async () => {
    const { primary, secondary } = await paired();
    const previous = await secondary.session.publicMessage();
    const reloaded = client({ ...secondary, storage: secondary.storage });
    assert.deepEqual(await reloaded.session.publicMessage(), previous);
    await connect(primary, reloaded);
    assert.equal((await reloaded.session.decryptText(await primary.session.encryptText("reload", 2, "a"))).text, "reload");
    const saved = JSON.parse(secondary.storage.getItem(E2EE.KEYPAIR_PREFIX + secondary.pairId));
    saved.publicKey = (await client().session.publicMessage())[1];
    secondary.storage.setItem(E2EE.KEYPAIR_PREFIX + secondary.pairId, JSON.stringify(saved));
    const repaired = client({ ...secondary, storage: secondary.storage });
    assert.notEqual((await repaired.session.publicMessage())[1], previous[1]);
    await connect(primary, repaired);
    assert.equal((await repaired.session.decryptText(await primary.session.encryptText("repair", 3, "a"))).text, "repair");
    primary.session.resetPeer();
    const pending = primary.session.acceptPublicMessage(await repaired.session.publicMessage());
    primary.session.resetPeer();
    assert.equal(await pending, false);
    assert.equal(primary.session.ready(), false);
    await connect(primary, repaired);
    primary.session.close();
    assert.equal(primary.session.ready(), false);
    await assert.rejects(primary.session.publicMessage());
    await assert.rejects(primary.session.encryptText("closed", 4, "a"));
});

test("storage-denied tabs retain a working in-memory authenticated pairing", async () => {
    const storage = { getItem() { throw new Error("denied"); }, setItem() { throw new Error("denied"); }, removeItem() { throw new Error("denied"); } };
    const primary = client({ storage });
    const secondary = client({ role: "secondary", secret: primary.secret, storage });
    await connect(primary, secondary);
    assert.equal((await secondary.session.decryptText(await primary.session.encryptText("memory", 1, "a"))).text, "memory");
});