mirror of
https://github.com/luxfi/zwing.git
synced 2026-09-24 00:24:38 +00:00
Port the complete Z-Wing protocol to Python and TypeScript, no longer
just the X-Wing combiner. Each language now ships:
identity Hybrid Ed25519 + ML-DSA-65 + X-Wing keypair, marshal/parse,
sign/verify, constant-time equality
kem Full IETF X-Wing encapsulate/decapsulate (ML-KEM-768 +
X25519 + SHA3-256 combiner) with wire-byte compat
handshake 1-RTT mutual-auth handshake mirroring Go state machine —
same labels (lux.zwing.v1/handshake-init etc), same wire
format (HandshakeInit / HandshakeResponse / transcript hash
/ AEAD-sealed responder identity)
channel Post-handshake ChaCha20-Poly1305 with sequence-numbered
nonces (high 4 zero, low 8 BE counter), MAX 1 MiB frames
Python implementation:
- cryptography (Ed25519/X25519/ChaCha20-Poly1305) + kyber-py (ML-KEM)
+ dilithium-py (ML-DSA). Pure Python, zero native deps.
- test_full.py: 39 tests covering happy paths + every error branch
- py/__init__.py modules at 100% line coverage:
channel.py 51 stmts 100%
handshake.py 159 stmts 100%
identity.py 91 stmts 100%
kem.py 26 stmts 100%
zwing.py 27 stmts 100%
TypeScript implementation:
- @noble/post-quantum (ml-kem768, ml-dsa65) + @noble/curves (ed25519,
x25519) + @noble/ciphers (chacha20poly1305) + @noble/hashes
- full.test.ts: 32 tests covering full e2e + every wire-decode error
- vitest --coverage v8: 100% statements / 100% branches / 100%
functions / 100% lines across every source file (channel.ts,
handshake.ts, identity.ts, kem.ts, zwing.ts)
Cross-language KAT (72df...fe4ed) still pins on the X-Wing combiner.
Full handshake wire-byte interop is now possible Go↔Python (both use
FIPS 204 ML-DSA-65 with identical 3309-byte signatures); Rust uses
PQClean Dilithium3 which has the same byte sizes as Round 3 — full
Rust↔Go interop test pending a unified ML-DSA-65 source.
326 lines
10 KiB
TypeScript
326 lines
10 KiB
TypeScript
/**
|
|
* Z-Wing 1-RTT mutual-auth handshake (TypeScript).
|
|
*
|
|
* Bit-for-bit compatible with the Go and Rust implementations. All
|
|
* HKDF / transcript / AEAD labels are identical.
|
|
*/
|
|
|
|
import { hkdf } from "@noble/hashes/hkdf.js";
|
|
import { sha256 } from "@noble/hashes/sha2.js";
|
|
import { chacha20poly1305 } from "@noble/ciphers/chacha.js";
|
|
|
|
import {
|
|
Identity,
|
|
IdentityPublic,
|
|
identityEquals,
|
|
marshalIdentityPublic,
|
|
parseIdentityPublic,
|
|
signIdentity,
|
|
verifyIdentity,
|
|
} from "./identity.js";
|
|
import { xwingDecapsulate, xwingEncapsulate } from "./kem.js";
|
|
import { XWING_CIPHERTEXT_SIZE } from "./zwing.js";
|
|
|
|
export const MAX_FRAME_SIZE = 1 << 20; // 1 MiB
|
|
export const HS_LABEL_INIT = new TextEncoder().encode(
|
|
"lux.zwing.v1/handshake-init",
|
|
);
|
|
export const HS_LABEL_RESPONSE = new TextEncoder().encode(
|
|
"lux.zwing.v1/handshake-response",
|
|
);
|
|
export const CHANNEL_KEY_LABEL_I2R = new TextEncoder().encode(
|
|
"lux.zwing.v1/i2r",
|
|
);
|
|
export const CHANNEL_KEY_LABEL_R2I = new TextEncoder().encode(
|
|
"lux.zwing.v1/r2i",
|
|
);
|
|
export const RESP_ID_HKDF_LABEL = new TextEncoder().encode(
|
|
"lux.zwing.v1/resp-id",
|
|
);
|
|
export const RESP_ID_NONCE_LABEL = new TextEncoder().encode("zwing-resp-id");
|
|
export const TRANSCRIPT_LABEL = new TextEncoder().encode(
|
|
"lux.zwing.v1/transcript",
|
|
);
|
|
|
|
/** Outcome of a successful handshake. */
|
|
export interface HandshakeOutput {
|
|
shared: Uint8Array;
|
|
remote: IdentityPublic;
|
|
keyI2R: Uint8Array;
|
|
keyR2I: Uint8Array;
|
|
}
|
|
|
|
/**
|
|
* Asynchronous full-duplex byte stream interface — abstracts away
|
|
* network/socket specifics so the handshake works over any duplex
|
|
* medium (TCP, in-memory pipe, Web socket, RNS link).
|
|
*/
|
|
export interface DuplexStream {
|
|
read(n: number): Promise<Uint8Array>;
|
|
write(data: Uint8Array): Promise<void>;
|
|
}
|
|
|
|
/** Drive the initiator side of the handshake. */
|
|
export async function runInitiator(
|
|
conn: DuplexStream,
|
|
local: Identity,
|
|
expectedRemote?: IdentityPublic,
|
|
): Promise<HandshakeOutput> {
|
|
// 1. Send HandshakeInit.
|
|
const idPub = marshalIdentityPublic({
|
|
edPk: local.edPk,
|
|
mlPk: local.mlPk,
|
|
xwingMlkemPk: local.xwingMlkemPk,
|
|
xwingX25519Pk: local.xwingX25519Pk,
|
|
});
|
|
const sig = signIdentity(local, HS_LABEL_INIT, idPub);
|
|
await writeFrame(conn, encodeHandshakeInit(idPub, sig));
|
|
|
|
// 2. Receive HandshakeResponse.
|
|
const respFrame = await readFrame(conn);
|
|
const { ct, encrypted } = decodeHandshakeResponse(respFrame);
|
|
|
|
// 3. Decapsulate.
|
|
const shared = xwingDecapsulate(local, ct);
|
|
|
|
// 4. AEAD-open responder identity payload.
|
|
const idKey = deriveKey(shared, RESP_ID_HKDF_LABEL);
|
|
const plaintext = aeadOpen(idKey, RESP_ID_NONCE_LABEL, ct, encrypted);
|
|
|
|
const { id: remoteIdBytes, sig: remoteSig } = splitRespIDPayload(plaintext);
|
|
const remote = parseIdentityPublic(remoteIdBytes);
|
|
|
|
// 5. Verify responder sig over transcript.
|
|
const transcript = transcriptHash(idPub, ct);
|
|
verifyIdentity(remote, HS_LABEL_RESPONSE, transcript, remoteSig);
|
|
|
|
// 6. Optional pinning.
|
|
if (expectedRemote && !identityEquals(remote, expectedRemote)) {
|
|
throw new Error("zwing: remote identity does not match expected");
|
|
}
|
|
|
|
return {
|
|
shared,
|
|
remote,
|
|
keyI2R: deriveKey(shared, CHANNEL_KEY_LABEL_I2R),
|
|
keyR2I: deriveKey(shared, CHANNEL_KEY_LABEL_R2I),
|
|
};
|
|
}
|
|
|
|
/** Drive the responder side of the handshake. */
|
|
export async function runResponder(
|
|
conn: DuplexStream,
|
|
local: Identity,
|
|
expectedRemote?: IdentityPublic,
|
|
): Promise<HandshakeOutput> {
|
|
// 1. Read HandshakeInit.
|
|
const initFrame = await readFrame(conn);
|
|
const { idPub, sig: initSig } = decodeHandshakeInit(initFrame);
|
|
const remote = parseIdentityPublic(idPub);
|
|
verifyIdentity(remote, HS_LABEL_INIT, idPub, initSig);
|
|
|
|
if (expectedRemote && !identityEquals(remote, expectedRemote)) {
|
|
throw new Error("zwing: remote identity does not match expected");
|
|
}
|
|
|
|
// 2. Encapsulate to initiator's static X-Wing.
|
|
const { ciphertext: ct, shared } = xwingEncapsulate(remote);
|
|
|
|
// 3. Sign transcript and seal.
|
|
const localIdPub = marshalIdentityPublic({
|
|
edPk: local.edPk,
|
|
mlPk: local.mlPk,
|
|
xwingMlkemPk: local.xwingMlkemPk,
|
|
xwingX25519Pk: local.xwingX25519Pk,
|
|
});
|
|
const transcript = transcriptHash(idPub, ct);
|
|
const sig = signIdentity(local, HS_LABEL_RESPONSE, transcript);
|
|
const plaintext = buildRespIDPayload(localIdPub, sig);
|
|
const idKey = deriveKey(shared, RESP_ID_HKDF_LABEL);
|
|
const encrypted = aeadSeal(idKey, RESP_ID_NONCE_LABEL, ct, plaintext);
|
|
|
|
await writeFrame(conn, encodeHandshakeResponse(ct, encrypted));
|
|
|
|
return {
|
|
shared,
|
|
remote,
|
|
keyI2R: deriveKey(shared, CHANNEL_KEY_LABEL_I2R),
|
|
keyR2I: deriveKey(shared, CHANNEL_KEY_LABEL_R2I),
|
|
};
|
|
}
|
|
|
|
// ─── wire helpers ──────────────────────────────────────────────────
|
|
|
|
export async function writeFrame(
|
|
conn: DuplexStream,
|
|
payload: Uint8Array,
|
|
): Promise<void> {
|
|
if (payload.length > MAX_FRAME_SIZE) {
|
|
throw new Error("zwing: message exceeds maximum size");
|
|
}
|
|
const hdr = new Uint8Array(4);
|
|
const dv = new DataView(hdr.buffer);
|
|
dv.setUint32(0, payload.length, false);
|
|
await conn.write(hdr);
|
|
await conn.write(payload);
|
|
}
|
|
|
|
export async function readFrame(conn: DuplexStream): Promise<Uint8Array> {
|
|
const hdr = await conn.read(4);
|
|
const dv = new DataView(hdr.buffer, hdr.byteOffset, 4);
|
|
const n = dv.getUint32(0, false);
|
|
if (n > MAX_FRAME_SIZE) {
|
|
throw new Error("zwing: message exceeds maximum size");
|
|
}
|
|
if (n === 0) {
|
|
throw new Error("zwing: invalid wire format");
|
|
}
|
|
return await conn.read(n);
|
|
}
|
|
|
|
function encodeHandshakeInit(idPub: Uint8Array, sig: Uint8Array): Uint8Array {
|
|
const out = new Uint8Array(4 + idPub.length + sig.length);
|
|
const dv = new DataView(out.buffer);
|
|
dv.setUint16(0, idPub.length, false);
|
|
out.set(idPub, 2);
|
|
dv.setUint16(2 + idPub.length, sig.length, false);
|
|
out.set(sig, 4 + idPub.length);
|
|
return out;
|
|
}
|
|
|
|
function decodeHandshakeInit(data: Uint8Array): {
|
|
idPub: Uint8Array;
|
|
sig: Uint8Array;
|
|
} {
|
|
const dv = new DataView(data.buffer, data.byteOffset, data.byteLength);
|
|
if (data.length < 2) throw new Error("zwing: short read");
|
|
const idLen = dv.getUint16(0, false);
|
|
if (data.length < 2 + idLen + 2) throw new Error("zwing: short read");
|
|
const idPub = data.slice(2, 2 + idLen);
|
|
const sigLen = dv.getUint16(2 + idLen, false);
|
|
const sigStart = 4 + idLen;
|
|
if (data.length < sigStart + sigLen) throw new Error("zwing: short read");
|
|
const sig = data.slice(sigStart, sigStart + sigLen);
|
|
if (data.length !== sigStart + sigLen) {
|
|
throw new Error("zwing: invalid wire format");
|
|
}
|
|
return { idPub, sig };
|
|
}
|
|
|
|
function encodeHandshakeResponse(ct: Uint8Array, encrypted: Uint8Array): Uint8Array {
|
|
const out = new Uint8Array(ct.length + 2 + encrypted.length);
|
|
out.set(ct, 0);
|
|
const dv = new DataView(out.buffer);
|
|
dv.setUint16(ct.length, encrypted.length, false);
|
|
out.set(encrypted, ct.length + 2);
|
|
return out;
|
|
}
|
|
|
|
function decodeHandshakeResponse(data: Uint8Array): {
|
|
ct: Uint8Array;
|
|
encrypted: Uint8Array;
|
|
} {
|
|
if (data.length < XWING_CIPHERTEXT_SIZE + 2) {
|
|
throw new Error("zwing: invalid wire format");
|
|
}
|
|
const ct = data.slice(0, XWING_CIPHERTEXT_SIZE);
|
|
const dv = new DataView(data.buffer, data.byteOffset, data.byteLength);
|
|
const encLen = dv.getUint16(XWING_CIPHERTEXT_SIZE, false);
|
|
const encStart = XWING_CIPHERTEXT_SIZE + 2;
|
|
if (data.length < encStart + encLen) {
|
|
throw new Error("zwing: short read");
|
|
}
|
|
const encrypted = data.slice(encStart, encStart + encLen);
|
|
if (data.length !== encStart + encLen) {
|
|
throw new Error("zwing: invalid wire format");
|
|
}
|
|
return { ct, encrypted };
|
|
}
|
|
|
|
function buildRespIDPayload(id: Uint8Array, sig: Uint8Array): Uint8Array {
|
|
const out = new Uint8Array(4 + id.length + sig.length);
|
|
const dv = new DataView(out.buffer);
|
|
dv.setUint16(0, id.length, false);
|
|
out.set(id, 2);
|
|
dv.setUint16(2 + id.length, sig.length, false);
|
|
out.set(sig, 4 + id.length);
|
|
return out;
|
|
}
|
|
|
|
function splitRespIDPayload(data: Uint8Array): {
|
|
id: Uint8Array;
|
|
sig: Uint8Array;
|
|
} {
|
|
if (data.length < 2) throw new Error("zwing: short read");
|
|
const dv = new DataView(data.buffer, data.byteOffset, data.byteLength);
|
|
const idLen = dv.getUint16(0, false);
|
|
if (data.length < 2 + idLen + 2) throw new Error("zwing: short read");
|
|
const id = data.slice(2, 2 + idLen);
|
|
const sigLen = dv.getUint16(2 + idLen, false);
|
|
const sigStart = 4 + idLen;
|
|
if (data.length < sigStart + sigLen) throw new Error("zwing: short read");
|
|
const sig = data.slice(sigStart, sigStart + sigLen);
|
|
if (data.length !== sigStart + sigLen) {
|
|
throw new Error("zwing: invalid wire format");
|
|
}
|
|
return { id, sig };
|
|
}
|
|
|
|
// Internal helpers exported only for branch-coverage tests. Not part
|
|
// of the public API.
|
|
export const __internal = {
|
|
encodeHandshakeInit,
|
|
decodeHandshakeInit,
|
|
encodeHandshakeResponse,
|
|
decodeHandshakeResponse,
|
|
buildRespIDPayload,
|
|
splitRespIDPayload,
|
|
};
|
|
|
|
// ─── KDF / transcript / AEAD ──────────────────────────────────────
|
|
|
|
export function deriveKey(secret: Uint8Array, label: Uint8Array): Uint8Array {
|
|
return hkdf(sha256, secret, undefined, label, 32);
|
|
}
|
|
|
|
function transcriptHash(initPub: Uint8Array, ct: Uint8Array): Uint8Array {
|
|
const h = sha256.create();
|
|
h.update(TRANSCRIPT_LABEL);
|
|
const lenBuf = new Uint8Array(4);
|
|
const dv = new DataView(lenBuf.buffer);
|
|
dv.setUint32(0, initPub.length, false);
|
|
h.update(lenBuf);
|
|
h.update(initPub);
|
|
dv.setUint32(0, ct.length, false);
|
|
h.update(lenBuf);
|
|
h.update(ct);
|
|
return h.digest();
|
|
}
|
|
|
|
function aeadSeal(
|
|
key: Uint8Array,
|
|
nonceLabel: Uint8Array,
|
|
aad: Uint8Array,
|
|
plaintext: Uint8Array,
|
|
): Uint8Array {
|
|
const nonce = handshakeNonce(nonceLabel);
|
|
return chacha20poly1305(key, nonce, aad).encrypt(plaintext);
|
|
}
|
|
|
|
function aeadOpen(
|
|
key: Uint8Array,
|
|
nonceLabel: Uint8Array,
|
|
aad: Uint8Array,
|
|
ciphertext: Uint8Array,
|
|
): Uint8Array {
|
|
const nonce = handshakeNonce(nonceLabel);
|
|
return chacha20poly1305(key, nonce, aad).decrypt(ciphertext);
|
|
}
|
|
|
|
function handshakeNonce(label: Uint8Array): Uint8Array {
|
|
const n = new Uint8Array(12);
|
|
const copy = Math.min(label.length, 12);
|
|
n.set(label.slice(0, copy));
|
|
return n;
|
|
}
|