mirror of
https://github.com/luxfi/zwing.git
synced 2026-09-24 00:24:38 +00:00
The previous "full PQ" claims for Python and TS were tested only within
each language. This commit proves the wire bytes actually match Go.
What was wrong:
Both Python and TS were calling ml_dsa65.sign(secretKey, message)
WITHOUT the FIPS 204 ctx parameter, while Go was calling
DSAPriv.SignCtx(rand, digest, []byte("lux.zwing.v1")). The signed
messages diverged → handshake verify failed across language pairs.
Fix:
Python: ML_DSA_65.sign(sk, digest, ctx=ZWING_DOMAIN) and matching
verify with ctx=ZWING_DOMAIN.
TS: ml_dsa65.sign(digest, sk, { context: ZWING_DOMAIN }) and
matching verify { context: ZWING_DOMAIN }.
Real interop tests added:
cmd/zwing-test-server/main.go — minimal Go server: prints listen
addr + identity hex on stdout,
accepts ONE handshake, echoes one
payload, exits.
py/test_interop_go.py — Python client connects via TCP,
runs run_initiator + Channel echo
against the live Go server.
js/interop_go.test.ts — TS client over node:net.Socket
runs runInitiator + Channel echo
against the live Go server.
Both interop tests pass:
Python ↔ Go: Ran 1 test (and 43 within-Python tests)
TypeScript ↔ Go: 1 interop test passes alongside 32 within-TS tests
Wire-byte compatibility now PROVEN end-to-end for X-Wing KEM, hybrid
identity (Ed25519 + ML-DSA-65 with lux.zwing.v1 ctx), HKDF channel
keys, and ChaCha20-Poly1305 records across Go ↔ Python ↔ TypeScript.
176 lines
5.5 KiB
TypeScript
176 lines
5.5 KiB
TypeScript
/**
|
|
* End-to-end interop test: TypeScript Z-Wing client ↔ Go Z-Wing server.
|
|
*
|
|
* Spawns the Go test server (built from cmd/zwing-test-server), reads
|
|
* the listen address and server identity hex from its stdout, opens a
|
|
* TCP socket, and runs a full Z-Wing handshake + AEAD echo round trip
|
|
* against it. If this test passes, every wire byte from
|
|
* HandshakeInit/HandshakeResponse and every record on the post-handshake
|
|
* channel is bit-identical between the Go and TypeScript implementations.
|
|
*/
|
|
|
|
import { spawn } from "node:child_process";
|
|
import { Socket, connect } from "node:net";
|
|
import { existsSync } from "node:fs";
|
|
import { describe, expect, it } from "vitest";
|
|
|
|
import { Channel } from "./channel.js";
|
|
import { runInitiator, type DuplexStream } from "./handshake.js";
|
|
import {
|
|
generateIdentity,
|
|
parseIdentityPublic,
|
|
publicOf,
|
|
} from "./identity.js";
|
|
|
|
const SERVER_BIN = "/tmp/zwing-test-server";
|
|
|
|
class SocketStream implements DuplexStream {
|
|
private buf = new Uint8Array(0);
|
|
// Single waiter at a time: each pending read knows how many bytes it
|
|
// needs. When new data arrives we either satisfy it or wait again.
|
|
private pending:
|
|
| { n: number; resolve: (b: Uint8Array) => void; reject: (e: Error) => void }
|
|
| null = null;
|
|
private closed = false;
|
|
private closeErr: Error | null = null;
|
|
|
|
constructor(private sock: Socket) {
|
|
sock.on("data", (chunk) => {
|
|
const merged = new Uint8Array(this.buf.length + chunk.length);
|
|
merged.set(this.buf);
|
|
merged.set(chunk, this.buf.length);
|
|
this.buf = merged;
|
|
this.tryDeliver();
|
|
});
|
|
sock.on("close", () => {
|
|
this.closed = true;
|
|
this.closeErr = this.closeErr ?? new Error("socket closed");
|
|
if (this.pending) {
|
|
this.pending.reject(this.closeErr);
|
|
this.pending = null;
|
|
}
|
|
});
|
|
sock.on("error", (err) => {
|
|
this.closed = true;
|
|
this.closeErr = err;
|
|
if (this.pending) {
|
|
this.pending.reject(err);
|
|
this.pending = null;
|
|
}
|
|
});
|
|
}
|
|
|
|
private tryDeliver() {
|
|
if (!this.pending) return;
|
|
if (this.buf.length < this.pending.n) return;
|
|
const out = this.buf.slice(0, this.pending.n);
|
|
this.buf = this.buf.slice(this.pending.n);
|
|
const p = this.pending;
|
|
this.pending = null;
|
|
p.resolve(out);
|
|
}
|
|
|
|
async read(n: number): Promise<Uint8Array> {
|
|
if (this.buf.length >= n) {
|
|
const out = this.buf.slice(0, n);
|
|
this.buf = this.buf.slice(n);
|
|
return out;
|
|
}
|
|
if (this.closed) {
|
|
throw this.closeErr ?? new Error("socket closed");
|
|
}
|
|
return new Promise<Uint8Array>((resolve, reject) => {
|
|
this.pending = { n, resolve, reject };
|
|
this.tryDeliver();
|
|
});
|
|
}
|
|
|
|
async write(data: Uint8Array): Promise<void> {
|
|
return new Promise((resolve, reject) => {
|
|
this.sock.write(Buffer.from(data), (err) => {
|
|
if (err) reject(err);
|
|
else resolve();
|
|
});
|
|
});
|
|
}
|
|
}
|
|
|
|
describe("Z-Wing TS ↔ Go interop", () => {
|
|
it.skipIf(!existsSync(SERVER_BIN))(
|
|
"TS client connects to Go server, completes handshake, echoes payload",
|
|
async () => {
|
|
// Spawn Go server.
|
|
const proc = spawn(SERVER_BIN, ["-addr", "127.0.0.1:0"], {
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
});
|
|
|
|
let stdoutBuf = "";
|
|
const lines: string[] = [];
|
|
const lineWaiters: Array<() => void> = [];
|
|
|
|
proc.stdout.on("data", (chunk: Buffer) => {
|
|
stdoutBuf += chunk.toString();
|
|
let nl: number;
|
|
while ((nl = stdoutBuf.indexOf("\n")) !== -1) {
|
|
lines.push(stdoutBuf.slice(0, nl));
|
|
stdoutBuf = stdoutBuf.slice(nl + 1);
|
|
const w = lineWaiters.shift();
|
|
if (w) w();
|
|
}
|
|
});
|
|
|
|
const stderrChunks: Buffer[] = [];
|
|
proc.stderr.on("data", (c: Buffer) => stderrChunks.push(c));
|
|
|
|
async function readLine(): Promise<string> {
|
|
if (lines.length > 0) return lines.shift()!;
|
|
await new Promise<void>((resolve) => lineWaiters.push(resolve));
|
|
return lines.shift()!;
|
|
}
|
|
|
|
try {
|
|
const addr = await readLine();
|
|
const pubHex = await readLine();
|
|
expect(addr).toMatch(/^127\.0\.0\.1:\d+$/);
|
|
expect(pubHex.length).toBeGreaterThan(0);
|
|
|
|
const [host, portStr] = addr.split(":");
|
|
const port = Number(portStr);
|
|
const serverPub = parseIdentityPublic(Buffer.from(pubHex, "hex"));
|
|
|
|
const sock = connect({ host, port });
|
|
await new Promise<void>((resolve, reject) => {
|
|
sock.once("connect", () => resolve());
|
|
sock.once("error", reject);
|
|
});
|
|
|
|
try {
|
|
const stream = new SocketStream(sock);
|
|
const client = generateIdentity();
|
|
const out = await runInitiator(stream, client, serverPub);
|
|
const chan = new Channel(stream, out, true);
|
|
|
|
const payload = new TextEncoder().encode("hello from TS initiator");
|
|
await chan.send(payload);
|
|
const got = await chan.recv();
|
|
expect(new TextDecoder().decode(got)).toBe("hello from TS initiator");
|
|
} finally {
|
|
sock.end();
|
|
}
|
|
|
|
const exit = await new Promise<number | null>((resolve) => {
|
|
proc.on("exit", (code) => resolve(code));
|
|
setTimeout(() => resolve(-1), 5000);
|
|
});
|
|
if (exit !== 0) {
|
|
const stderr = Buffer.concat(stderrChunks).toString();
|
|
throw new Error(`go server exit=${exit}\nstderr:\n${stderr}`);
|
|
}
|
|
} finally {
|
|
if (proc.exitCode === null) proc.kill();
|
|
}
|
|
},
|
|
20_000,
|
|
);
|
|
});
|