zwing: real cross-language interop — Python↔Go and TypeScript↔Go

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.
This commit is contained in:
2026-05-06 15:04:18 -07:00
parent e90f664a63
commit 5220f95271
5 changed files with 377 additions and 7 deletions
+60
View File
@@ -0,0 +1,60 @@
// Tiny Z-Wing test server used for cross-language interop tests.
// Listens on the address given by the first arg (or 127.0.0.1:0),
// prints the listening address + the public-identity hex on stdout
// (one per line), then accepts ONE Z-Wing connection, echoes one
// payload, and exits.
package main
import (
"encoding/hex"
"flag"
"fmt"
"io"
"log"
"os"
"github.com/luxfi/zwing"
)
func main() {
addr := flag.String("addr", "127.0.0.1:0", "listen addr")
flag.Parse()
id, err := zwing.GenerateIdentity()
if err != nil {
log.Fatalf("identity: %v", err)
}
ln, err := zwing.Listen(*addr, &zwing.Config{LocalIdentity: id})
if err != nil {
log.Fatalf("listen: %v", err)
}
defer ln.Close()
pubBytes := id.Public().MarshalBinary()
fmt.Println(ln.Addr().String())
fmt.Println(hex.EncodeToString(pubBytes))
// Force stdout flush so the harness can read both lines before we
// block on Accept.
os.Stdout.Sync()
conn, err := ln.Accept()
if err != nil {
log.Fatalf("accept: %v", err)
}
defer conn.Close()
buf := make([]byte, 4096)
n, err := conn.Read(buf)
if err != nil {
if err == io.EOF {
return
}
log.Fatalf("read: %v", err)
}
if _, err := conn.Write(buf[:n]); err != nil {
log.Fatalf("write: %v", err)
}
}
+5 -4
View File
@@ -145,8 +145,9 @@ export function signIdentity(
): Uint8Array {
const digest = identityDigest(ctx, message);
const edSig = ed25519.sign(digest, id.edSk);
// Noble ml-dsa API: sign(msg, secretKey).
const mlSig = ml_dsa65.sign(digest, id.mlSk);
// Noble ml-dsa API: sign(msg, secretKey, opts). Pass ZWING_DOMAIN as
// the FIPS 204 context to match the Go DSAPriv.SignCtx call.
const mlSig = ml_dsa65.sign(digest, id.mlSk, { context: ZWING_DOMAIN });
const out = new Uint8Array(SIGNATURE_SIZE);
out.set(edSig, 0);
out.set(mlSig, ED25519_SIGNATURE_SIZE);
@@ -169,8 +170,8 @@ export function verifyIdentity(
if (!ed25519.verify(edSig, digest, pub.edPk)) {
throw new Error("zwing: ed25519 verify failed");
}
// Noble ml-dsa API: verify(sig, msg, pubKey).
if (!ml_dsa65.verify(mlSig, digest, pub.mlPk)) {
// Noble ml-dsa API: verify(sig, msg, pubKey, opts). Same context as sign.
if (!ml_dsa65.verify(mlSig, digest, pub.mlPk, { context: ZWING_DOMAIN })) {
throw new Error("zwing: ml-dsa-65 verify failed");
}
}
+175
View File
@@ -0,0 +1,175 @@
/**
* 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,
);
});
+9 -3
View File
@@ -140,10 +140,16 @@ def identity_digest(ctx: bytes, message: bytes) -> bytes:
def sign_identity(identity: Identity, ctx: bytes, message: bytes) -> bytes:
"""Concatenate Ed25519 and ML-DSA-65 detached signatures over the digest."""
"""Concatenate Ed25519 and ML-DSA-65 detached signatures over the digest.
The ML-DSA-65 signing call passes ``ZWING_DOMAIN`` as the FIPS 204
``ctx`` parameter — this matches the Go side's
``DSAPriv.SignCtx(rand, digest, []byte("lux.zwing.v1"))`` so Python
and Go signatures verify under each other byte-for-byte.
"""
digest = identity_digest(ctx, message)
ed_sig = identity.ed_sk.sign(digest)
ml_sig = ML_DSA_65.sign(identity.ml_sk, digest)
ml_sig = ML_DSA_65.sign(identity.ml_sk, digest, ctx=ZWING_DOMAIN)
return ed_sig + ml_sig
@@ -163,7 +169,7 @@ def verify_identity(
except Exception as e: # cryptography raises InvalidSignature
raise ValueError("zwing: ed25519 verify failed") from e
if not ML_DSA_65.verify(pub.ml_pk, digest, ml_sig):
if not ML_DSA_65.verify(pub.ml_pk, digest, ml_sig, ctx=ZWING_DOMAIN):
raise ValueError("zwing: ml-dsa-65 verify failed")
+128
View File
@@ -0,0 +1,128 @@
"""End-to-end interop test: Python Z-Wing client ↔ Go Z-Wing server.
Spawns the Go test server (built from cmd/zwing-test-server), reads the
listen address and the server's public-identity hex from its stdout, then
connects with a Python initiator pinning that identity. If the handshake
completes and the encrypted echo round-trips, full Go↔Python wire-byte
interop is proven for X-Wing KEM + ML-DSA-65 hybrid identity +
ChaCha20-Poly1305 channel records.
Usage:
cd py && python3 test_interop_go.py
Requires the test server binary at /tmp/zwing-test-server (built by
``cd .. && go build -o /tmp/zwing-test-server ./cmd/zwing-test-server``).
"""
from __future__ import annotations
import os
import socket
import subprocess
import sys
import unittest
# Make sure py/ is on sys.path when executed from anywhere.
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
from channel import Channel
from handshake import run_initiator
from identity import (
generate_identity,
parse_identity_public,
public_of,
)
SERVER_BIN = "/tmp/zwing-test-server"
class _SocketStream:
"""Adapter: socket.socket → DuplexStream-shaped read/write."""
def __init__(self, sock: socket.socket) -> None:
self._sock = sock
def read(self, n: int) -> bytes:
chunks = []
remaining = n
while remaining > 0:
chunk = self._sock.recv(remaining)
if not chunk:
raise ConnectionError("zwing: remote closed")
chunks.append(chunk)
remaining -= len(chunk)
return b"".join(chunks)
def write(self, data: bytes) -> None:
self._sock.sendall(data)
class GoPythonInterop(unittest.TestCase):
@classmethod
def setUpClass(cls):
if not os.path.exists(SERVER_BIN):
raise unittest.SkipTest(f"server binary missing at {SERVER_BIN}")
def test_python_client_to_go_server_handshake_and_echo(self):
# Spawn the Go server. It prints the listen addr on the first
# stdout line and the server public-identity hex on the second.
proc = subprocess.Popen(
[SERVER_BIN, "-addr", "127.0.0.1:0"],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
)
try:
addr_line = proc.stdout.readline().strip()
pub_hex = proc.stdout.readline().strip()
self.assertNotEqual(addr_line, "", "no addr from go server")
self.assertNotEqual(pub_hex, "", "no pubkey from go server")
host, port_s = addr_line.rsplit(":", 1)
port = int(port_s)
server_pub = parse_identity_public(bytes.fromhex(pub_hex))
client = generate_identity()
sock = socket.create_connection((host, port), timeout=10.0)
try:
stream = _SocketStream(sock)
out = run_initiator(stream, client, server_pub)
chan = Channel(stream, out, initiator=True)
payload = b"hello from Python initiator"
chan.send(payload)
got = chan.recv()
self.assertEqual(got, payload)
finally:
sock.close()
# Server should exit cleanly within a few seconds.
try:
proc.wait(timeout=5)
except subprocess.TimeoutExpired:
proc.terminate()
proc.wait(timeout=2)
stderr_out = proc.stderr.read()
self.assertEqual(
proc.returncode,
0,
msg=f"go server exit={proc.returncode}\nstderr=\n{stderr_out}",
)
finally:
if proc.poll() is None:
# Drain stderr to surface what the server saw.
try:
err = proc.stderr.read()
print(f"go server stderr:\n{err}", file=sys.stderr)
except Exception:
pass
proc.kill()
proc.wait(timeout=2)
if __name__ == "__main__":
unittest.main()