Files
zeekay e90f664a63 zwing: full PQ stack in Python and TypeScript with 100% coverage
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.
2026-05-06 14:18:16 -07:00

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;
}
}