mirror of
https://github.com/luxfi/zap.git
synced 2026-10-30 15:04:16 +00:00
New package github.com/luxfi/zap/quic provides the canonical QUIC
transport for the ZAP messaging substrate.
Wire format is identical to the TCP transport (length-prefixed
Cap'n Proto frames), so every consumer's wire-level expectations
are preserved.
What QUIC adds beyond TCP+TLS:
- Multiplexed bidi/uni streams (one ZAP RPC per QUIC stream,
no head-of-line blocking between RPCs)
- Connection migration on local-IP changes (Wi-Fi to LTE,
NAT rebind, validator re-homing)
- 0-RTT resumption via TLS 1.3 session tickets
- TLS 1.3 hybrid post-quantum key exchange X25519MLKEM768
(IANA NamedGroup 0x11ec) as the default
- ALPN allowlist 'zap/1'
Cryptography:
- We use the IANA-registered hybrid X25519MLKEM768 verbatim;
no custom combiner, no z-wing fork. Go stdlib's crypto/tls
performs HKDF-Extract(X25519_ss || MLKEM_ss) internally.
- Server cert sig stays classical (ECDSA / RSA) — Go stdlib
TLS does not yet ship ML-DSA. The hybrid KEM defends against
harvest-now/decrypt-later; the cert is the orthogonal
auth-time concern, gated by today's trusted CA.
- 0-RTT replay is documented; non-idempotent handlers must
set RejectEarlyData=true.
Back-compat is sacred: TransportTCP is the default for
NodeConfig.Transport, so every existing consumer keeps working
untouched. TransportQUIC is opt-in and requires:
import _ "github.com/luxfi/zap/quic"
so the QUIC TransportFactory registers itself at init.
Tests prove X25519MLKEM768 actually negotiates end-to-end:
$ go test ./quic/... -run TestHandshake -v
TestHandshake_NegotiatesX25519MLKEM768
client negotiated CurveID = 0x11ec, ALPN = "zap/1"
--- PASS
Also covered: classical-fallback negotiation when server excludes
the hybrid, ALPN allowlist rejection, multiplex of concurrent
streams, 0-RTT round-trip, unidirectional broadcast streams,
frame-size enforcement, QUICConfig propagation through Listen.
Benchmarks on Apple M1 Max (1-RPC ping-pong, single connection):
RTT_QUIC/256B 0.40 MB/s 634 us/op
RTT_QUIC/1MiB 44.94 MB/s 23 ms/op
RTT_TCP_Baseline/256B 3.16 MB/s 81 us/op (no TLS, no PQ)
RTT_TCP_Baseline/1MiB 838.88 MB/s 1.2 ms/op
Multistream concurrent (16 parallel): 5.02 MB/s @ 50us/op
QUIC pays for TLS 1.3 + PQ KEM relative to the plaintext-TCP
baseline; small-message latency is 8x, large-message throughput
is 18x slower. The multistream concurrency case is where QUIC
wins — independent RPCs do not block each other.
Files:
quic/conn.go transport-level Conn (Send/Recv/streams)
quic/server.go Listener wrapping quic-go Listener/EarlyListener
quic/client.go Dialer with 0-RTT-attempting DialEarly
quic/tls.go TLS defaults + self-signed test cert helper
quic/factory.go registers QUIC TransportFactory with parent zap pkg
quic/config.go Config wrapper exposed via NodeConfig.QUICConfig
quic/doc.go package documentation
quic/README.md deployment guide + threat model
quic/handshake_test.go X25519MLKEM768 negotiation + multiplex + 0-RTT
quic/benchmark_test.go QUIC vs TCP baseline RTT + throughput
transport.go new Transport enum + TransportFactory hook
node_quic.go QUIC-path Send/Call/ConnectDirect dispatch
node.go additive: Transport field on NodeConfig, branch
in Start/Send/Call/ConnectDirect
LLM.md QUIC transport section under PQ-TLS Support
go.mod / go.sum add github.com/quic-go/quic-go v0.59.1
231 lines
6.4 KiB
Go
231 lines
6.4 KiB
Go
// Copyright (C) 2026, Lux Industries Inc. All rights reserved.
|
|
// See the file LICENSE for licensing terms.
|
|
|
|
package quic
|
|
|
|
import (
|
|
"context"
|
|
"crypto/tls"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"net"
|
|
"sync"
|
|
"time"
|
|
|
|
quicgo "github.com/quic-go/quic-go"
|
|
)
|
|
|
|
// ClientConfig configures a QUIC dialer.
|
|
type ClientConfig struct {
|
|
// NodeID is this node's ZAP identity. Sent on the control stream
|
|
// during the post-handshake identity exchange. Required.
|
|
NodeID string
|
|
|
|
// TLS is the TLS 1.3 client config. Required.
|
|
//
|
|
// If MinVersion / CurvePreferences / NextProtos are not set, they
|
|
// are defaulted to:
|
|
//
|
|
// MinVersion = TLS 1.3
|
|
// CurvePreferences = [X25519MLKEM768, X25519]
|
|
// NextProtos = ["zap/1"]
|
|
//
|
|
// The caller is responsible for providing RootCAs or
|
|
// VerifyPeerCertificate. Test code may set InsecureSkipVerify; do
|
|
// not do this in production.
|
|
TLS *tls.Config
|
|
|
|
// QUIC, if non-nil, is the quic-go Config used for dialing.
|
|
QUIC *quicgo.Config
|
|
|
|
// SessionCache is the client-side TLS 1.3 session ticket cache.
|
|
// When non-nil, 0-RTT resumption is enabled for second-and-later
|
|
// dials to the same peer.
|
|
//
|
|
// If nil, the TLS config's own ClientSessionCache (defaulted to
|
|
// an in-memory LRU of 128 entries by applyZAPDefaults) is used.
|
|
SessionCache tls.ClientSessionCache
|
|
|
|
// Logger for transport events. Defaults to slog.Default().
|
|
Logger *slog.Logger
|
|
}
|
|
|
|
// Client is a QUIC dialer with optional connection migration and
|
|
// 0-RTT support.
|
|
//
|
|
// One Client may be shared across many goroutines and used to Dial
|
|
// multiple peers concurrently. The Client owns one *quic.Transport
|
|
// which is bound to a single UDP socket — every Dial uses the same
|
|
// local socket, so a connection migration of the local side will
|
|
// affect all connections owned by this Client (this matches QUIC
|
|
// semantics).
|
|
type Client struct {
|
|
cfg ClientConfig
|
|
tr *quicgo.Transport
|
|
log *slog.Logger
|
|
mu sync.Mutex // guards tr lifecycle
|
|
}
|
|
|
|
// NewClient returns a Client that listens on a random local UDP port.
|
|
//
|
|
// NewClient does NOT establish any QUIC connections; use Dial to
|
|
// connect to a specific peer.
|
|
func NewClient(cfg ClientConfig) (*Client, error) {
|
|
if cfg.NodeID == "" {
|
|
return nil, errors.New("zap/quic: ClientConfig.NodeID required")
|
|
}
|
|
if cfg.TLS == nil {
|
|
return nil, errors.New("zap/quic: ClientConfig.TLS required")
|
|
}
|
|
if cfg.Logger == nil {
|
|
cfg.Logger = slog.Default()
|
|
}
|
|
|
|
udpAddr, err := net.ResolveUDPAddr("udp", ":0")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("zap/quic: resolve UDP: %w", err)
|
|
}
|
|
pktConn, err := net.ListenUDP("udp", udpAddr)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("zap/quic: listen UDP: %w", err)
|
|
}
|
|
tr := &quicgo.Transport{Conn: pktConn}
|
|
|
|
return &Client{
|
|
cfg: cfg,
|
|
tr: tr,
|
|
log: cfg.Logger,
|
|
}, nil
|
|
}
|
|
|
|
// LocalAddr returns the local UDP address bound by NewClient.
|
|
func (c *Client) LocalAddr() net.Addr {
|
|
return c.tr.Conn.LocalAddr()
|
|
}
|
|
|
|
// Transport returns the underlying *quic.Transport. Exposed for tests
|
|
// that need to trigger a deliberate path migration.
|
|
func (c *Client) Transport() *quicgo.Transport {
|
|
return c.tr
|
|
}
|
|
|
|
// Dial establishes a QUIC connection to addr.
|
|
//
|
|
// addr is an "ip:port" string resolvable as a UDP address.
|
|
//
|
|
// On a second-and-later dial to the same peer, if a valid TLS 1.3
|
|
// session ticket is cached, 0-RTT resumption is attempted (the
|
|
// returned Conn's IsZeroRTT() reports whether the server accepted it).
|
|
func (c *Client) Dial(ctx context.Context, addr string) (*Conn, error) {
|
|
return c.dial(ctx, addr, false)
|
|
}
|
|
|
|
// DialEarly is identical to Dial but explicitly attempts a 0-RTT
|
|
// handshake. If no session ticket is cached for the peer, this
|
|
// behaves identically to Dial (a full 1-RTT handshake is performed).
|
|
func (c *Client) DialEarly(ctx context.Context, addr string) (*Conn, error) {
|
|
return c.dial(ctx, addr, true)
|
|
}
|
|
|
|
func (c *Client) dial(ctx context.Context, addr string, earlyData bool) (*Conn, error) {
|
|
udpAddr, err := net.ResolveUDPAddr("udp", addr)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("zap/quic: resolve %q: %w", addr, err)
|
|
}
|
|
|
|
tlsCfg := c.cfg.TLS.Clone()
|
|
if c.cfg.SessionCache != nil {
|
|
tlsCfg.ClientSessionCache = c.cfg.SessionCache
|
|
}
|
|
applyZAPDefaults(tlsCfg)
|
|
|
|
quicCfg := clientQUICConfig(c.cfg)
|
|
|
|
var qc *quicgo.Conn
|
|
if earlyData {
|
|
qc, err = c.tr.DialEarly(ctx, udpAddr, tlsCfg, quicCfg)
|
|
} else {
|
|
qc, err = c.tr.Dial(ctx, udpAddr, tlsCfg, quicCfg)
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("zap/quic: dial %s: %w", addr, err)
|
|
}
|
|
|
|
// DialEarly returns before the TLS handshake fully completes;
|
|
// we must wait for handshake to learn the negotiated ALPN.
|
|
if earlyData {
|
|
select {
|
|
case <-qc.HandshakeComplete():
|
|
case <-ctx.Done():
|
|
_ = qc.CloseWithError(0x10, "handshake timeout")
|
|
return nil, ctx.Err()
|
|
}
|
|
}
|
|
|
|
if err := validateNegotiatedALPN(qc.ConnectionState().TLS); err != nil {
|
|
_ = qc.CloseWithError(0x10, err.Error())
|
|
return nil, err
|
|
}
|
|
|
|
// Open the control stream — server expects us to open it first.
|
|
ctrlCtx, cancel := context.WithTimeout(ctx, handshakeTimeout)
|
|
defer cancel()
|
|
ctrl, err := qc.OpenStreamSync(ctrlCtx)
|
|
if err != nil {
|
|
_ = qc.CloseWithError(0x11, "open control stream failed")
|
|
return nil, fmt.Errorf("zap/quic: open control stream: %w", err)
|
|
}
|
|
|
|
peerID, err := performClientHandshake(ctx, ctrl, c.cfg.NodeID)
|
|
if err != nil {
|
|
_ = qc.CloseWithError(0x12, "handshake failed")
|
|
return nil, fmt.Errorf("zap/quic: handshake: %w", err)
|
|
}
|
|
|
|
c.log.Debug("zap/quic: dialed connection",
|
|
"peerID", peerID,
|
|
"remote", qc.RemoteAddr().String(),
|
|
"curveID", fmt.Sprintf("0x%x", uint16(qc.ConnectionState().TLS.CurveID)),
|
|
"0rtt", qc.ConnectionState().Used0RTT,
|
|
)
|
|
|
|
return newConnFromQUIC(qc, ctrl, peerID), nil
|
|
}
|
|
|
|
// Close releases the underlying UDP socket. In-flight Dial calls
|
|
// return errors; existing *Conn objects remain valid until their
|
|
// own Close.
|
|
func (c *Client) Close() error {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
if c.tr == nil {
|
|
return nil
|
|
}
|
|
err := c.tr.Close()
|
|
c.tr = nil
|
|
return err
|
|
}
|
|
|
|
func clientQUICConfig(cfg ClientConfig) *quicgo.Config {
|
|
q := &quicgo.Config{}
|
|
if cfg.QUIC != nil {
|
|
*q = *cfg.QUIC
|
|
}
|
|
if q.MaxIdleTimeout == 0 {
|
|
q.MaxIdleTimeout = 30 * time.Second
|
|
}
|
|
if q.KeepAlivePeriod == 0 {
|
|
q.KeepAlivePeriod = 15 * time.Second
|
|
}
|
|
if q.MaxIncomingStreams == 0 {
|
|
q.MaxIncomingStreams = 1024
|
|
}
|
|
if q.MaxIncomingUniStreams == 0 {
|
|
q.MaxIncomingUniStreams = 1024
|
|
}
|
|
// Connection migration is automatic in quic-go as long as the
|
|
// local socket can receive packets from new paths.
|
|
return q
|
|
}
|