zwing v0.1.0: Z-Wing PQ secure channel for Lux

Z-Wing is the canonical Lux post-quantum secure channel. One construction,
one wire format, no negotiation. Layered design exposes a net.Conn so
luxfi/api/zap (and any other stream protocol) runs unchanged on top.

  KEM:       X-Wing  (X25519 + ML-KEM-768, IETF draft-connolly-cfrg-xwing-kem
                     SHA3-256 combiner: "\./" || "X-Wing" || ssM || ssX || ctX || pkX)
  Identity:  Ed25519 + ML-DSA-65 hybrid signatures
  AEAD:      ChaCha20-Poly1305 with sequence-numbered nonces (per direction)
  Transport: TCP (RNS routing is a follow-up package)

Public API: GenerateIdentity / Dial / Listen / Client / Server.

Six tests cover the spec end-to-end: handshake round-trip, TCP dial+listen,
ExpectedRemote pinning, key-zero-after-handshake forward secrecy, sequenced
nonces, and ZAP-over-Z-Wing application round-trip.
This commit is contained in:
2026-05-06 12:18:21 -07:00
commit f21120ce2f
16 changed files with 1769 additions and 0 deletions
+29
View File
@@ -0,0 +1,29 @@
BSD 3-Clause License
Copyright (c) 2024, Lux Partners Limited
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
3. Neither the name of the copyright holder nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+202
View File
@@ -0,0 +1,202 @@
# Z-Wing — Lux Post-Quantum Secure Channel
## Overview
Z-Wing is the canonical Lux PQ secure channel. One construction, one wire
format, no negotiation. If two endpoints don't both speak Z-Wing v1, they
don't talk.
Layering:
```
Application: ZAP RPC (luxfi/api/zap) <- unchanged
Channel: Z-Wing <- this package
KEM: X-Wing (X25519 + ML-KEM-768, IETF draft-connolly-cfrg-xwing-kem)
Identity: Ed25519 + ML-DSA-65 hybrid signatures
AEAD: ChaCha20-Poly1305 with sequence-numbered nonces
Routing: Plain TCP (this version) or RNS (follow-up)
```
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:
```go
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)
```
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 |
| `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).
## 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`.
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
```go
// 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 a follow-up package (LP-9702 deployment
path); v0.1.0 ships Z-Wing-over-TCP only.
* 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` | X-Wing KEM (X25519 + ML-KEM-768 + SHA3-256 combiner) |
| `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 |
## 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
* `draft-connolly-cfrg-xwing-kem` — IETF X-Wing combiner spec
+200
View File
@@ -0,0 +1,200 @@
// Copyright (C) 2020-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package zwing
import (
"crypto/cipher"
"encoding/binary"
"errors"
"net"
"sync"
"time"
"golang.org/x/crypto/chacha20poly1305"
)
// Conn is a Z-Wing secure channel implementing net.Conn. After the
// handshake, every Read/Write goes through ChaCha20-Poly1305 with
// per-direction sequence-numbered nonces.
type Conn struct {
raw net.Conn
remote *IdentityPublic
readMu sync.Mutex
readAEAD cipher.AEAD
readSeq uint64
readBuf []byte // overflow from a previous Read
writeMu sync.Mutex
writeAEAD cipher.AEAD
writeSeq uint64
}
// newConn wraps a freshly handshaken net.Conn. role determines which
// direction's key is used for reading vs writing.
func newConn(raw net.Conn, res *handshakeResult, initiator bool) (*Conn, error) {
c := &Conn{raw: raw, remote: res.remote}
var rxKey, txKey []byte
if initiator {
txKey = res.keyI2R
rxKey = res.keyR2I
} else {
txKey = res.keyR2I
rxKey = res.keyI2R
}
rx, err := chacha20poly1305.New(rxKey)
if err != nil {
return nil, err
}
tx, err := chacha20poly1305.New(txKey)
if err != nil {
return nil, err
}
c.readAEAD = rx
c.writeAEAD = tx
// Best-effort zeroing of the channel keys held by the handshake result
// once the AEADs have copied them internally.
zero(res.keyI2R)
zero(res.keyR2I)
return c, nil
}
// RemoteIdentity returns the verified remote IdentityPublic. Callers may
// pin specific peers via Config.ExpectedRemote, but this getter lets
// servers see who connected once the handshake succeeded.
func (c *Conn) RemoteIdentity() *IdentityPublic { return c.remote }
// Read reads decrypted plaintext into b. A Z-Wing record may be larger
// than b, in which case the remainder is buffered for the next call.
func (c *Conn) Read(b []byte) (int, error) {
c.readMu.Lock()
defer c.readMu.Unlock()
if len(c.readBuf) > 0 {
n := copy(b, c.readBuf)
c.readBuf = c.readBuf[n:]
if len(c.readBuf) == 0 {
c.readBuf = nil
}
return n, nil
}
frame, err := readFrame(c.raw)
if err != nil {
return 0, err
}
if c.readSeq == ^uint64(0) {
return 0, ErrSequenceExhausted
}
nonce := nonceFor(c.readSeq)
plaintext, err := c.readAEAD.Open(nil, nonce[:], frame, nil)
if err != nil {
return 0, ErrCiphertextCorrupted
}
c.readSeq++
n := copy(b, plaintext)
if n < len(plaintext) {
c.readBuf = plaintext[n:]
}
return n, nil
}
// Write encrypts b as a single Z-Wing record and writes it to the
// underlying conn. Application payloads larger than MaxFrameSize-tag are
// split.
func (c *Conn) Write(b []byte) (int, error) {
c.writeMu.Lock()
defer c.writeMu.Unlock()
const maxPlain = MaxFrameSize - chacha20poly1305.Overhead
written := 0
for written < len(b) {
end := written + maxPlain
if end > len(b) {
end = len(b)
}
chunk := b[written:end]
if c.writeSeq == ^uint64(0) {
return written, ErrSequenceExhausted
}
nonce := nonceFor(c.writeSeq)
ciphertext := c.writeAEAD.Seal(nil, nonce[:], chunk, nil)
c.writeSeq++
if err := writeFrame(c.raw, ciphertext); err != nil {
return written, err
}
written += len(chunk)
}
return written, nil
}
// Close closes the underlying connection.
func (c *Conn) Close() error {
if c.raw == nil {
return ErrChannelClosed
}
return c.raw.Close()
}
// LocalAddr returns the underlying local address.
func (c *Conn) LocalAddr() net.Addr { return c.raw.LocalAddr() }
// RemoteAddr returns the underlying remote address.
func (c *Conn) RemoteAddr() net.Addr { return c.raw.RemoteAddr() }
// SetDeadline sets read+write deadline on the underlying conn.
func (c *Conn) SetDeadline(t time.Time) error { return c.raw.SetDeadline(t) }
// SetReadDeadline sets the read deadline on the underlying conn.
func (c *Conn) SetReadDeadline(t time.Time) error { return c.raw.SetReadDeadline(t) }
// SetWriteDeadline sets the write deadline on the underlying conn.
func (c *Conn) SetWriteDeadline(t time.Time) error { return c.raw.SetWriteDeadline(t) }
// nonceFor builds a 12-byte ChaCha20-Poly1305 nonce from a 64-bit counter:
// the high 4 bytes are zero, the low 8 are the big-endian counter.
func nonceFor(seq uint64) [12]byte {
var n [12]byte
binary.BigEndian.PutUint64(n[4:], seq)
return n
}
// aeadSeal/aeadOpen are used during the handshake to protect the
// responder identity payload. They use the X-Wing ciphertext as
// associated data so the binding cannot be transplanted onto another
// session.
func aeadSeal(key, nonceLabel, aad, plaintext []byte) ([]byte, error) {
aead, err := chacha20poly1305.New(key)
if err != nil {
return nil, err
}
nonce := handshakeNonce(nonceLabel)
return aead.Seal(nil, nonce[:], plaintext, aad), nil
}
func aeadOpen(key, nonceLabel, aad, ciphertext []byte) ([]byte, error) {
aead, err := chacha20poly1305.New(key)
if err != nil {
return nil, err
}
nonce := handshakeNonce(nonceLabel)
pt, err := aead.Open(nil, nonce[:], ciphertext, aad)
if err != nil {
return nil, errors.New("zwing: handshake AEAD open failed")
}
return pt, nil
}
// handshakeNonce derives a 12-byte fixed nonce from a label. The key used
// with this nonce is unique to a single handshake (HKDF over the X-Wing
// shared secret with a session-specific label), so reusing the nonce is
// safe.
func handshakeNonce(label []byte) [12]byte {
var n [12]byte
copy(n[:], label)
return n
}
+23
View File
@@ -0,0 +1,23 @@
// Copyright (C) 2020-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package zwing
import "time"
// DefaultPort is the canonical Z-Wing TCP port.
const DefaultPort = 9999
// Config configures a Z-Wing dialer or listener.
type Config struct {
// LocalIdentity is this endpoint's long-term identity. Required.
LocalIdentity *Identity
// ExpectedRemote pins the peer's expected public identity. If set, the
// handshake fails with ErrIdentityMismatch when the remote presents a
// different identity. Leave nil for "any peer with a valid identity".
ExpectedRemote *IdentityPublic
// HandshakeTimeout bounds the handshake itself. Zero means no timeout.
HandshakeTimeout time.Duration
}
+61
View File
@@ -0,0 +1,61 @@
// Copyright (C) 2020-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package zwing
import (
"context"
"net"
"time"
)
// Dial connects to a Z-Wing listener at addr and runs the handshake.
// The returned net.Conn is the post-handshake encrypted channel.
func Dial(ctx context.Context, addr string, cfg *Config) (net.Conn, error) {
if cfg == nil || cfg.LocalIdentity == nil {
return nil, ErrConfigMissingID
}
d := net.Dialer{}
raw, err := d.DialContext(ctx, "tcp", addr)
if err != nil {
return nil, err
}
return clientHandshake(raw, cfg)
}
// Client wraps an existing net.Conn (e.g. a TLS conn, a Unix socket, a
// pipe) and runs the Z-Wing handshake on top as the initiator.
func Client(raw net.Conn, cfg *Config) (net.Conn, error) {
return clientHandshake(raw, cfg)
}
// Server wraps an existing net.Conn and runs the handshake as the responder.
func Server(raw net.Conn, cfg *Config) (net.Conn, error) {
return serverHandshake(raw, cfg)
}
func clientHandshake(raw net.Conn, cfg *Config) (net.Conn, error) {
if cfg.HandshakeTimeout > 0 {
_ = raw.SetDeadline(time.Now().Add(cfg.HandshakeTimeout))
defer raw.SetDeadline(time.Time{})
}
res, err := runInitiator(raw, cfg)
if err != nil {
raw.Close()
return nil, err
}
return newConn(raw, res, true)
}
func serverHandshake(raw net.Conn, cfg *Config) (net.Conn, error) {
if cfg.HandshakeTimeout > 0 {
_ = raw.SetDeadline(time.Now().Add(cfg.HandshakeTimeout))
defer raw.SetDeadline(time.Time{})
}
res, err := runResponder(raw, cfg)
if err != nil {
raw.Close()
return nil, err
}
return newConn(raw, res, false)
}
+30
View File
@@ -0,0 +1,30 @@
// Copyright (C) 2020-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
// Package zwing implements Z-Wing, the canonical Lux post-quantum secure
// channel. It provides an authenticated, forward-secret, hybrid PQ tunnel
// that exposes a net.Conn so application protocols (e.g. luxfi/api/zap)
// run unchanged on top.
//
// Layering:
//
// Application: ZAP RPC (luxfi/api/zap) <- unchanged
// Channel: Z-Wing (this package)
// KEM: X-Wing (X25519 + ML-KEM-768, IETF draft-connolly-cfrg-xwing-kem)
// Identity: Ed25519 + ML-DSA-65 hybrid signatures
// AEAD: ChaCha20-Poly1305, sequence-numbered nonces
// Routing: Plain TCP (this version) or RNS (follow-up package)
//
// Z-Wing is one construction. There are no alternates, no build tags, no
// cipher negotiation. The point of standardising is that two endpoints
// either speak Z-Wing v1 or they don't talk.
//
// Public API:
//
// id, _ := zwing.GenerateIdentity()
// ln, _ := zwing.Listen(":9999", &zwing.Config{LocalIdentity: id})
// conn, _ := zwing.Dial(ctx, "host:9999", &zwing.Config{LocalIdentity: clientID})
//
// `conn` and the result of `ln.Accept()` both implement net.Conn and can be
// passed to zap.NewConn, http.Serve, or anything else that wants a stream.
package zwing
+19
View File
@@ -0,0 +1,19 @@
// Copyright (C) 2020-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package zwing
import "errors"
var (
ErrHandshakeFailed = errors.New("zwing: handshake failed")
ErrIdentityMismatch = errors.New("zwing: remote identity does not match expected")
ErrSignatureInvalid = errors.New("zwing: signature verification failed")
ErrMessageTooLarge = errors.New("zwing: message exceeds maximum size")
ErrShortRead = errors.New("zwing: short read")
ErrInvalidWireFormat = errors.New("zwing: invalid wire format")
ErrChannelClosed = errors.New("zwing: channel closed")
ErrSequenceExhausted = errors.New("zwing: AEAD sequence number exhausted")
ErrConfigMissingID = errors.New("zwing: config missing LocalIdentity")
ErrCiphertextCorrupted = errors.New("zwing: AEAD authentication failed")
)
+15
View File
@@ -0,0 +1,15 @@
module github.com/luxfi/zwing
go 1.26.2
require (
github.com/luxfi/api v1.0.9
github.com/luxfi/crypto v1.18.3
golang.org/x/crypto v0.50.0
)
require (
github.com/cloudflare/circl v1.6.3 // indirect
github.com/luxfi/accel v1.0.7 // indirect
golang.org/x/sys v0.43.0 // indirect
)
+12
View File
@@ -0,0 +1,12 @@
github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8=
github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
github.com/luxfi/accel v1.0.7 h1:ksHieAp50umwqxqgyHk9WiOmXM54kia3IEDb5H7FsM8=
github.com/luxfi/accel v1.0.7/go.mod h1:iZD3oxffiMEIT/KvzD8bgwC/cBn4AYlMW3QJpbRa4RE=
github.com/luxfi/api v1.0.9 h1:JuuKdJ6EaYCJSaZLB0xUWU470kbCnEklWcSOGIfl4Aw=
github.com/luxfi/api v1.0.9/go.mod h1:5OFWvZF+PbyuvLCDyKKpXCA/xp+LxJ32yOhNEKwQFN0=
github.com/luxfi/crypto v1.18.3 h1:f6mBUHNjZl9/9nXPSqjq+oJttrPl8slvcZ49Vi7jKw4=
github.com/luxfi/crypto v1.18.3/go.mod h1:qBtCJDT6p6aNa2hMtPxYwC688Bnsb3Amso8q09GsMls=
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
+366
View File
@@ -0,0 +1,366 @@
// Copyright (C) 2020-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package zwing
import (
"crypto/sha256"
"encoding/binary"
"io"
"golang.org/x/crypto/hkdf"
)
// Handshake is the Z-Wing 1-RTT mutual-auth handshake.
//
// Initiator -> Responder : HandshakeInit
// initiator IdentityPublic || ML-DSA+Ed25519 sig over (label || init pub)
//
// Responder -> Initiator : HandshakeResponse
// XWing ciphertext || encrypted{ responder IdentityPublic || sig }
//
// After both messages, both sides hold the same X-Wing shared secret
// derived against initiator's static X-Wing key. Forward secrecy is
// provided by the X25519 ephemeral inside X-Wing and by zeroing all
// intermediate key material once channel keys are derived.
//
// Identity-binding: the responder signs a transcript hash that covers
// the initiator's identity material AND the X-Wing ciphertext, so the
// signature can't be replayed onto another session.
const (
hsLabelInit = "lux.zwing.v1/handshake-init"
hsLabelResponse = "lux.zwing.v1/handshake-response"
channelKeyLabelI2R = "lux.zwing.v1/i2r"
channelKeyLabelR2I = "lux.zwing.v1/r2i"
chacha20KeySize = 32
)
// HandshakeInit is the wire form of the initiator's first message.
type HandshakeInit struct {
IdentityPub []byte // serialised IdentityPublic
Signature []byte // identity signature over label || IdentityPub
}
// HandshakeResponse is the wire form of the responder's reply. The
// identity payload is encrypted under a key derived from the X-Wing
// shared secret so the responder identity is not revealed to passive
// observers.
type HandshakeResponse struct {
XWingCiphertext []byte // X-Wing ct (1088+32 bytes)
EncryptedID []byte // AEAD-sealed responder identity + signature
}
// MarshalBinary encodes a HandshakeInit as
//
// [2 bytes BE: idLen][id bytes][2 bytes BE: sigLen][sig bytes]
func (h *HandshakeInit) MarshalBinary() []byte {
out := make([]byte, 0, 4+len(h.IdentityPub)+len(h.Signature))
out = appendU16(out, len(h.IdentityPub))
out = append(out, h.IdentityPub...)
out = appendU16(out, len(h.Signature))
out = append(out, h.Signature...)
return out
}
func parseHandshakeInit(data []byte) (*HandshakeInit, error) {
r := newSliceReader(data)
idLen, err := r.readU16()
if err != nil {
return nil, err
}
id, err := r.readN(int(idLen))
if err != nil {
return nil, err
}
sigLen, err := r.readU16()
if err != nil {
return nil, err
}
sig, err := r.readN(int(sigLen))
if err != nil {
return nil, err
}
if !r.empty() {
return nil, ErrInvalidWireFormat
}
return &HandshakeInit{IdentityPub: id, Signature: sig}, nil
}
// MarshalBinary encodes a HandshakeResponse as
//
// [XWingCiphertextSize bytes][2 bytes BE: encLen][enc bytes]
func (h *HandshakeResponse) MarshalBinary() []byte {
out := make([]byte, 0, XWingCiphertextSize+2+len(h.EncryptedID))
out = append(out, h.XWingCiphertext...)
out = appendU16(out, len(h.EncryptedID))
out = append(out, h.EncryptedID...)
return out
}
func parseHandshakeResponse(data []byte) (*HandshakeResponse, error) {
if len(data) < XWingCiphertextSize+2 {
return nil, ErrInvalidWireFormat
}
r := newSliceReader(data)
ct, err := r.readN(XWingCiphertextSize)
if err != nil {
return nil, err
}
encLen, err := r.readU16()
if err != nil {
return nil, err
}
enc, err := r.readN(int(encLen))
if err != nil {
return nil, err
}
if !r.empty() {
return nil, ErrInvalidWireFormat
}
return &HandshakeResponse{XWingCiphertext: ct, EncryptedID: enc}, nil
}
// handshakeResult holds the data both sides agree on after a successful run.
type handshakeResult struct {
shared [XWingSharedSize]byte
remote *IdentityPublic
keyI2R []byte // 32 bytes, initiator->responder AEAD key
keyR2I []byte // 32 bytes, responder->initiator AEAD key
}
// runInitiator drives the initiator side of the handshake on conn.
func runInitiator(conn io.ReadWriter, cfg *Config) (*handshakeResult, error) {
if cfg == nil || cfg.LocalIdentity == nil {
return nil, ErrConfigMissingID
}
local := cfg.LocalIdentity
// 1. Send HandshakeInit.
idPub := local.Public().MarshalBinary()
sig, err := local.Sign([]byte(hsLabelInit), idPub)
if err != nil {
return nil, err
}
init := &HandshakeInit{IdentityPub: idPub, Signature: sig}
if err := writeFrame(conn, init.MarshalBinary()); err != nil {
return nil, err
}
// 2. Receive HandshakeResponse.
respFrame, err := readFrame(conn)
if err != nil {
return nil, err
}
resp, err := parseHandshakeResponse(respFrame)
if err != nil {
return nil, err
}
// 3. Decapsulate X-Wing shared secret.
shared, err := XWingDecapsulate(local.XWing, resp.XWingCiphertext)
if err != nil {
return nil, err
}
// 4. Derive a temporary AEAD key for the responder-identity payload
// and decrypt it. Then derive the channel keys from the same secret
// via separate labels.
idKey := derive(shared[:], []byte("lux.zwing.v1/resp-id"), chacha20KeySize)
defer zero(idKey)
plaintext, err := aeadOpen(idKey, []byte("zwing-resp-id"), resp.XWingCiphertext, resp.EncryptedID)
if err != nil {
return nil, ErrCiphertextCorrupted
}
remoteID, remoteSig, err := splitRespIDPayload(plaintext)
if err != nil {
return nil, err
}
remote, err := ParseIdentityPublic(remoteID)
if err != nil {
return nil, err
}
// 5. Verify responder signature over transcript = init || ct.
transcript := transcriptHash(idPub, resp.XWingCiphertext)
if err := remote.Verify([]byte(hsLabelResponse), transcript, remoteSig); err != nil {
return nil, err
}
// 6. Optional pinning.
if cfg.ExpectedRemote != nil && !remote.Equal(cfg.ExpectedRemote) {
return nil, ErrIdentityMismatch
}
res := &handshakeResult{
shared: shared,
remote: remote,
keyI2R: derive(shared[:], []byte(channelKeyLabelI2R), chacha20KeySize),
keyR2I: derive(shared[:], []byte(channelKeyLabelR2I), chacha20KeySize),
}
return res, nil
}
// runResponder drives the responder side on conn.
func runResponder(conn io.ReadWriter, cfg *Config) (*handshakeResult, error) {
if cfg == nil || cfg.LocalIdentity == nil {
return nil, ErrConfigMissingID
}
local := cfg.LocalIdentity
// 1. Read HandshakeInit.
initFrame, err := readFrame(conn)
if err != nil {
return nil, err
}
init, err := parseHandshakeInit(initFrame)
if err != nil {
return nil, err
}
remote, err := ParseIdentityPublic(init.IdentityPub)
if err != nil {
return nil, err
}
if err := remote.Verify([]byte(hsLabelInit), init.IdentityPub, init.Signature); err != nil {
return nil, err
}
if cfg.ExpectedRemote != nil && !remote.Equal(cfg.ExpectedRemote) {
return nil, ErrIdentityMismatch
}
// 2. Encapsulate X-Wing to initiator's static key.
ct, shared, err := XWingEncapsulate(nil, remote.XWingPub)
if err != nil {
return nil, err
}
// 3. Sign transcript and seal local identity.
idPub := local.Public().MarshalBinary()
transcript := transcriptHash(init.IdentityPub, ct)
sig, err := local.Sign([]byte(hsLabelResponse), transcript)
if err != nil {
return nil, err
}
plaintext := buildRespIDPayload(idPub, sig)
idKey := derive(shared[:], []byte("lux.zwing.v1/resp-id"), chacha20KeySize)
defer zero(idKey)
ciphertext, err := aeadSeal(idKey, []byte("zwing-resp-id"), ct, plaintext)
if err != nil {
return nil, err
}
resp := &HandshakeResponse{XWingCiphertext: ct, EncryptedID: ciphertext}
if err := writeFrame(conn, resp.MarshalBinary()); err != nil {
return nil, err
}
res := &handshakeResult{
shared: shared,
remote: remote,
keyI2R: derive(shared[:], []byte(channelKeyLabelI2R), chacha20KeySize),
keyR2I: derive(shared[:], []byte(channelKeyLabelR2I), chacha20KeySize),
}
return res, nil
}
// derive runs HKDF-SHA256 over the X-Wing shared secret to produce a key
// for the requested label. We use SHA-256 (not SHA3) here to avoid pulling
// a second hash construction into the critical path; the underlying secret
// is already SHA3-256-derived by the X-Wing combiner.
func derive(secret, label []byte, size int) []byte {
out := make([]byte, size)
r := hkdf.New(sha256.New, secret, nil, label)
if _, err := io.ReadFull(r, out); err != nil {
// HKDF over a fixed-length input never fails for reasonable sizes.
panic(err)
}
return out
}
// transcriptHash binds the initiator's identity payload and the X-Wing
// ciphertext into a single 32-byte hash that the responder signs.
func transcriptHash(initPub, ct []byte) []byte {
h := sha256.New()
h.Write([]byte("lux.zwing.v1/transcript"))
binary.Write(h, binary.BigEndian, uint32(len(initPub)))
h.Write(initPub)
binary.Write(h, binary.BigEndian, uint32(len(ct)))
h.Write(ct)
return h.Sum(nil)
}
// buildRespIDPayload encodes the responder identity + signature as
//
// [2 BE idLen][id][2 BE sigLen][sig]
func buildRespIDPayload(id, sig []byte) []byte {
out := make([]byte, 0, 4+len(id)+len(sig))
out = appendU16(out, len(id))
out = append(out, id...)
out = appendU16(out, len(sig))
out = append(out, sig...)
return out
}
func splitRespIDPayload(data []byte) (id, sig []byte, err error) {
r := newSliceReader(data)
idLen, err := r.readU16()
if err != nil {
return nil, nil, err
}
id, err = r.readN(int(idLen))
if err != nil {
return nil, nil, err
}
sigLen, err := r.readU16()
if err != nil {
return nil, nil, err
}
sig, err = r.readN(int(sigLen))
if err != nil {
return nil, nil, err
}
if !r.empty() {
return nil, nil, ErrInvalidWireFormat
}
return id, sig, nil
}
// ── small helpers ────────────────────────────────────────────────────
func appendU16(b []byte, n int) []byte {
if n < 0 || n > 0xFFFF {
panic("zwing: u16 out of range")
}
return append(b, byte(n>>8), byte(n))
}
type sliceReader struct {
buf []byte
}
func newSliceReader(b []byte) *sliceReader { return &sliceReader{buf: b} }
func (r *sliceReader) readU16() (uint16, error) {
if len(r.buf) < 2 {
return 0, ErrShortRead
}
v := binary.BigEndian.Uint16(r.buf[:2])
r.buf = r.buf[2:]
return v, nil
}
func (r *sliceReader) readN(n int) ([]byte, error) {
if n < 0 || len(r.buf) < n {
return nil, ErrShortRead
}
out := r.buf[:n]
r.buf = r.buf[n:]
return out, nil
}
func (r *sliceReader) empty() bool { return len(r.buf) == 0 }
+176
View File
@@ -0,0 +1,176 @@
// Copyright (C) 2020-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package zwing
import (
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"errors"
"io"
"github.com/luxfi/crypto/ed25519"
"github.com/luxfi/crypto/mldsa"
)
// Identity binds a peer to long-term Ed25519 + ML-DSA-65 keys plus an
// X-Wing static keypair used as the KEM recipient material.
type Identity struct {
EdPub ed25519.PublicKey
EdPriv ed25519.PrivateKey
DSAPriv *mldsa.PrivateKey
XWing *XWingPrivateKey
}
// IdentityPublic is the wire-serialisable, secrets-stripped half of an
// Identity. It is what peers exchange and verify against.
type IdentityPublic struct {
EdPub ed25519.PublicKey
DSAPub *mldsa.PublicKey
XWingPub *XWingPublicKey
}
// GenerateIdentity creates a fresh Z-Wing identity using crypto/rand.
func GenerateIdentity() (*Identity, error) {
return GenerateIdentityFrom(rand.Reader)
}
// GenerateIdentityFrom creates an identity from a custom entropy source.
// Useful for deterministic key generation in tests.
func GenerateIdentityFrom(r io.Reader) (*Identity, error) {
edPub, edPriv, err := ed25519.GenerateKey(r)
if err != nil {
return nil, err
}
dsaPriv, err := mldsa.GenerateKey(r, mldsa.MLDSA65)
if err != nil {
return nil, err
}
xwing, err := GenerateXWingKey(r)
if err != nil {
return nil, err
}
return &Identity{
EdPub: edPub,
EdPriv: edPriv,
DSAPriv: dsaPriv,
XWing: xwing,
}, nil
}
// Public returns the public half of the identity.
func (id *Identity) Public() *IdentityPublic {
return &IdentityPublic{
EdPub: id.EdPub,
DSAPub: id.DSAPriv.PublicKey,
XWingPub: id.XWing.Public(),
}
}
// Sign produces the Z-Wing identity signature: an Ed25519 signature over
// SHA-256(ctx || message) concatenated with an ML-DSA-65 signature over
// the same digest. ctx is a hard-coded protocol label so signatures are
// not portable across protocols.
func (id *Identity) Sign(ctx, message []byte) ([]byte, error) {
digest := identityDigest(ctx, message)
edSig := ed25519.Sign(id.EdPriv, digest)
dsaSig, err := id.DSAPriv.SignCtx(rand.Reader, digest, []byte(zwingDomain))
if err != nil {
return nil, err
}
out := make([]byte, 0, ed25519.SignatureSize+mldsa.MLDSA65SignatureSize)
out = append(out, edSig...)
out = append(out, dsaSig...)
return out, nil
}
// Verify checks an identity signature produced by Sign.
func (pub *IdentityPublic) Verify(ctx, message, signature []byte) error {
if len(signature) != ed25519.SignatureSize+mldsa.MLDSA65SignatureSize {
return ErrSignatureInvalid
}
digest := identityDigest(ctx, message)
edSig := signature[:ed25519.SignatureSize]
dsaSig := signature[ed25519.SignatureSize:]
if !ed25519.Verify(pub.EdPub, digest, edSig) {
return ErrSignatureInvalid
}
if !pub.DSAPub.VerifySignatureCtx(digest, dsaSig, []byte(zwingDomain)) {
return ErrSignatureInvalid
}
return nil
}
// MarshalBinary serialises the public identity:
//
// [Ed25519 pk: 32][ML-DSA-65 pk: 1952][X-Wing pk: 1216]
func (pub *IdentityPublic) MarshalBinary() []byte {
out := make([]byte, 0, IdentityPublicSize)
out = append(out, pub.EdPub...)
out = append(out, pub.DSAPub.Bytes()...)
out = append(out, pub.XWingPub.MarshalBinary()...)
return out
}
// ParseIdentityPublic decodes the wire format produced by MarshalBinary.
func ParseIdentityPublic(data []byte) (*IdentityPublic, error) {
if len(data) != IdentityPublicSize {
return nil, errors.New("zwing: invalid public identity size")
}
off := 0
edPub := make(ed25519.PublicKey, ed25519.PublicKeySize)
copy(edPub, data[off:off+ed25519.PublicKeySize])
off += ed25519.PublicKeySize
dsaPubBytes := data[off : off+mldsa.MLDSA65PublicKeySize]
off += mldsa.MLDSA65PublicKeySize
dsaPub, err := mldsa.PublicKeyFromBytes(dsaPubBytes, mldsa.MLDSA65)
if err != nil {
return nil, err
}
xwingPub, err := ParseXWingPublicKey(data[off : off+XWingPublicKeySize])
if err != nil {
return nil, err
}
return &IdentityPublic{EdPub: edPub, DSAPub: dsaPub, XWingPub: xwingPub}, nil
}
// Equal reports whether two public identities have byte-identical key material.
func (pub *IdentityPublic) Equal(other *IdentityPublic) bool {
if pub == nil || other == nil {
return false
}
if subtle.ConstantTimeCompare(pub.EdPub, other.EdPub) != 1 {
return false
}
if subtle.ConstantTimeCompare(pub.DSAPub.Bytes(), other.DSAPub.Bytes()) != 1 {
return false
}
if subtle.ConstantTimeCompare(pub.XWingPub.MarshalBinary(), other.XWingPub.MarshalBinary()) != 1 {
return false
}
return true
}
// IdentityPublicSize is the fixed-size wire encoding of an IdentityPublic.
const IdentityPublicSize = ed25519.PublicKeySize +
mldsa.MLDSA65PublicKeySize +
XWingPublicKeySize
// zwingDomain is the protocol domain separator. It is bound into every
// identity signature so Z-Wing signatures cannot be replayed onto another
// Lux protocol that signs with the same identity keys.
const zwingDomain = "lux.zwing.v1"
func identityDigest(ctx, message []byte) []byte {
h := sha256.New()
h.Write([]byte(zwingDomain))
h.Write([]byte{byte(len(ctx))})
h.Write(ctx)
h.Write(message)
return h.Sum(nil)
}
+185
View File
@@ -0,0 +1,185 @@
// Copyright (C) 2020-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package zwing
import (
"crypto/rand"
"errors"
"io"
"github.com/luxfi/crypto/mlkem"
"golang.org/x/crypto/curve25519"
"golang.org/x/crypto/sha3"
)
// X-Wing combiner constants per draft-connolly-cfrg-xwing-kem.
//
// shared_secret = SHA3-256( "\./" || "X-Wing" || ss_M || ss_X || ct_X || pk_X )
//
// ss_M ML-KEM-768 shared secret (32 bytes)
// ss_X X25519 shared secret (32 bytes)
// ct_X X25519 ephemeral public key sent by encapsulator (32 bytes)
// pk_X X25519 static public key of recipient (32 bytes)
var (
xwingLabelPrefix = []byte{'\\', '.', '/'} // 3 bytes: backslash, dot, slash
xwingLabelName = []byte("X-Wing") // 6 bytes ASCII
)
// X-Wing wire sizes.
const (
XWingPublicKeySize = mlkem.MLKEM768PublicKeySize + curve25519.PointSize // 1184 + 32
XWingCiphertextSize = mlkem.MLKEM768CiphertextSize + curve25519.PointSize // 1088 + 32
XWingSharedSize = 32
)
// XWingPublicKey is the recipient's X-Wing static public key.
type XWingPublicKey struct {
MLKEM *mlkem.PublicKey
X25519 [32]byte
}
// XWingPrivateKey is the recipient's X-Wing static secret key plus the
// matching public material needed for the combiner.
type XWingPrivateKey struct {
MLKEMPriv *mlkem.PrivateKey
X25519Priv [32]byte
X25519Pub [32]byte
}
// Public returns the X-Wing public key.
func (sk *XWingPrivateKey) Public() *XWingPublicKey {
return &XWingPublicKey{
MLKEM: sk.MLKEMPriv.PublicKey(),
X25519: sk.X25519Pub,
}
}
// MarshalBinary serialises the X-Wing public key as ML-KEM-768 || X25519.
func (pk *XWingPublicKey) MarshalBinary() []byte {
out := make([]byte, 0, XWingPublicKeySize)
out = append(out, pk.MLKEM.Bytes()...)
out = append(out, pk.X25519[:]...)
return out
}
// ParseXWingPublicKey decodes the wire format produced by MarshalBinary.
func ParseXWingPublicKey(data []byte) (*XWingPublicKey, error) {
if len(data) != XWingPublicKeySize {
return nil, errors.New("zwing: invalid X-Wing public key size")
}
mlkemPK, err := mlkem.PublicKeyFromBytes(data[:mlkem.MLKEM768PublicKeySize], mlkem.MLKEM768)
if err != nil {
return nil, err
}
pk := &XWingPublicKey{MLKEM: mlkemPK}
copy(pk.X25519[:], data[mlkem.MLKEM768PublicKeySize:])
return pk, nil
}
// GenerateXWingKey produces a fresh X-Wing static keypair.
func GenerateXWingKey(reader io.Reader) (*XWingPrivateKey, error) {
if reader == nil {
reader = rand.Reader
}
_, mlkemPriv, err := mlkem.GenerateKeyPair(reader, mlkem.MLKEM768)
if err != nil {
return nil, err
}
sk := &XWingPrivateKey{MLKEMPriv: mlkemPriv}
if _, err := io.ReadFull(reader, sk.X25519Priv[:]); err != nil {
return nil, err
}
pub, err := curve25519.X25519(sk.X25519Priv[:], curve25519.Basepoint)
if err != nil {
return nil, err
}
copy(sk.X25519Pub[:], pub)
return sk, nil
}
// XWingEncapsulate runs the encapsulator side. Output is (ciphertext, shared).
// The ciphertext is ML-KEM-768 ct || X25519 ephemeral pk.
func XWingEncapsulate(reader io.Reader, recipient *XWingPublicKey) (ciphertext []byte, shared [XWingSharedSize]byte, err error) {
if reader == nil {
reader = rand.Reader
}
mlkemCT, ssM, err := recipient.MLKEM.Encapsulate(reader)
if err != nil {
return nil, shared, err
}
var ephPriv [32]byte
if _, err := io.ReadFull(reader, ephPriv[:]); err != nil {
return nil, shared, err
}
ephPub, err := curve25519.X25519(ephPriv[:], curve25519.Basepoint)
if err != nil {
return nil, shared, err
}
ssX, err := curve25519.X25519(ephPriv[:], recipient.X25519[:])
if err != nil {
return nil, shared, err
}
shared = combineXWing(ssM, ssX, ephPub, recipient.X25519[:])
ciphertext = make([]byte, 0, XWingCiphertextSize)
ciphertext = append(ciphertext, mlkemCT...)
ciphertext = append(ciphertext, ephPub...)
zero(ephPriv[:])
zero(ssM)
zero(ssX)
return ciphertext, shared, nil
}
// XWingDecapsulate runs the recipient side.
func XWingDecapsulate(sk *XWingPrivateKey, ciphertext []byte) ([XWingSharedSize]byte, error) {
var shared [XWingSharedSize]byte
if len(ciphertext) != XWingCiphertextSize {
return shared, errors.New("zwing: invalid X-Wing ciphertext size")
}
mlkemCT := ciphertext[:mlkem.MLKEM768CiphertextSize]
ephPub := ciphertext[mlkem.MLKEM768CiphertextSize:]
ssM, err := sk.MLKEMPriv.Decapsulate(mlkemCT)
if err != nil {
return shared, err
}
ssX, err := curve25519.X25519(sk.X25519Priv[:], ephPub)
if err != nil {
return shared, err
}
shared = combineXWing(ssM, ssX, ephPub, sk.X25519Pub[:])
zero(ssM)
zero(ssX)
return shared, nil
}
// combineXWing implements the IETF X-Wing KDF combiner exactly:
//
// SHA3-256( "\./" || "X-Wing" || ss_M || ss_X || ct_X || pk_X )
//
// All inputs are fixed-length so no separators are needed: ss_M=32,
// ss_X=32, ct_X=32, pk_X=32.
func combineXWing(ssM, ssX, ctX, pkX []byte) [XWingSharedSize]byte {
h := sha3.New256()
h.Write(xwingLabelPrefix)
h.Write(xwingLabelName)
h.Write(ssM)
h.Write(ssX)
h.Write(ctX)
h.Write(pkX)
var out [XWingSharedSize]byte
copy(out[:], h.Sum(nil))
return out
}
func zero(b []byte) {
for i := range b {
b[i] = 0
}
}
+43
View File
@@ -0,0 +1,43 @@
// Copyright (C) 2020-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package zwing
import "net"
// Listener is a Z-Wing listener. Accepted connections are post-handshake
// net.Conns ready for application traffic.
type Listener struct {
raw net.Listener
cfg *Config
}
// Listen binds a TCP listener at addr that performs Z-Wing handshakes
// before yielding connections.
func Listen(addr string, cfg *Config) (*Listener, error) {
if cfg == nil || cfg.LocalIdentity == nil {
return nil, ErrConfigMissingID
}
raw, err := net.Listen("tcp", addr)
if err != nil {
return nil, err
}
return &Listener{raw: raw, cfg: cfg}, nil
}
// Accept returns the next handshaken connection. If a handshake fails
// the underlying conn is closed and Accept returns the error; callers
// that want to keep listening should call Accept again.
func (l *Listener) Accept() (net.Conn, error) {
raw, err := l.raw.Accept()
if err != nil {
return nil, err
}
return serverHandshake(raw, l.cfg)
}
// Close closes the underlying TCP listener.
func (l *Listener) Close() error { return l.raw.Close() }
// Addr returns the listener's TCP address.
func (l *Listener) Addr() net.Addr { return l.raw.Addr() }
+54
View File
@@ -0,0 +1,54 @@
// Copyright (C) 2020-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package zwing
import (
"encoding/binary"
"io"
)
// MaxFrameSize is the largest single transport frame Z-Wing will read or
// write. Application bytes larger than this are split by the channel.
const MaxFrameSize = 1 << 20 // 1 MiB
// frame layout:
//
// [4 bytes BE length][payload]
//
// length covers the payload only. payload is either a handshake message
// (raw bytes) or an AEAD-sealed transport record.
func writeFrame(w io.Writer, payload []byte) error {
if len(payload) > MaxFrameSize {
return ErrMessageTooLarge
}
var hdr [4]byte
binary.BigEndian.PutUint32(hdr[:], uint32(len(payload)))
if _, err := w.Write(hdr[:]); err != nil {
return err
}
if _, err := w.Write(payload); err != nil {
return err
}
return nil
}
func readFrame(r io.Reader) ([]byte, error) {
var hdr [4]byte
if _, err := io.ReadFull(r, hdr[:]); err != nil {
return nil, err
}
n := binary.BigEndian.Uint32(hdr[:])
if n > MaxFrameSize {
return nil, ErrMessageTooLarge
}
if n == 0 {
return nil, ErrInvalidWireFormat
}
buf := make([]byte, n)
if _, err := io.ReadFull(r, buf); err != nil {
return nil, err
}
return buf, nil
}
+116
View File
@@ -0,0 +1,116 @@
// Copyright (C) 2020-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package zwing_test
import (
"bytes"
"context"
"net"
"testing"
"time"
"github.com/luxfi/api/zap"
"github.com/luxfi/zwing"
)
// TestZAPOverZWing wires a luxfi/api/zap client through a Z-Wing channel,
// runs one request/response, and verifies the application bytes survive
// the encrypted transport unchanged.
func TestZAPOverZWing(t *testing.T) {
clientID, err := zwing.GenerateIdentity()
if err != nil {
t.Fatalf("client id: %v", err)
}
serverID, err := zwing.GenerateIdentity()
if err != nil {
t.Fatalf("server id: %v", err)
}
ln, err := zwing.Listen("127.0.0.1:0", &zwing.Config{LocalIdentity: serverID})
if err != nil {
t.Fatalf("listen: %v", err)
}
defer ln.Close()
srvDone := make(chan error, 1)
go func() {
srvDone <- runZAPEchoServer(ln)
}()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
cConn, err := zwing.Dial(ctx, ln.Addr().String(), &zwing.Config{LocalIdentity: clientID})
if err != nil {
t.Fatalf("dial: %v", err)
}
defer cConn.Close()
zapConn := zap.NewConn(cConn, nil)
defer zapConn.Close()
payload := []byte("zap-over-zwing")
respType, respPayload, err := zapConn.Call(ctx, zap.MsgBuildBlock, payload)
if err != nil {
t.Fatalf("zap call: %v", err)
}
if respType&zap.MsgResponseFlag == 0 {
t.Fatalf("response missing MsgResponseFlag")
}
if !bytes.Equal(respPayload, payload) {
t.Fatalf("payload mismatch: got %q want %q", respPayload, payload)
}
// Close the client side first so the server's blocking ReadMessage
// returns EOF promptly, then close the listener.
zapConn.Close()
cConn.Close()
ln.Close()
select {
case err := <-srvDone:
if err != nil && err != net.ErrClosed {
t.Logf("server exited: %v", err)
}
case <-time.After(2 * time.Second):
t.Fatal("server did not exit after closing client and listener")
}
}
// runZAPEchoServer accepts one Z-Wing conn and runs a tiny ZAP echo loop
// using the zap wire helpers directly so we don't need to construct a
// full zap.Server (which would need a Listener built from a net.Listener,
// not from zwing.Listener).
func runZAPEchoServer(ln *zwing.Listener) error {
conn, err := ln.Accept()
if err != nil {
return err
}
defer conn.Close()
// Read one request frame, echo it back with the response flag set.
for {
msgType, payload, err := zap.ReadMessage(readerOf(conn))
if err != nil {
return err
}
if len(payload) < 4 {
return nil
}
// payload is [reqID:uint32][body...]; echo body back with reqID.
body := payload[4:]
respPayload := make([]byte, 4+len(body))
copy(respPayload[:4], payload[:4])
copy(respPayload[4:], body)
if err := zap.WriteMessage(writerOf(conn), msgType|zap.MsgResponseFlag, respPayload); err != nil {
return err
}
}
}
// readerOf / writerOf wrap a net.Conn so the zap wire helpers see plain
// io.Reader / io.Writer (which they require).
type readerOnly struct{ net.Conn }
type writerOnly struct{ net.Conn }
func readerOf(c net.Conn) readerOnly { return readerOnly{c} }
func writerOf(c net.Conn) writerOnly { return writerOnly{c} }
+238
View File
@@ -0,0 +1,238 @@
// Copyright (C) 2020-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package zwing
import (
"bytes"
"context"
"errors"
"net"
"sync"
"testing"
"time"
)
func newPair(t *testing.T) (clientID, serverID *Identity) {
t.Helper()
c, err := GenerateIdentity()
if err != nil {
t.Fatalf("client identity: %v", err)
}
s, err := GenerateIdentity()
if err != nil {
t.Fatalf("server identity: %v", err)
}
return c, s
}
// pipeConn turns a net.Pipe end into a deadline-supporting conn. net.Pipe
// already gives us net.Conns; we just plumb both ends.
// runHandshakeOverPipe drives one initiator and one responder over a
// memory pipe with the given configs and returns both Conns or the first
// error.
func runHandshakeOverPipe(t *testing.T, clientCfg, serverCfg *Config) (cConn, sConn net.Conn, cErr, sErr error) {
t.Helper()
left, right := net.Pipe()
var wg sync.WaitGroup
wg.Add(2)
go func() {
defer wg.Done()
c, err := Client(left, clientCfg)
cConn = c
cErr = err
}()
go func() {
defer wg.Done()
s, err := Server(right, serverCfg)
sConn = s
sErr = err
}()
wg.Wait()
return cConn, sConn, cErr, sErr
}
func TestHandshakeRoundTrip(t *testing.T) {
clientID, serverID := newPair(t)
cConn, sConn, cErr, sErr := runHandshakeOverPipe(t,
&Config{LocalIdentity: clientID},
&Config{LocalIdentity: serverID},
)
if cErr != nil || sErr != nil {
t.Fatalf("handshake errors: client=%v server=%v", cErr, sErr)
}
defer cConn.Close()
defer sConn.Close()
// The remote identities should be the peers'.
cRemote := cConn.(*Conn).RemoteIdentity()
sRemote := sConn.(*Conn).RemoteIdentity()
if !cRemote.Equal(serverID.Public()) {
t.Fatal("client did not see server identity")
}
if !sRemote.Equal(clientID.Public()) {
t.Fatal("server did not see client identity")
}
// Round-trip a payload in both directions. net.Pipe is synchronous
// so writes must overlap with reads on the peer.
payload := []byte("z-wing flies")
wErr := make(chan error, 1)
go func() { _, e := cConn.Write(payload); wErr <- e }()
buf := make([]byte, len(payload))
if _, err := sConn.Read(buf); err != nil {
t.Fatalf("server read: %v", err)
}
if err := <-wErr; err != nil {
t.Fatalf("client write: %v", err)
}
if !bytes.Equal(buf, payload) {
t.Fatalf("c->s: got %q want %q", buf, payload)
}
go func() { _, _ = sConn.Write([]byte("ack")) }()
buf = make([]byte, 3)
if _, err := cConn.Read(buf); err != nil {
t.Fatalf("client read: %v", err)
}
if !bytes.Equal(buf, []byte("ack")) {
t.Fatalf("s->c: got %q want %q", buf, "ack")
}
}
func TestDialListen(t *testing.T) {
clientID, serverID := newPair(t)
ln, err := Listen("127.0.0.1:0", &Config{LocalIdentity: serverID})
if err != nil {
t.Fatalf("listen: %v", err)
}
defer ln.Close()
type accepted struct {
c net.Conn
err error
}
acceptCh := make(chan accepted, 1)
go func() {
c, err := ln.Accept()
acceptCh <- accepted{c: c, err: err}
}()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
cConn, err := Dial(ctx, ln.Addr().String(), &Config{LocalIdentity: clientID})
if err != nil {
t.Fatalf("dial: %v", err)
}
defer cConn.Close()
a := <-acceptCh
if a.err != nil {
t.Fatalf("accept: %v", a.err)
}
defer a.c.Close()
msg := []byte("hello over tcp")
if _, err := cConn.Write(msg); err != nil {
t.Fatalf("write: %v", err)
}
buf := make([]byte, len(msg))
if _, err := a.c.Read(buf); err != nil {
t.Fatalf("read: %v", err)
}
if !bytes.Equal(buf, msg) {
t.Fatalf("got %q want %q", buf, msg)
}
}
func TestIdentityMismatch(t *testing.T) {
clientID, serverID := newPair(t)
_, otherID := newPair(t)
_, _, cErr, sErr := runHandshakeOverPipe(t,
// Client expects a server identity that does not match.
&Config{LocalIdentity: clientID, ExpectedRemote: otherID.Public()},
&Config{LocalIdentity: serverID},
)
if !errors.Is(cErr, ErrIdentityMismatch) {
t.Fatalf("client: want ErrIdentityMismatch, got %v", cErr)
}
// Server side may either succeed in writing then see the conn close,
// or fail with a pipe error - we don't pin the exact behaviour.
_ = sErr
}
func TestForwardSecrecy(t *testing.T) {
clientID, serverID := newPair(t)
cConn, sConn, cErr, sErr := runHandshakeOverPipe(t,
&Config{LocalIdentity: clientID},
&Config{LocalIdentity: serverID},
)
if cErr != nil || sErr != nil {
t.Fatalf("handshake: %v / %v", cErr, sErr)
}
defer cConn.Close()
defer sConn.Close()
// The post-handshake Conn must not retain any handshake-scoped raw
// key material in exported fields. The handshakeResult.keyI2R / keyR2I
// slices are zeroed after AEADs are constructed (see newConn). We can
// verify by introspecting the result via a fresh handshake and
// inspecting keys-after-zero.
res := &handshakeResult{
shared: [XWingSharedSize]byte{},
keyI2R: derive([]byte{1, 2, 3}, []byte("k1"), 32),
keyR2I: derive([]byte{1, 2, 3}, []byte("k2"), 32),
}
left, right := net.Pipe()
defer left.Close()
defer right.Close()
c, err := newConn(left, res, true)
if err != nil {
t.Fatalf("newConn: %v", err)
}
_ = c
// After newConn the slices fed in should be zeroed.
for i, b := range res.keyI2R {
if b != 0 {
t.Fatalf("keyI2R[%d] not zeroed: 0x%x", i, b)
}
}
for i, b := range res.keyR2I {
if b != 0 {
t.Fatalf("keyR2I[%d] not zeroed: 0x%x", i, b)
}
}
}
// TestSequencedNonces makes sure two messages in a row use distinct
// nonces and decrypt independently.
func TestSequencedNonces(t *testing.T) {
clientID, serverID := newPair(t)
cConn, sConn, cErr, sErr := runHandshakeOverPipe(t,
&Config{LocalIdentity: clientID},
&Config{LocalIdentity: serverID},
)
if cErr != nil || sErr != nil {
t.Fatalf("handshake: %v / %v", cErr, sErr)
}
defer cConn.Close()
defer sConn.Close()
for i, msg := range [][]byte{[]byte("one"), []byte("two"), []byte("three")} {
wErr := make(chan error, 1)
go func() { _, e := cConn.Write(msg); wErr <- e }()
buf := make([]byte, len(msg))
if _, err := sConn.Read(buf); err != nil {
t.Fatalf("read %d: %v", i, err)
}
if err := <-wErr; err != nil {
t.Fatalf("write %d: %v", i, err)
}
if !bytes.Equal(buf, msg) {
t.Fatalf("msg %d: got %q want %q", i, buf, msg)
}
}
}