Files
zeekay 5220f95271 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.
2026-05-06 15:04:18 -07:00

195 lines
6.3 KiB
TypeScript

/**
* Hybrid Z-Wing identity: Ed25519 + ML-DSA-65 + X-Wing static keypair.
*
* Wire format mirrors the Go and Rust implementations:
*
* [Ed25519 pk: 32][ML-DSA-65 pk: 1952][X-Wing pk: 1216] = 3200 bytes
*
* Signature wire = [Ed25519 sig: 64][ML-DSA-65 sig: 3309] = 3373 bytes.
*/
import { ml_dsa65 } from "@noble/post-quantum/ml-dsa.js";
import { ml_kem768 } from "@noble/post-quantum/ml-kem.js";
import { ed25519, x25519 } from "@noble/curves/ed25519.js";
import { sha256 } from "@noble/hashes/sha2.js";
import { XWING_PUBLIC_KEY_SIZE, X25519_POINT_SIZE } from "./zwing.js";
/** Ed25519 public key length. */
export const ED25519_PUBLIC_KEY_SIZE = 32;
/** Ed25519 signature length. */
export const ED25519_SIGNATURE_SIZE = 64;
/** ML-DSA-65 public key length (FIPS 204). */
export const MLDSA65_PUBLIC_KEY_SIZE = 1952;
/** ML-DSA-65 signature length (FIPS 204). */
export const MLDSA65_SIGNATURE_SIZE = 3309;
/** ML-KEM-768 public key length. */
export const MLKEM768_PUBLIC_KEY_SIZE = 1184;
/** Total wire size of an `IdentityPublic`. */
export const IDENTITY_PUBLIC_SIZE =
ED25519_PUBLIC_KEY_SIZE + MLDSA65_PUBLIC_KEY_SIZE + XWING_PUBLIC_KEY_SIZE;
/** Total wire size of an identity signature. */
export const SIGNATURE_SIZE =
ED25519_SIGNATURE_SIZE + MLDSA65_SIGNATURE_SIZE;
/** Z-Wing protocol context bound into every identity signature. */
export const ZWING_DOMAIN = new TextEncoder().encode("lux.zwing.v1");
/** Long-term Z-Wing identity. */
export interface Identity {
edSk: Uint8Array; // 32-byte Ed25519 seed
edPk: Uint8Array; // 32-byte Ed25519 public key
mlSk: Uint8Array; // ML-DSA-65 secret
mlPk: Uint8Array; // ML-DSA-65 public
xwingMlkemSk: Uint8Array; // ML-KEM-768 secret
xwingMlkemPk: Uint8Array; // ML-KEM-768 public
xwingX25519Sk: Uint8Array; // X25519 32-byte secret
xwingX25519Pk: Uint8Array; // X25519 32-byte public
}
/** Public half of an Identity. */
export interface IdentityPublic {
edPk: Uint8Array;
mlPk: Uint8Array;
xwingMlkemPk: Uint8Array;
xwingX25519Pk: Uint8Array;
}
/** Generate a fresh identity using a CSPRNG. */
export function generateIdentity(): Identity {
const edSeed = crypto.getRandomValues(new Uint8Array(32));
const edPk = ed25519.getPublicKey(edSeed);
const mlKp = ml_dsa65.keygen();
const mlSk = mlKp.secretKey;
const mlPk = mlKp.publicKey;
const mlkemKp = ml_kem768.keygen();
const xwingMlkemSk = mlkemKp.secretKey;
const xwingMlkemPk = mlkemKp.publicKey;
const xwingX25519Sk = crypto.getRandomValues(new Uint8Array(32));
const xwingX25519Pk = x25519.getPublicKey(xwingX25519Sk);
return {
edSk: edSeed,
edPk,
mlSk,
mlPk,
xwingMlkemSk,
xwingMlkemPk,
xwingX25519Sk,
xwingX25519Pk,
};
}
/** Public half of `id`. */
export function publicOf(id: Identity): IdentityPublic {
return {
edPk: id.edPk,
mlPk: id.mlPk,
xwingMlkemPk: id.xwingMlkemPk,
xwingX25519Pk: id.xwingX25519Pk,
};
}
/** Marshal a public identity to wire bytes. */
export function marshalIdentityPublic(pub: IdentityPublic): Uint8Array {
const out = new Uint8Array(IDENTITY_PUBLIC_SIZE);
let off = 0;
out.set(pub.edPk, off);
off += ED25519_PUBLIC_KEY_SIZE;
out.set(pub.mlPk, off);
off += MLDSA65_PUBLIC_KEY_SIZE;
out.set(pub.xwingMlkemPk, off);
off += MLKEM768_PUBLIC_KEY_SIZE;
out.set(pub.xwingX25519Pk, off);
return out;
}
/** Parse a public identity from wire bytes. Throws on size mismatch. */
export function parseIdentityPublic(data: Uint8Array): IdentityPublic {
if (data.length !== IDENTITY_PUBLIC_SIZE) {
throw new Error(
`zwing: invalid identity public size (got ${data.length}, want ${IDENTITY_PUBLIC_SIZE})`,
);
}
let off = 0;
const edPk = data.slice(off, off + ED25519_PUBLIC_KEY_SIZE);
off += ED25519_PUBLIC_KEY_SIZE;
const mlPk = data.slice(off, off + MLDSA65_PUBLIC_KEY_SIZE);
off += MLDSA65_PUBLIC_KEY_SIZE;
const xwingMlkemPk = data.slice(off, off + MLKEM768_PUBLIC_KEY_SIZE);
off += MLKEM768_PUBLIC_KEY_SIZE;
const xwingX25519Pk = data.slice(off, off + X25519_POINT_SIZE);
return { edPk, mlPk, xwingMlkemPk, xwingX25519Pk };
}
/** Bind (ZWING_DOMAIN || len(ctx) || ctx || message) into a SHA-256 digest. */
export function identityDigest(ctx: Uint8Array, message: Uint8Array): Uint8Array {
const h = sha256.create();
h.update(ZWING_DOMAIN);
h.update(new Uint8Array([ctx.length & 0xff]));
h.update(ctx);
h.update(message);
return h.digest();
}
/** Sign `(ctx, message)` with both Ed25519 and ML-DSA-65. */
export function signIdentity(
id: Identity,
ctx: Uint8Array,
message: Uint8Array,
): Uint8Array {
const digest = identityDigest(ctx, message);
const edSig = ed25519.sign(digest, id.edSk);
// 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);
return out;
}
/** Verify a hybrid Z-Wing signature. Throws on any failure. */
export function verifyIdentity(
pub: IdentityPublic,
ctx: Uint8Array,
message: Uint8Array,
signature: Uint8Array,
): void {
if (signature.length !== SIGNATURE_SIZE) {
throw new Error("zwing: signature size mismatch");
}
const digest = identityDigest(ctx, message);
const edSig = signature.slice(0, ED25519_SIGNATURE_SIZE);
const mlSig = signature.slice(ED25519_SIGNATURE_SIZE);
if (!ed25519.verify(edSig, digest, pub.edPk)) {
throw new Error("zwing: ed25519 verify failed");
}
// 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");
}
}
/** Constant-time equality of two public identities. */
export function identityEquals(a: IdentityPublic, b: IdentityPublic): boolean {
return (
constantTimeEq(a.edPk, b.edPk) &&
constantTimeEq(a.mlPk, b.mlPk) &&
constantTimeEq(a.xwingMlkemPk, b.xwingMlkemPk) &&
constantTimeEq(a.xwingX25519Pk, b.xwingX25519Pk)
);
}
function constantTimeEq(a: Uint8Array, b: Uint8Array): boolean {
if (a.length !== b.length) return false;
let diff = 0;
for (let i = 0; i < a.length; i++) diff |= a[i] ^ b[i];
return diff === 0;
}