mirror of
https://github.com/luxfi/zwing.git
synced 2026-09-24 00:24:38 +00:00
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:
@@ -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
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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")
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user