zwing: Python (zwing/py) + TS (zwing/js) ports with cross-language KAT

Add canonical Python and TypeScript ports of the IETF X-Wing combiner
under py/ and js/ inside this repo. Both implement

  shared_secret = SHA3-256("\./X-Wing" || ss_M || ss_X || ct_X || pk_X)

byte-for-byte and pin the same KAT as the Go (kat_test.go) and Rust
(zap-protocol/zap zap::zwing) ports:

  inputs:  ssM=0x01..x32, ssX=0x02..x32, ctX=0x03..x32, pkX=0x04..x32
  output:  72df2088314a73de80c21d9593f13fcd5629c800c70b1507f0dd918fde5fe4ed

Layout:
  py/    pyproject.toml + zwing.py + test_zwing.py (stdlib hashlib)
  js/    package.json + zwing.ts + zwing.test.ts (@noble/hashes ^2.2.0)

Z-Wing now has 4 byte-compatible implementations under one canonical
roof (luxfi/zwing). The Rust port lives at zap-protocol/zap as
zap::zwing because that's the canonical Rust home for the schema +
runtime. All four pin the same hex.
This commit is contained in:
2026-05-06 13:08:54 -07:00
parent 0c10ffee9d
commit 0967911206
9 changed files with 381 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
node_modules/
dist/
pnpm-lock.yaml
+50
View File
@@ -0,0 +1,50 @@
{
"name": "@luxfi/zwing",
"version": "0.2.2",
"description": "Z-Wing — Lux post-quantum secure channel (TypeScript). IETF X-Wing KEM combiner.",
"type": "module",
"main": "./dist/zwing.js",
"module": "./dist/zwing.js",
"types": "./dist/zwing.d.ts",
"exports": {
".": {
"types": "./dist/zwing.d.ts",
"import": "./dist/zwing.js"
}
},
"files": [
"dist",
"zwing.ts",
"README.md",
"LICENSE"
],
"scripts": {
"build": "tsc -p tsconfig.json",
"test": "vitest run",
"test:watch": "vitest"
},
"keywords": [
"post-quantum",
"kem",
"x-wing",
"ml-kem",
"zwing",
"lux"
],
"author": "Lux Industries <[email protected]>",
"license": "MIT OR Apache-2.0",
"repository": {
"type": "git",
"url": "git+https://github.com/luxfi/zwing.git",
"directory": "js"
},
"homepage": "https://github.com/luxfi/zwing/tree/main/js#readme",
"bugs": "https://github.com/luxfi/zwing/issues",
"dependencies": {
"@noble/hashes": "^2.2.0"
},
"devDependencies": {
"typescript": "^5.7.0",
"vitest": "^2.0.0"
}
}
+23
View File
@@ -0,0 +1,23 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "bundler",
"lib": ["ES2022", "DOM"],
"outDir": "./dist",
"rootDir": "./",
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"strict": true,
"noImplicitAny": true,
"strictNullChecks": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"allowImportingTsExtensions": false
},
"include": ["zwing.ts"],
"exclude": ["node_modules", "dist", "*.test.ts"]
}
+77
View File
@@ -0,0 +1,77 @@
import { describe, expect, it } from "vitest";
import { XWING_SHARED_SIZE, combineXWing } from "./zwing.js";
// Cross-language KAT shared with Go (../kat_test.go), Rust
// (~/work/zap/zap/src/zwing.rs), and Python (../py/test_zwing.py). If
// this hex changes, Z-Wing has diverged across implementations.
const KAT_HEX =
"72df2088314a73de80c21d9593f13fcd5629c800c70b1507f0dd918fde5fe4ed";
function repeat(byte: number, len: number): Uint8Array {
return new Uint8Array(len).fill(byte);
}
function toHex(bytes: Uint8Array): string {
return Array.from(bytes)
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
}
describe("X-Wing combiner KAT (cross-language)", () => {
it("matches the canonical KAT for repeated 0x01..0x04 inputs", () => {
const got = combineXWing(
repeat(0x01, 32),
repeat(0x02, 32),
repeat(0x03, 32),
repeat(0x04, 32),
);
expect(got.length).toBe(XWING_SHARED_SIZE);
expect(toHex(got)).toBe(KAT_HEX);
});
it("is deterministic", () => {
const a = combineXWing(
repeat(0x01, 32),
repeat(0x02, 32),
repeat(0x03, 32),
repeat(0x04, 32),
);
const b = combineXWing(
repeat(0x01, 32),
repeat(0x02, 32),
repeat(0x03, 32),
repeat(0x04, 32),
);
expect(toHex(a)).toBe(toHex(b));
});
it("changes on any input flip", () => {
const base = combineXWing(
repeat(0x01, 32),
repeat(0x02, 32),
repeat(0x03, 32),
repeat(0x04, 32),
);
const flipped = repeat(0x01, 32);
flipped[0] ^= 0x01;
const mutated = combineXWing(
flipped,
repeat(0x02, 32),
repeat(0x03, 32),
repeat(0x04, 32),
);
expect(toHex(base)).not.toBe(toHex(mutated));
});
it("rejects wrong-size inputs", () => {
expect(() =>
combineXWing(
repeat(0x00, 31),
repeat(0x00, 32),
repeat(0x00, 32),
repeat(0x00, 32),
),
).toThrow();
});
});
+80
View File
@@ -0,0 +1,80 @@
/**
* Z-Wing — Lux post-quantum secure channel (TypeScript port).
*
* Z-Wing is a composition: IETF X-Wing KEM at the bottom (X25519 +
* ML-KEM-768 with the SHA3-256 combiner from
* draft-connolly-cfrg-xwing-kem) plus Lux Ed25519 + ML-DSA-65 hybrid
* identity signatures plus ChaCha20-Poly1305 channel records. The KEM
* combiner is byte-for-byte the IETF spec for full interop with the Go
* implementation in this same repo (luxfi/zwing) and any third-party
* X-Wing peer.
*
* X-Wing combiner (IETF spec, exact):
*
* shared_secret = SHA3-256( "\./" || "X-Wing" || ss_M || ss_X || ct_X || pk_X )
*
* The label is `"X-Wing"`, not `"Z-Wing"`. Z-Wing's domain separation
* lives one layer up in the HKDF info string `"lux.zwing.v1/{i2r,r2i}"`
* that derives the channel keys, and in the ML-DSA context
* `"lux.zwing.v1"` used for hybrid identity signatures.
*
* @module
*/
import { sha3_256 } from "@noble/hashes/sha3.js";
/** ML-KEM-768 public key size in bytes. */
export const MLKEM768_PUBLIC_KEY_SIZE = 1184;
/** ML-KEM-768 ciphertext size in bytes. */
export const MLKEM768_CIPHERTEXT_SIZE = 1088;
/** X25519 public key (and ciphertext) size in bytes. */
export const X25519_POINT_SIZE = 32;
/** X-Wing public key wire size: ML-KEM-768 pk || X25519 pk. */
export const XWING_PUBLIC_KEY_SIZE =
MLKEM768_PUBLIC_KEY_SIZE + X25519_POINT_SIZE;
/** X-Wing ciphertext wire size: ML-KEM-768 ct || X25519 ephemeral pk. */
export const XWING_CIPHERTEXT_SIZE =
MLKEM768_CIPHERTEXT_SIZE + X25519_POINT_SIZE;
/** Z-Wing / X-Wing shared-secret size in bytes. */
export const XWING_SHARED_SIZE = 32;
// IETF X-Wing combiner labels (exact bytes from draft-connolly-cfrg-xwing-kem).
// Three-byte prefix `\./` plus the six-byte ASCII protocol name `X-Wing`.
const XWING_LABEL_PREFIX = new Uint8Array([0x5c, 0x2e, 0x2f]); // "\./"
const XWING_LABEL_NAME = new TextEncoder().encode("X-Wing");
/**
* Combine the X-Wing KEM ingredients into a single 32-byte shared
* secret. Inputs are all 32 bytes: ss_m = ML-KEM-768 shared secret,
* ss_x = X25519 shared secret, ct_x = X25519 ephemeral public key from
* encapsulator, pk_x = X25519 static public key of recipient.
*/
export function combineXWing(
ss_m: Uint8Array,
ss_x: Uint8Array,
ct_x: Uint8Array,
pk_x: Uint8Array,
): Uint8Array {
if (ss_m.length !== XWING_SHARED_SIZE) {
throw new Error(`ss_m must be ${XWING_SHARED_SIZE} bytes`);
}
if (ss_x.length !== X25519_POINT_SIZE) {
throw new Error(`ss_x must be ${X25519_POINT_SIZE} bytes`);
}
if (ct_x.length !== X25519_POINT_SIZE) {
throw new Error(`ct_x must be ${X25519_POINT_SIZE} bytes`);
}
if (pk_x.length !== X25519_POINT_SIZE) {
throw new Error(`pk_x must be ${X25519_POINT_SIZE} bytes`);
}
const h = sha3_256.create();
h.update(XWING_LABEL_PREFIX);
h.update(XWING_LABEL_NAME);
h.update(ss_m);
h.update(ss_x);
h.update(ct_x);
h.update(pk_x);
return h.digest();
}
Binary file not shown.
+31
View File
@@ -0,0 +1,31 @@
[project]
name = "zwing"
version = "0.2.2"
description = "Z-Wing — Lux post-quantum secure channel (Python). IETF X-Wing KEM combiner."
readme = "README.md"
license = { text = "MIT OR Apache-2.0" }
authors = [{ name = "Lux Industries", email = "[email protected]" }]
requires-python = ">=3.10"
keywords = ["post-quantum", "kem", "x-wing", "ml-kem", "zwing", "lux"]
classifiers = [
"Development Status :: 4 - Beta",
"Intended Audience :: Developers",
"Topic :: Security :: Cryptography",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
]
[project.urls]
Homepage = "https://github.com/luxfi/zwing"
Repository = "https://github.com/luxfi/zwing"
Issues = "https://github.com/luxfi/zwing/issues"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["zwing.py"]
+49
View File
@@ -0,0 +1,49 @@
"""Z-Wing X-Wing combiner KAT — must match Go/Rust/JS impls byte-for-byte.
Run with:
cd py && python3 test_zwing.py -v
"""
import unittest
from zwing import (
XWING_SHARED_SIZE,
combine_xwing,
)
# Cross-language KAT shared with Go (../kat_test.go), Rust
# (~/work/zap/zap/src/zwing.rs), and JS (../js/zwing.test.ts). If
# this hex changes, Z-Wing has diverged across implementations.
KAT_HEX = "72df2088314a73de80c21d9593f13fcd5629c800c70b1507f0dd918fde5fe4ed"
class XWingCombinerKAT(unittest.TestCase):
def test_xwing_combiner_kat_constant_inputs(self):
ss_m = b"\x01" * 32
ss_x = b"\x02" * 32
ct_x = b"\x03" * 32
pk_x = b"\x04" * 32
got = combine_xwing(ss_m, ss_x, ct_x, pk_x)
self.assertEqual(len(got), XWING_SHARED_SIZE)
self.assertEqual(got.hex(), KAT_HEX)
def test_xwing_combiner_is_deterministic(self):
a = combine_xwing(b"\x01" * 32, b"\x02" * 32, b"\x03" * 32, b"\x04" * 32)
b = combine_xwing(b"\x01" * 32, b"\x02" * 32, b"\x03" * 32, b"\x04" * 32)
self.assertEqual(a, b)
def test_xwing_combiner_changes_on_any_input_change(self):
base = combine_xwing(b"\x01" * 32, b"\x02" * 32, b"\x03" * 32, b"\x04" * 32)
flipped = bytes([0x01 ^ 0x01]) + b"\x01" * 31
mutated = combine_xwing(flipped, b"\x02" * 32, b"\x03" * 32, b"\x04" * 32)
self.assertNotEqual(base, mutated)
def test_xwing_combiner_rejects_wrong_size(self):
with self.assertRaises(ValueError):
combine_xwing(b"\x00" * 31, b"\x00" * 32, b"\x00" * 32, b"\x00" * 32)
if __name__ == "__main__":
unittest.main()
+68
View File
@@ -0,0 +1,68 @@
"""Z-Wing — Lux post-quantum secure channel (Python port).
Z-Wing is a composition: IETF X-Wing KEM at the bottom (X25519 + ML-KEM-768
with the SHA3-256 combiner from draft-connolly-cfrg-xwing-kem) plus Lux
Ed25519 + ML-DSA-65 hybrid identity signatures plus ChaCha20-Poly1305
channel records. The KEM combiner is byte-for-byte the IETF spec for full
interop with the Go implementation in this same repo (luxfi/zwing).
X-Wing combiner (IETF spec, exact)::
shared_secret = SHA3-256( "\\./" || "X-Wing" || ss_M || ss_X || ct_X || pk_X )
The label is ``"X-Wing"``, not ``"Z-Wing"``. Z-Wing's domain separation
lives one layer up in the HKDF info string ``"lux.zwing.v1/{i2r,r2i}"``
that derives the channel keys, and in the ML-DSA context
``"lux.zwing.v1"`` used for hybrid identity signatures.
"""
from __future__ import annotations
import hashlib
# ML-KEM-768 public key size in bytes.
MLKEM768_PUBLIC_KEY_SIZE = 1184
# ML-KEM-768 ciphertext size in bytes.
MLKEM768_CIPHERTEXT_SIZE = 1088
# X25519 public key (and ciphertext) size in bytes.
X25519_POINT_SIZE = 32
# X-Wing public key wire size: ML-KEM-768 pk || X25519 pk.
XWING_PUBLIC_KEY_SIZE = MLKEM768_PUBLIC_KEY_SIZE + X25519_POINT_SIZE
# X-Wing ciphertext wire size: ML-KEM-768 ct || X25519 ephemeral pk.
XWING_CIPHERTEXT_SIZE = MLKEM768_CIPHERTEXT_SIZE + X25519_POINT_SIZE
# Z-Wing / X-Wing shared-secret size in bytes.
XWING_SHARED_SIZE = 32
# IETF X-Wing combiner labels (exact bytes from draft-connolly-cfrg-xwing-kem).
# Three-byte prefix `\./` plus the six-byte ASCII protocol name `X-Wing`.
_XWING_LABEL_PREFIX = b"\\./"
_XWING_LABEL_NAME = b"X-Wing"
def combine_xwing(ss_m: bytes, ss_x: bytes, ct_x: bytes, pk_x: bytes) -> bytes:
"""Combine the X-Wing KEM ingredients into a single 32-byte shared secret.
All inputs are 32 bytes:
ss_m: ML-KEM-768 shared secret
ss_x: X25519 shared secret
ct_x: X25519 ephemeral public key from encapsulator
pk_x: X25519 static public key of recipient
"""
if len(ss_m) != XWING_SHARED_SIZE:
raise ValueError(f"ss_m must be {XWING_SHARED_SIZE} bytes")
if len(ss_x) != X25519_POINT_SIZE:
raise ValueError(f"ss_x must be {X25519_POINT_SIZE} bytes")
if len(ct_x) != X25519_POINT_SIZE:
raise ValueError(f"ct_x must be {X25519_POINT_SIZE} bytes")
if len(pk_x) != X25519_POINT_SIZE:
raise ValueError(f"pk_x must be {X25519_POINT_SIZE} bytes")
h = hashlib.sha3_256()
h.update(_XWING_LABEL_PREFIX)
h.update(_XWING_LABEL_NAME)
h.update(ss_m)
h.update(ss_x)
h.update(ct_x)
h.update(pk_x)
return h.digest()