Files
zwing/js/full.test.ts
zeekay e90f664a63 zwing: full PQ stack in Python and TypeScript with 100% coverage
Port the complete Z-Wing protocol to Python and TypeScript, no longer
just the X-Wing combiner. Each language now ships:

  identity   Hybrid Ed25519 + ML-DSA-65 + X-Wing keypair, marshal/parse,
             sign/verify, constant-time equality
  kem        Full IETF X-Wing encapsulate/decapsulate (ML-KEM-768 +
             X25519 + SHA3-256 combiner) with wire-byte compat
  handshake  1-RTT mutual-auth handshake mirroring Go state machine —
             same labels (lux.zwing.v1/handshake-init etc), same wire
             format (HandshakeInit / HandshakeResponse / transcript hash
             / AEAD-sealed responder identity)
  channel    Post-handshake ChaCha20-Poly1305 with sequence-numbered
             nonces (high 4 zero, low 8 BE counter), MAX 1 MiB frames

Python implementation:
  - cryptography (Ed25519/X25519/ChaCha20-Poly1305) + kyber-py (ML-KEM)
    + dilithium-py (ML-DSA). Pure Python, zero native deps.
  - test_full.py: 39 tests covering happy paths + every error branch
  - py/__init__.py modules at 100% line coverage:
      channel.py 51 stmts 100%
      handshake.py 159 stmts 100%
      identity.py 91 stmts 100%
      kem.py 26 stmts 100%
      zwing.py 27 stmts 100%

TypeScript implementation:
  - @noble/post-quantum (ml-kem768, ml-dsa65) + @noble/curves (ed25519,
    x25519) + @noble/ciphers (chacha20poly1305) + @noble/hashes
  - full.test.ts: 32 tests covering full e2e + every wire-decode error
  - vitest --coverage v8: 100% statements / 100% branches / 100%
    functions / 100% lines across every source file (channel.ts,
    handshake.ts, identity.ts, kem.ts, zwing.ts)

Cross-language KAT (72df...fe4ed) still pins on the X-Wing combiner.
Full handshake wire-byte interop is now possible Go↔Python (both use
FIPS 204 ML-DSA-65 with identical 3309-byte signatures); Rust uses
PQClean Dilithium3 which has the same byte sizes as Round 3 — full
Rust↔Go interop test pending a unified ML-DSA-65 source.
2026-05-06 14:18:16 -07:00

426 lines
14 KiB
TypeScript

import { describe, expect, it } from "vitest";
import { Channel, nonceFor } from "./channel.js";
import {
HandshakeOutput,
MAX_FRAME_SIZE,
readFrame,
runInitiator,
runResponder,
writeFrame,
} from "./handshake.js";
import type { DuplexStream } from "./handshake.js";
import {
generateIdentity,
identityEquals,
marshalIdentityPublic,
parseIdentityPublic,
publicOf,
signIdentity,
verifyIdentity,
} from "./identity.js";
import { xwingDecapsulate, xwingEncapsulate } from "./kem.js";
/**
* Async in-memory pipe pair so two sides of a handshake can run in
* parallel via `Promise.all`.
*/
function pipe(): [DuplexStream, DuplexStream] {
type Buf = { data: Uint8Array; resolve: (b: Uint8Array) => void };
const queueAB: Uint8Array[] = [];
const queueBA: Uint8Array[] = [];
const waitersAB: ((b: Uint8Array) => void)[] = [];
const waitersBA: ((b: Uint8Array) => void)[] = [];
function makeReader(queue: Uint8Array[], waiters: ((b: Uint8Array) => void)[]) {
let buf = new Uint8Array(0);
return async function read(n: number): Promise<Uint8Array> {
while (buf.length < n) {
let chunk: Uint8Array;
if (queue.length > 0) {
chunk = queue.shift()!;
} else {
chunk = await new Promise<Uint8Array>((resolve) => {
waiters.push(resolve);
});
}
const merged = new Uint8Array(buf.length + chunk.length);
merged.set(buf);
merged.set(chunk, buf.length);
buf = merged;
}
const out = buf.slice(0, n);
buf = buf.slice(n);
return out;
};
}
function makeWriter(queue: Uint8Array[], waiters: ((b: Uint8Array) => void)[]) {
return async function write(data: Uint8Array): Promise<void> {
const copy = new Uint8Array(data);
if (waiters.length > 0) {
const resolve = waiters.shift()!;
resolve(copy);
} else {
queue.push(copy);
}
};
}
const a: DuplexStream = {
read: makeReader(queueBA, waitersBA),
write: makeWriter(queueAB, waitersAB),
};
const b: DuplexStream = {
read: makeReader(queueAB, waitersAB),
write: makeWriter(queueBA, waitersBA),
};
return [a, b];
}
describe("Z-Wing full e2e (TS)", () => {
it("identity sign + verify round trip", () => {
const id = generateIdentity();
const pub = publicOf(id);
const sig = signIdentity(id, new TextEncoder().encode("ctx"), new TextEncoder().encode("hi"));
expect(() =>
verifyIdentity(pub, new TextEncoder().encode("ctx"), new TextEncoder().encode("hi"), sig),
).not.toThrow();
});
it("verify rejects wrong message", () => {
const id = generateIdentity();
const pub = publicOf(id);
const sig = signIdentity(id, new TextEncoder().encode("ctx"), new TextEncoder().encode("hi"));
expect(() =>
verifyIdentity(pub, new TextEncoder().encode("ctx"), new TextEncoder().encode("bye"), sig),
).toThrow();
});
it("verify rejects wrong context", () => {
const id = generateIdentity();
const pub = publicOf(id);
const sig = signIdentity(id, new TextEncoder().encode("ctx"), new TextEncoder().encode("hi"));
expect(() =>
verifyIdentity(pub, new TextEncoder().encode("other"), new TextEncoder().encode("hi"), sig),
).toThrow();
});
it("verify rejects wrong-size signature", () => {
const id = generateIdentity();
const pub = publicOf(id);
expect(() =>
verifyIdentity(
pub,
new TextEncoder().encode("ctx"),
new TextEncoder().encode("hi"),
new Uint8Array([1, 2, 3]),
),
).toThrow();
});
it("identity public marshal round trip", () => {
const id = generateIdentity();
const pub = publicOf(id);
const wire = marshalIdentityPublic(pub);
const parsed = parseIdentityPublic(wire);
expect(identityEquals(parsed, pub)).toBe(true);
});
it("rejects wrong-size identity public", () => {
expect(() => parseIdentityPublic(new Uint8Array(10))).toThrow();
});
it("two distinct identities differ", () => {
const a = publicOf(generateIdentity());
const b = publicOf(generateIdentity());
expect(identityEquals(a, b)).toBe(false);
});
it("xwing encap/decap round trip", () => {
const recipient = generateIdentity();
const { ciphertext, shared } = xwingEncapsulate(publicOf(recipient));
const out = xwingDecapsulate(recipient, ciphertext);
expect(out).toEqual(shared);
});
it("xwing decap rejects wrong size", () => {
const id = generateIdentity();
expect(() => xwingDecapsulate(id, new Uint8Array(10))).toThrow();
});
it("full handshake round trip via in-memory pipe", async () => {
const client = generateIdentity();
const server = generateIdentity();
const [a, b] = pipe();
const [cOut, sOut] = await Promise.all([
runInitiator(a, client, publicOf(server)),
runResponder(b, server, publicOf(client)),
]);
expect(cOut.keyI2R).toEqual(sOut.keyI2R);
expect(cOut.keyR2I).toEqual(sOut.keyR2I);
expect(identityEquals(cOut.remote, publicOf(server))).toBe(true);
expect(identityEquals(sOut.remote, publicOf(client))).toBe(true);
});
it("pinned remote mismatch rejected", async () => {
const client = generateIdentity();
const server = generateIdentity();
const other = generateIdentity();
const [a, b] = pipe();
await expect(
Promise.all([runInitiator(a, client, publicOf(other)), runResponder(b, server)]),
).rejects.toThrow();
});
it("e2e channel echo", async () => {
const client = generateIdentity();
const server = generateIdentity();
const [a, b] = pipe();
const [cOut, sOut] = await Promise.all([
runInitiator(a, client, publicOf(server)),
runResponder(b, server, publicOf(client)),
]);
const cChan = new Channel(a, cOut, true);
const sChan = new Channel(b, sOut, false);
await cChan.send(new TextEncoder().encode("z-wing ts e2e"));
const got = await sChan.recv();
expect(new TextDecoder().decode(got)).toBe("z-wing ts e2e");
await sChan.send(new TextEncoder().encode("ack"));
const back = await cChan.recv();
expect(new TextDecoder().decode(back)).toBe("ack");
});
it("write frame oversize rejected", async () => {
const sink: DuplexStream = {
read: async () => new Uint8Array(0),
write: async () => {},
};
await expect(writeFrame(sink, new Uint8Array(MAX_FRAME_SIZE + 1))).rejects.toThrow();
});
it("read frame zero length rejected", async () => {
let i = 0;
const conn: DuplexStream = {
read: async (n: number) => {
if (i++ === 0) return new Uint8Array([0, 0, 0, 0]);
return new Uint8Array(n);
},
write: async () => {},
};
await expect(readFrame(conn)).rejects.toThrow();
});
it("read frame oversize rejected", async () => {
const conn: DuplexStream = {
read: async () => new Uint8Array([0xff, 0xff, 0xff, 0xff]),
write: async () => {},
};
await expect(readFrame(conn)).rejects.toThrow();
});
it("nonce_for has BE counter in low bytes", () => {
expect(nonceFor(0n)).toEqual(new Uint8Array(12));
const n1 = nonceFor(1n);
expect(n1[11]).toBe(1);
const nMax = nonceFor(0xffffffffffffffffn);
for (let i = 4; i < 12; i++) expect(nMax[i]).toBe(0xff);
});
it("identityEquals length mismatch returns false", async () => {
const { identityEquals } = await import("./identity.js");
const a = {
edPk: new Uint8Array(32),
mlPk: new Uint8Array(1952),
xwingMlkemPk: new Uint8Array(1184),
xwingX25519Pk: new Uint8Array(32),
};
const b = {
edPk: new Uint8Array(16), // wrong length
mlPk: new Uint8Array(1952),
xwingMlkemPk: new Uint8Array(1184),
xwingX25519Pk: new Uint8Array(32),
};
expect(identityEquals(a, b)).toBe(false);
});
it("Channel rx overflow path", async () => {
const sink: DuplexStream = {
read: async () => new Uint8Array(0),
write: async () => {},
};
const id = generateIdentity();
const out: HandshakeOutput = {
shared: new Uint8Array(32),
remote: publicOf(id),
keyI2R: new Uint8Array(32),
keyR2I: new Uint8Array(32),
};
const chan = new Channel(sink, out, true);
// Inject overflow buffer; recv must return it without touching the wire.
(chan as any).rxOverflow = new TextEncoder().encode("hello-overflow");
const got = await chan.recv();
expect(new TextDecoder().decode(got)).toBe("hello-overflow");
});
it("Channel tx seq exhausted", async () => {
const sink: DuplexStream = {
read: async () => new Uint8Array(0),
write: async () => {},
};
const id = generateIdentity();
const out: HandshakeOutput = {
shared: new Uint8Array(32),
remote: publicOf(id),
keyI2R: new Uint8Array(32),
keyR2I: new Uint8Array(32),
};
const chan = new Channel(sink, out, true);
(chan as any).txSeq = 0xffffffffffffffffn;
await expect(chan.send(new Uint8Array([1]))).rejects.toThrow();
});
it("Channel rx seq exhausted", async () => {
let frameRead = false;
const stream: DuplexStream = {
read: async (n) => {
if (!frameRead) {
frameRead = true;
// Return a valid header for a 1-byte frame...
if (n === 4) return new Uint8Array([0, 0, 0, 1]);
}
if (n === 1) return new Uint8Array([0]);
return new Uint8Array(n);
},
write: async () => {},
};
const id = generateIdentity();
const out: HandshakeOutput = {
shared: new Uint8Array(32),
remote: publicOf(id),
keyI2R: new Uint8Array(32),
keyR2I: new Uint8Array(32),
};
const chan = new Channel(stream, out, true);
(chan as any).rxSeq = 0xffffffffffffffffn;
await expect(chan.recv()).rejects.toThrow();
});
it("zwing combineXWing rejects every wrong-size input", async () => {
const { combineXWing } = await import("./zwing.js");
const ok = new Uint8Array(32);
const short = new Uint8Array(31);
expect(() => combineXWing(short, ok, ok, ok)).toThrow();
expect(() => combineXWing(ok, short, ok, ok)).toThrow();
expect(() => combineXWing(ok, ok, short, ok)).toThrow();
expect(() => combineXWing(ok, ok, ok, short)).toThrow();
});
it("xwing wire decode errors", async () => {
// Full wire round-trip already tested. Here we drive the few pure
// decode error branches.
const conn: DuplexStream = {
read: async (n) => {
// Fast-fail header read with an oversize length.
if (n === 4) return new Uint8Array([0xff, 0xff, 0xff, 0xff]);
return new Uint8Array(n);
},
write: async () => {},
};
await expect(readFrame(conn)).rejects.toThrow();
});
it("identity sign verify rejects corrupt mldsa half", async () => {
const id = generateIdentity();
const pub = publicOf(id);
const sig = signIdentity(id, new TextEncoder().encode("ctx"), new TextEncoder().encode("hi"));
sig[200] ^= 0xff; // mutate inside the ML-DSA portion
expect(() =>
verifyIdentity(pub, new TextEncoder().encode("ctx"), new TextEncoder().encode("hi"), sig),
).toThrow();
});
it("decodeHandshakeResponse short enc body rejected", async () => {
const { __internal } = await import("./handshake.js");
const { XWING_CIPHERTEXT_SIZE } = await import("./zwing.js");
// Right-size ct, encLen=0xFFFF, no body.
const buf = new Uint8Array(XWING_CIPHERTEXT_SIZE + 2);
buf[XWING_CIPHERTEXT_SIZE] = 0xff;
buf[XWING_CIPHERTEXT_SIZE + 1] = 0xff;
expect(() => __internal.decodeHandshakeResponse(buf)).toThrow();
});
it("decodeHandshakeResponse trailing garbage rejected", async () => {
const { __internal } = await import("./handshake.js");
const { XWING_CIPHERTEXT_SIZE } = await import("./zwing.js");
// Right-size ct, encLen=0, plus trailing byte.
const buf = new Uint8Array(XWING_CIPHERTEXT_SIZE + 3);
expect(() => __internal.decodeHandshakeResponse(buf)).toThrow();
});
it("splitRespIDPayload short sig rejected", async () => {
const { __internal } = await import("./handshake.js");
// idLen=0, sigLen=0xFFFF, no body.
expect(() => __internal.splitRespIDPayload(new Uint8Array([0, 0, 0xff, 0xff]))).toThrow();
});
it("splitRespIDPayload trailing garbage rejected", async () => {
const { __internal } = await import("./handshake.js");
// idLen=0, sigLen=0, then extra byte.
expect(() =>
__internal.splitRespIDPayload(new Uint8Array([0, 0, 0, 0, 0x99])),
).toThrow();
});
it("responder pinned remote mismatch rejected", async () => {
const client = generateIdentity();
const server = generateIdentity();
const other = generateIdentity();
const [a, b] = pipe();
await expect(
Promise.all([runInitiator(a, client), runResponder(b, server, publicOf(other))]),
).rejects.toThrow();
});
it("splitRespIDPayload short pre-idLen rejected", async () => {
const { __internal } = await import("./handshake.js");
expect(() => __internal.splitRespIDPayload(new Uint8Array(0))).toThrow();
expect(() => __internal.splitRespIDPayload(new Uint8Array([0]))).toThrow();
});
it("splitRespIDPayload short id rejected", async () => {
const { __internal } = await import("./handshake.js");
// idLen=10 but buffer only 2 bytes long.
expect(() =>
__internal.splitRespIDPayload(new Uint8Array([0, 10])),
).toThrow();
});
it("decodeHandshakeResponse rejects pre-XWING-CT-length buffer", async () => {
const { __internal } = await import("./handshake.js");
expect(() => __internal.decodeHandshakeResponse(new Uint8Array(50))).toThrow();
});
it("decodeHandshakeInit short reads at every boundary", async () => {
const { __internal } = await import("./handshake.js");
expect(() => __internal.decodeHandshakeInit(new Uint8Array(0))).toThrow();
expect(() => __internal.decodeHandshakeInit(new Uint8Array([0]))).toThrow();
// idLen=1, no id body.
expect(() => __internal.decodeHandshakeInit(new Uint8Array([0, 1]))).toThrow();
// idLen=1, id="A", no sigLen.
expect(() =>
__internal.decodeHandshakeInit(new Uint8Array([0, 1, 0x41])),
).toThrow();
// idLen=0, sigLen=0xFFFF, no body.
expect(() =>
__internal.decodeHandshakeInit(new Uint8Array([0, 0, 0xff, 0xff])),
).toThrow();
// idLen=0, sigLen=0, trailing garbage.
expect(() =>
__internal.decodeHandshakeInit(new Uint8Array([0, 0, 0, 0, 0x99])),
).toThrow();
});
});