Z-Wing v0.1.0 briefly relabelled the X-Wing KDF combiner with a
"Z-Wing" domain separator. That was wrong: the only reason to use
IETF X-Wing is exact interop with anything that speaks the IETF
draft, and rebranding the combiner breaks that interop while adding
zero security. The Lux contribution lives ONE layer up.
This commit restores the IETF labels everywhere:
* kem.go: combineXWing writes "\./" || "X-Wing" || ss_M || ss_X ||
ct_X || pk_X. The label slice names go back to xwingLabelPrefix /
xwingLabelName.
* LLM.md: rewritten to position Z-Wing as a composition (real X-Wing
KEM + Ed25519+ML-DSA-65 hybrid identity + ChaCha20-Poly1305 +
RNS-or-TCP transport), not a separate KEM. Z-Wing's domain
separation lives in the HKDF info string "lux.zwing.v1/{i2r,r2i}"
and the ML-DSA context "lux.zwing.v1", not in the KEM.
Tests pass with the IETF labels (TestHandshakeRoundTrip,
TestZAPOverZWing, TestForwardSecrecy, TestSequencedNonces,
TestIdentityMismatch, TestDialListen).
The package is still called zwing and the construction is still
called Z-Wing. The KEM at the bottom of the stack is and stays IETF
X-Wing.
9.8 KiB
Z-Wing — Lux Post-Quantum Secure Channel
What Z-Wing is
Z-Wing is a composition, not a new cryptographic primitive. It bolts three ingredients into a single net.Conn:
┌────────────────────────────────────────────────────────────────────┐
│ Z-Wing │
│ │
│ KEM = IETF X-Wing (X25519 + ML-KEM-768; SHA3-256 │
│ combiner; the "X-Wing" label is the IETF spec — │
│ this is exact interop, not a fork) │
│ Identity = Ed25519 + ML-DSA-65 hybrid signatures │
│ Transport = any net.Conn (TCP today; RNS in the LP-9702 │
│ deployment path so Z-Wing can ride a mesh routed │
│ link) │
│ AEAD = ChaCha20-Poly1305 with sequence-numbered nonces │
│ │
└────────────────────────────────────────────────────────────────────┘
If you strip the layers, the cryptography is real X-Wing byte-for- byte. Two Z-Wing peers can interop with anything that speaks the IETF draft-connolly-cfrg-xwing-kem KEM. The Lux contribution is what wraps the KEM:
- a hybrid Ed25519 + ML-DSA-65 identity signed into the handshake transcript (so an attacker who breaks one of the two signatures still can't forge a session);
- a single canonical wire format with no negotiation (no downgrade);
- a
net.Connoutput so any Lux service that already speaks ZAP gets PQ on the wire by swappingnet.Dialforzwing.Dial; - a planned RNS routed link as the canonical transport (LP-9702) so the channel survives mesh-routed networks, not just point-to-point TCP.
If two endpoints don't both speak Z-Wing v1, they don't talk. There is exactly one way to do this.
Layering with ZAP
Application: ZAP RPC (luxfi/api/zap) <- unchanged
Channel: Z-Wing <- this package
KEM: IETF X-Wing (X25519 + ML-KEM-768)
Identity: Ed25519 + ML-DSA-65 hybrid signatures
AEAD: ChaCha20-Poly1305 with sequence-numbered nonces
Routing: Any net.Conn — TCP (Dial), Unix socket, RNS link, or any
other transport. Use `Client(conn, cfg)` / `Server(conn, cfg)`
for non-TCP transports.
The Conn returned by Dial / Listener.Accept() implements net.Conn,
so any Lux service that already speaks ZAP gets PQ on the wire by simply
swapping its dialer:
raw, _ := zwing.Dial(ctx, "host:9999", &zwing.Config{LocalIdentity: id})
zapConn := zap.NewConn(raw, nil) // ZAP unchanged
Default port
Z-Wing inherits the canonical Lux ZAP port: TCP 9999. Override only when running multiple Z-Wing servers on the same host.
Wire format
Frame
Every transport frame on a Z-Wing channel:
[4 bytes BE: payload length][payload bytes]
MaxFrameSize = 1 MiB. The payload is either a handshake message
(during the handshake) or an AEAD-sealed application record (after).
Handshake (1-RTT)
Initiator -> Responder : HandshakeInit
[2 BE idLen][IdentityPublic bytes][2 BE sigLen][hybrid signature]
Responder -> Initiator : HandshakeResponse
[XWingCiphertextSize bytes: ML-KEM ct || X25519 ephemeral pk]
[2 BE encLen][AEAD-sealed responder identity + signature]
- The initiator signs
(label || IdentityPublic)so a passive observer cannot lift the public identity onto another protocol. - The responder signs the transcript hash that covers the initiator's public identity AND the X-Wing ciphertext, binding the signature to the session.
- The responder identity is encrypted under an HKDF-derived key from the X-Wing shared secret, so passive observers do not learn the responder's long-term identity.
IdentityPublic
[Ed25519 pk: 32][ML-DSA-65 pk: 1952][X-Wing pk: 1216]
total: 3200 bytes
X-Wing pk = ML-KEM-768 pk (1184) || X25519 pk (32).
X-Wing combiner (IETF spec, exact)
Z-Wing uses the IETF X-Wing combiner verbatim — same labels, same hash, same input order. No Lux-specific tweaks at the KEM layer:
shared_secret = SHA3-256( "\./" || "X-Wing" || ss_M || ss_X || ct_X || pk_X )
| Input | Bytes | Source |
|---|---|---|
"\./" |
3 | label prefix (backslash, dot, slash) |
"X-Wing" |
6 | ASCII protocol name (IETF spec) |
ss_M |
32 | ML-KEM-768 shared secret |
ss_X |
32 | X25519 shared secret |
ct_X |
32 | X25519 ephemeral public key from encapsulator |
pk_X |
32 | X25519 static public key of recipient |
Hash construction: SHA3-256, no HKDF. Inputs are fixed-length so no length separators are required.
In the Z-Wing handshake the responder is the encapsulator (it sends ct_X) and the initiator is the recipient (its X-Wing static is the target of the encapsulation).
The label is "X-Wing", not "Z-Wing". The whole point of using the
IETF KEM is to interop with any X-Wing peer; rebranding the combiner
would break that interop and add zero security.
Channel keys
After the handshake:
keyI2R = HKDF-SHA256(shared, "lux.zwing.v1/i2r", 32)
keyR2I = HKDF-SHA256(shared, "lux.zwing.v1/r2i", 32)
Each direction has its own ChaCha20-Poly1305 key. Nonce = 12 bytes,
high 4 bytes zero, low 8 bytes are the big-endian sequence counter.
A 64-bit counter would last 5.8e11 years at one record per nanosecond;
exhausting it returns ErrSequenceExhausted.
The lux.zwing.v1 HKDF info string is where the Lux value-add lives —
not in the KEM combiner. Z-Wing channel keys are domain-separated from
any other system that might also use IETF X-Wing.
Forward secrecy comes from the X25519 ephemeral inside X-Wing. The
ephemeral private key and intermediate ss_M / ss_X values are zeroed
once the shared secret is derived. Channel keys are zeroed after the
AEADs have been constructed.
Identity signatures
The Identity type holds three keypairs: Ed25519, ML-DSA-65, and X-Wing.
Signature wire format:
[Ed25519 sig: 64][ML-DSA-65 sig: 3309] total: 3373 bytes
Both must verify or ErrSignatureInvalid is returned. ML-DSA is signed
with the lux.zwing.v1 context per FIPS 204 §5.2 so signatures are not
portable across protocols that share identity keys.
Public API
// Identity
id, _ := zwing.GenerateIdentity()
pub := id.Public() // serialise + hand to a peer
// Server
ln, _ := zwing.Listen(":9999", &zwing.Config{LocalIdentity: id})
for {
conn, err := ln.Accept() // post-handshake net.Conn
if err != nil { break }
go handle(conn)
}
// Client
conn, _ := zwing.Dial(ctx, "host:9999", &zwing.Config{
LocalIdentity: clientID,
ExpectedRemote: pinnedServerPub, // optional; nil = any valid peer
})
Client(net.Conn, *Config) and Server(net.Conn, *Config) wrap an
existing connection (Unix socket, TLS conn, in-memory pipe, RNS link)
without TCP Dial/Listen.
What's NOT in this version
- RNS routing. Z-Wing-over-RNS is the LP-9702 deployment path; v0.2.0
ships Z-Wing-over-TCP only. Once RNS is in, the same
Client(conn, cfg)entry point will accept an RNS link asconn— no protocol changes required. - AES-256-GCM AEAD. Single ciphersuite; ChaCha20-Poly1305 only.
- Cipher / KEM negotiation. There is one Z-Wing v1.
Testing
GOWORK=off go test ./... -count=1
Six tests cover the spec:
| Test | What it proves |
|---|---|
TestHandshakeRoundTrip |
end-to-end handshake; both peers see correct remote identity; bidirectional traffic works |
TestDialListen |
TCP Dial + Listener.Accept, payload round-trip |
TestIdentityMismatch |
Config.ExpectedRemote = wrong returns ErrIdentityMismatch |
TestForwardSecrecy |
channel keys are zeroed after the AEAD is constructed |
TestSequencedNonces |
three sequential records each decrypt cleanly under increasing nonces |
TestZAPOverZWing |
zap.NewConn over a Z-Wing client; one ZAP RPC round-trip succeeds |
Files
| File | Purpose |
|---|---|
doc.go |
package documentation |
errors.go |
typed errors |
kem.go |
IETF X-Wing KEM (X25519 + ML-KEM-768 + SHA3-256 combiner with exact "X-Wing" label) |
identity.go |
hybrid Ed25519 + ML-DSA-65 identity with X-Wing static key |
wire.go |
length-prefixed framing |
handshake.go |
initiator/responder state machines |
channel.go |
post-handshake net.Conn with ChaCha20-Poly1305 |
config.go |
Config and DefaultPort |
dial.go |
Dial / Client |
listen.go |
Listen / Listener |
zwing_test.go |
core protocol tests |
zap_test.go |
ZAP-over-Z-Wing integration |
Naming
- The package is
zwingand the construction isZ-Wing. - The KEM at the bottom of the stack is IETF X-Wing by name and by bytes. We keep the IETF name there because that's the interop contract; renaming the combiner would be misleading and break cross-stack compatibility.
lux.zwing.v1appears as the HKDF context for channel keys and the ML-DSA context for identity signatures. That is where Z-Wing's domain-separation lives.
See also
~/work/lux/api/zap/LLM.md— the application protocol that runs on top~/work/zap/zap/src/crypto.rs— Rust hybrid handshake reference~/work/lux/crypto— ML-KEM, ML-DSA, Ed25519 primitives used here~/work/lux/papers/lps/lp-9702-z-wing.tex— Z-Wing composition specdraft-connolly-cfrg-xwing-kem— IETF X-Wing combiner spec (the KEM Z-Wing wraps)