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.
93 lines
2.7 KiB
TypeScript
93 lines
2.7 KiB
TypeScript
/**
|
|
* Z-Wing post-handshake encrypted channel for TypeScript.
|
|
*
|
|
* Wraps a `DuplexStream` with ChaCha20-Poly1305 records using
|
|
* sequence-numbered nonces. Wire format matches the Go and Rust
|
|
* implementations byte-for-byte.
|
|
*/
|
|
|
|
import { chacha20poly1305 } from "@noble/ciphers/chacha.js";
|
|
|
|
import {
|
|
DuplexStream,
|
|
HandshakeOutput,
|
|
MAX_FRAME_SIZE,
|
|
readFrame,
|
|
writeFrame,
|
|
} from "./handshake.js";
|
|
import type { IdentityPublic } from "./identity.js";
|
|
|
|
const CHACHA_OVERHEAD = 16;
|
|
const NONCE_LEN = 12;
|
|
|
|
/** Build the 12-byte nonce from a 64-bit BE counter. */
|
|
export function nonceFor(seq: bigint): Uint8Array {
|
|
const n = new Uint8Array(NONCE_LEN);
|
|
for (let i = 0; i < 8; i++) {
|
|
n[NONCE_LEN - 1 - i] = Number((seq >> BigInt(i * 8)) & 0xffn);
|
|
}
|
|
return n;
|
|
}
|
|
|
|
export class Channel {
|
|
private inner: DuplexStream;
|
|
public readonly remote: IdentityPublic;
|
|
private rxKey: Uint8Array;
|
|
private txKey: Uint8Array;
|
|
private rxSeq = 0n;
|
|
private txSeq = 0n;
|
|
private rxOverflow: Uint8Array | null = null;
|
|
|
|
constructor(inner: DuplexStream, out: HandshakeOutput, initiator: boolean) {
|
|
this.inner = inner;
|
|
this.remote = out.remote;
|
|
if (initiator) {
|
|
this.txKey = out.keyI2R;
|
|
this.rxKey = out.keyR2I;
|
|
} else {
|
|
this.txKey = out.keyR2I;
|
|
this.rxKey = out.keyI2R;
|
|
}
|
|
// Best-effort wipe of the handshake-side keys.
|
|
out.shared.fill(0);
|
|
// Note: keyI2R / keyR2I have been copied into rxKey / txKey above
|
|
// by reference — wiping them is the responsibility of the caller
|
|
// since we don't own the original buffers exclusively.
|
|
}
|
|
|
|
/** Encrypt and send a single record. Splits if larger than MAX. */
|
|
async send(plaintext: Uint8Array): Promise<void> {
|
|
const maxPlain = MAX_FRAME_SIZE - CHACHA_OVERHEAD;
|
|
let written = 0;
|
|
while (written < plaintext.length) {
|
|
const end = Math.min(written + maxPlain, plaintext.length);
|
|
const chunk = plaintext.slice(written, end);
|
|
if (this.txSeq === 0xffffffffffffffffn) {
|
|
throw new Error("zwing: AEAD sequence number exhausted");
|
|
}
|
|
const nonce = nonceFor(this.txSeq);
|
|
const ct = chacha20poly1305(this.txKey, nonce).encrypt(chunk);
|
|
this.txSeq += 1n;
|
|
await writeFrame(this.inner, ct);
|
|
written = end;
|
|
}
|
|
}
|
|
|
|
/** Receive one full record. */
|
|
async recv(): Promise<Uint8Array> {
|
|
if (this.rxOverflow) {
|
|
const buf = this.rxOverflow;
|
|
this.rxOverflow = null;
|
|
return buf;
|
|
}
|
|
const frame = await readFrame(this.inner);
|
|
if (this.rxSeq === 0xffffffffffffffffn) {
|
|
throw new Error("zwing: AEAD sequence number exhausted");
|
|
}
|
|
const nonce = nonceFor(this.rxSeq);
|
|
const pt = chacha20poly1305(this.rxKey, nonce).decrypt(frame);
|
|
this.rxSeq += 1n;
|
|
return pt;
|
|
}
|
|
}
|