quic

package
v0.8.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jun 6, 2026 License: BSD-3-Clause Imports: 20 Imported by: 0

README

zap/quic — QUIC transport for ZAP

github.com/luxfi/zap/quic is the canonical QUIC transport for the ZAP messaging substrate (KMS, MPC, IAM, arcd, every Hanzo / Lux service that embeds ZAP).

It wraps github.com/quic-go/quic-go and exposes:

  • TLS 1.3 with the IANA-registered hybrid post-quantum key exchange X25519MLKEM768 (NamedGroup 0x11ec) as the default.
  • Multiplexed bidirectional + unidirectional streams over one connection (one ZAP message exchange per stream).
  • Connection migration on local-IP changes (Wi-Fi to LTE, NAT rebind).
  • 0-RTT resumption via TLS 1.3 session tickets.
  • ALPN allowlist: zap/1 only — other ALPN values are rejected at the TLS layer and again post-handshake as belt-and-suspenders.

When to pick QUIC over TCP

QUIC is the better choice when any one of these applies:

  • The connection serves many concurrent independent RPCs (KMS, MPC, threshold orchestrators). QUIC eliminates head-of-line blocking between streams.
  • The client moves networks (laptop, validator that re-homes between ASNs, K8s pod migrated between nodes). The QUIC connection ID survives the path change; TCP would tear down.
  • The latency budget cares about 0-RTT resumption. Repeat connections to a cached peer skip a round trip.

TCP is the better choice when middleboxes / firewalls block UDP, or when the peer link is a kernel TCP pipe and the binary cannot afford the UDP per-packet syscall overhead.

NodeConfig.Transport selects between them. Default is TransportTCP for back-compat — every existing consumer keeps working untouched.

Quick start

As a standalone QUIC server / client
import (
    "context"
    "crypto/tls"

    zapquic "github.com/luxfi/zap/quic"
)

cert, _ := zapquic.GenerateSelfSignedCert("example.com")
srv, _ := zapquic.Listen(zapquic.ServerConfig{
    NodeID: "node-a",
    Addr:   ":9999",
    TLS:    &tls.Config{Certificates: []tls.Certificate{cert}},
})
defer srv.Close()

go func() {
    for {
        c, err := srv.Accept(context.Background())
        if err != nil { return }
        go handle(c)
    }
}()

cli, _ := zapquic.NewClient(zapquic.ClientConfig{
    NodeID: "node-b",
    TLS:    &tls.Config{RootCAs: trustedCAs},
})
defer cli.Close()

c, _ := cli.Dial(context.Background(), "node-a.example.com:9999")
c.Send(reqBytes)
respBytes, _ := c.Recv()
Via the ZAP Node API
import (
    "github.com/luxfi/zap"
    _ "github.com/luxfi/zap/quic" // registers the QUIC TransportFactory
)

n := zap.NewNode(zap.NodeConfig{
    NodeID:    "node-a",
    Port:      9999,
    TLS:       tlsCfg,            // must contain server Certificates
    Transport: zap.TransportQUIC, // opt-in; default stays TCP
})
n.Start()

All existing Handle / Send / Call / Broadcast / ConnectDirect calls work identically.

Production deployment notes

  • UDP port reachability: most cloud / on-prem firewalls deny UDP by default. Open the ZAP port (default 9999) explicitly for UDP.
  • MTU: quic-go picks a safe IPv6 baseline (1232) but will probe up to the path MTU. On overlay networks (VXLAN, IP-in-IP, WireGuard) set quic.Config.InitialPacketSize conservatively to avoid PMTUD blackholes.
  • ECN: Linux kernels >= 4.18 support QUIC ECN out of the box. No application-level configuration required.
  • Receive buffer: quic-go warns on small SO_RCVBUF. On Linux: sysctl -w net.core.rmem_max=7500000.
  • Connection migration: works automatically across local NAT rebinds. Cross-server migration (load balancer switching the backend) requires shared connection-ID state — out of scope for this transport.
  • 0-RTT replay: enabled by default. Application handlers MUST be idempotent, or set ServerConfig.RejectEarlyData = true to force a full 1-RTT handshake for every connection.

Cipher and KEM defaults

Defaults installed by applyZAPDefaults (see tls.go):

Field Default
MinVersion TLS 1.3 (required for ML-KEM)
CurvePreferences [X25519MLKEM768, X25519]
NextProtos ["zap/1"]
ClientSessionCache (client) in-memory LRU, 128 entries
Session-ticket lifetime 1 hour (Go stdlib default; not extended)

Override any field by setting it on ServerConfig.TLS / ClientConfig.TLS before calling Listen / NewClient. zap/1 is always prepended to the caller's NextProtos if missing.

Why X25519MLKEM768?

X25519MLKEM768 is the IANA-registered NamedGroup 0x11ec. It is the hybrid post-quantum KEM jointly defined by Cloudflare, Google, and the IETF TLS working group, shipped by Go 1.24+, BoringSSL, Cloudflare production, and every other modern TLS stack with a PQ story.

The KEM combines:

  • X25519 (RFC 7748) — classical 128-bit ECC security
  • ML-KEM-768 (NIST FIPS 203) — NIST Level 3 lattice security

The combined shared secret is HKDF-Extract(X25519_ss || MLKEM_ss), performed by Go's crypto/tls internally. This package does NOT implement its own KEM combiner; that would be a custom-crypto path and a needless attack surface.

We deliberately do not ship a hybrid that deviates from 0x11ec (no "z-wing" fork, no rolled-our-own ID). Interop with every other Hanzo / Lux service is the load-bearing property; the moment we diverge from the IANA registry we lose it.

Threat model — what is and isn't protected

Hybrid KEM defends against harvest-now / decrypt-later: a captured ciphertext stays confidential even if a quantum adversary eventually breaks X25519. The KEM half (ML-KEM-768) carries the post-quantum security; X25519 is the classical fallback.

The server certificate signature is still classical (ECDSA or RSA). Go's stdlib TLS does not yet support post-quantum signature schemes (ML-DSA, SLH-DSA). A quantum adversary who can forge today's CA signature can mount a real-time MITM, but cannot decrypt past captures. This trade-off is acceptable under the published threat model and is the same one Cloudflare ships today; for a fully post-quantum auth path see the PQ-Hybrid-KEM paper at ../papers/pq-hybrid-kem/main.pdf.

API surface

Type / func Purpose
Listen(ServerConfig) Start a QUIC listener.
NewClient(ClientConfig) Construct a client with its own UDP socket.
Server.Accept(ctx) Block for the next post-handshake connection.
Client.Dial(ctx, addr) 1-RTT dial.
Client.DialEarly(ctx, addr) 0-RTT-attempting dial (resumes via session ticket).
Conn.Send(frame) / Conn.Recv() Send / receive ZAP frames on the control stream.
Conn.OpenStream(ctx) Open a new bidirectional QUIC stream for an independent RPC.
Conn.AcceptStream(ctx) Accept a peer-initiated bidirectional stream.
Conn.OpenUniStream(ctx) Open a unidirectional stream (broadcasts, subscriptions).
Conn.AcceptUniStream(ctx) Accept a peer-initiated unidirectional stream.
Conn.ConnectionState() TLS connection state — CurveID is the negotiated NamedGroup.
Conn.IsZeroRTT() Whether the QUIC handshake completed with 0-RTT accepted.
Conn.Close() Graceful close: drain control stream, send CONNECTION_CLOSE(0).
GenerateSelfSignedCert(hosts...) Test-only ECDSA-P256 cert helper.
ALPN "zap/1" — the only accepted ALPN.
MaxFrameSize 10 MiB — same as the TCP transport.

Test vectors

go test ./quic/... -v runs the X25519MLKEM768 negotiation assertion end-to-end. The TestHandshake_NegotiatesX25519MLKEM768 test fails loudly if CurveID != 0x11ec after either side completes the handshake — keep it green.

See also

Documentation

Overview

Package quic implements the QUIC transport for the ZAP messaging substrate.

QUIC offers, compared to ZAP's existing TCP+TLS transport:

  • Stream multiplexing: N concurrent RPCs over one connection without head-of-line blocking.
  • Connection migration: the connection survives client-IP changes (Wi-Fi-to-LTE, NAT rebinding, etc.) because the QUIC connection ID is independent of the 5-tuple.
  • 0-RTT resumption: cached session tickets let the second handshake to a peer complete without a round trip before app data flows.
  • TLS 1.3 with the X25519MLKEM768 hybrid post-quantum key exchange (IANA NamedGroup 0x11ec) baked in.

Wire format

One ZAP message is one length-prefixed Cap'n Proto frame on a QUIC stream. The frame format is identical to the TCP transport:

[4-byte little-endian length][ZAP message bytes]

Two stream patterns are used:

  • Bidirectional streams carry one request/response exchange. After the response is written the server side closes its half of the stream and the client closes its half on Recv, freeing the stream ID. This maps onto Node.Call.
  • Unidirectional streams from the server carry one-way notifications and subscription deliveries. The receiver routes each frame through the same handler dispatch as the TCP path.

The control stream (the first bidirectional stream opened by the dialer) carries the 64-byte ZAP node-identity handshake exchanged before any RPC.

Cryptography

The default TLS 1.3 configuration prefers X25519MLKEM768. That is the IANA-registered hybrid: the shared secret is HKDF-Extract of X25519_ss concatenated with ML-KEM-768 ciphertext-derived ss. The Go runtime performs the combination internally — this package does not roll its own KEM combiner. See ../papers/pq-hybrid-kem/main.pdf.

The server certificate signature is still classical (ECDSA or RSA); post-quantum signature schemes are not yet wired into Go's stdlib TLS. The threat model that motivates X25519MLKEM768 is harvest-now / decrypt-later against the confidentiality of the key-exchange, not forgery against a trusted-today certificate authority.

0-RTT replay

QUIC permits 0-RTT application data, which can be replayed by a network adversary. The server defaults to accepting 0-RTT data, but application handlers MUST treat 0-RTT-carried RPCs as either idempotent or rejected. Use ServerConfig.RejectEarlyData to force a full 1-RTT handshake for every connection.

Index

Constants

View Source
const ALPN = "zap/1"

ALPN is the ALPN protocol identifier negotiated for every ZAP-QUIC connection. Servers reject TLS handshakes that propose any other ALPN.

View Source
const MaxFrameSize = 10 * 1024 * 1024

MaxFrameSize bounds a single ZAP frame on a QUIC stream. Identical to the TCP transport's bound — 10 MiB — so the application layer sees the same envelope regardless of transport.

Variables

View Source
var (
	// ErrConnClosed is returned by Send/Recv/OpenStream after the
	// underlying QUIC connection has been closed (graceful or abrupt).
	ErrConnClosed = errors.New("zap/quic: connection closed")

	// ErrFrameTooLarge is returned when reading a frame whose
	// declared length exceeds MaxFrameSize.
	ErrFrameTooLarge = errors.New("zap/quic: frame too large")

	// ErrBadHandshake is returned when the peer's identity handshake
	// is malformed or empty.
	ErrBadHandshake = errors.New("zap/quic: invalid handshake")

	// ErrBadALPN is returned when the negotiated ALPN is not "zap/1".
	// quic-go enforces ALPN at the TLS layer; this is a belt-and-suspenders
	// post-handshake check.
	ErrBadALPN = errors.New("zap/quic: unexpected ALPN")
)

Common errors.

View Source
var ErrServerClosed = errors.New("zap/quic: server closed")

ErrServerClosed is returned by Accept after Close has been called. It is the unwrap target for errors from a closed listener.

Functions

func GenerateSelfSignedCert

func GenerateSelfSignedCert(hosts ...string) (tls.Certificate, error)

GenerateSelfSignedCert generates a short-lived self-signed ECDSA-P256 certificate suitable for testing. NOT for production use: production deployments must supply real certificates via tls.Config.Certificates or GetCertificate, signed by a trusted CA.

Exposed as a public helper because tests in other packages may want to spin up a real ZAP-QUIC server.

Types

type Client

type Client struct {
	// contains filtered or unexported fields
}

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).

func NewClient

func NewClient(cfg ClientConfig) (*Client, error)

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 (*Client) Close

func (c *Client) Close() error

Close releases the underlying UDP socket. In-flight Dial calls return errors; existing *Conn objects remain valid until their own Close.

func (*Client) Dial

func (c *Client) Dial(ctx context.Context, addr string) (*Conn, error)

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 (*Client) DialEarly

func (c *Client) DialEarly(ctx context.Context, addr string) (*Conn, error)

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 (*Client) LocalAddr

func (c *Client) LocalAddr() net.Addr

LocalAddr returns the local UDP address bound by NewClient.

func (*Client) Transport

func (c *Client) Transport() *quicgo.Transport

Transport returns the underlying *quic.Transport. Exposed for tests that need to trigger a deliberate path migration.

type ClientConfig

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
}

ClientConfig configures a QUIC dialer.

type Config

type Config struct {
	// QUIC is the underlying quic-go transport config. Optional;
	// nil yields ZAP's defaults (30s idle timeout, 15s keepalive,
	// 1024 max concurrent streams in each direction).
	QUIC *quicgo.Config

	// RejectEarlyData disables 0-RTT resumption on the server side.
	// Set this to true if any RPC handler is non-idempotent — 0-RTT
	// data is replayable by an active network adversary.
	RejectEarlyData bool
}

Config is the user-facing knob that the zap parent package's NodeConfig.QUICConfig accepts. It's a thin wrapper so callers don't have to import quic-go just to flip RejectEarlyData; they can supply nil for QUIC if defaults are fine.

Typical use:

cfg := zap.NodeConfig{
    NodeID:    "node-a",
    Port:      9999,
    TLS:       tlsCfg,
    Transport: zap.TransportQUIC,
    QUICConfig: &quic.Config{RejectEarlyData: true},
}

type Conn

type Conn struct {
	// PeerID is the ZAP node identity asserted by the peer in the
	// identity handshake on the control stream.
	PeerID string

	// LocalAddr / RemoteAddr expose the underlying UDP 5-tuple at the
	// time the connection was created. Because QUIC supports connection
	// migration, RemoteAddr may differ from the address actually
	// observed for the most recent packet — these fields are
	// informational, not authoritative.
	LocalAddr  net.Addr
	RemoteAddr net.Addr
	// contains filtered or unexported fields
}

Conn is a multiplexed ZAP connection over QUIC.

Conn is the transport-level connection abstraction shared between the dialer (client.go) and the listener (server.go). It exposes the same shape as ZAP's TCP *Conn (Send, Recv, Close, peer identity) and adds stream-level primitives that the underlying TCP transport cannot efficiently express.

Conn is safe for concurrent use. Send serializes onto the control stream; concurrent callers may instead OpenStream to get independent per-RPC streams.

func (*Conn) AcceptStream

func (c *Conn) AcceptStream(ctx context.Context) (*Stream, error)

AcceptStream blocks until the peer opens a new bidirectional QUIC stream. Used by the server to serve per-RPC streams from the client.

func (*Conn) AcceptUniStream

func (c *Conn) AcceptUniStream(ctx context.Context) (*UniReceiveStream, error)

AcceptUniStream blocks until the peer opens a unidirectional QUIC stream addressed to this side.

func (*Conn) Close

func (c *Conn) Close() error

Close initiates a graceful close of the QUIC connection.

Graceful close protocol:

  1. CloseWrite() on the control stream so the peer's Recv loop observes io.EOF after draining all in-flight frames.
  2. Wait up to drainTimeout for the peer's control stream to deliver its own EOF (the symmetric DRAIN signal).
  3. CloseWithError(0, "") on the underlying *quic.Conn, which emits a QUIC CONNECTION_CLOSE frame with error code 0.

This is the ALPN-coordinated DRAIN sequence the mission spec calls for: in-flight frames are flushed before connection close.

func (*Conn) ConnectionState

func (c *Conn) ConnectionState() tls.ConnectionState

ConnectionState returns the TLS connection state of the underlying QUIC handshake. Notably, ConnectionState().CurveID reports the negotiated TLS NamedGroup — used by tests to assert that X25519MLKEM768 (0x11ec) is the actual negotiated KEM.

func (*Conn) Context

func (c *Conn) Context() context.Context

Context returns the underlying connection's context, which is canceled when the connection terminates (either side).

func (*Conn) IsZeroRTT

func (c *Conn) IsZeroRTT() bool

IsZeroRTT reports whether the QUIC handshake completed with 0-RTT early data accepted by the server. Only meaningful on the client side after a resumed connection.

func (*Conn) OpenStream

func (c *Conn) OpenStream(ctx context.Context) (*Stream, error)

OpenStream opens a new bidirectional QUIC stream for an independent request/response exchange. The returned Stream wraps QUIC-level flow control so the caller cannot overrun the peer.

The typical RPC pattern is:

s, err := conn.OpenStream(ctx)
if err != nil { return err }
defer s.Close()
s.WriteFrame(reqBytes)   // sends one ZAP frame
respBytes, err := s.ReadFrame()

After Close, the stream ID is freed and may be reused.

func (*Conn) OpenUniStream

func (c *Conn) OpenUniStream(ctx context.Context) (*UniStream, error)

OpenUniStream opens a unidirectional send stream, used for fire-and-forget subscription deliveries and broadcasts where the peer never replies.

func (*Conn) QUIC

func (c *Conn) QUIC() *quicgo.Conn

QUIC returns the underlying *quic.Conn. Exposed for advanced callers (e.g. tests that need to inspect ConnectionState or trigger a migration). Most consumers should not touch this.

func (*Conn) Recv

func (c *Conn) Recv() ([]byte, error)

Recv reads the next ZAP frame from the control stream. Frames are read in the same order they were sent.

func (*Conn) Send

func (c *Conn) Send(frame []byte) error

Send writes a ZAP frame on the control stream. This is the transport-API parity surface for the TCP Conn.Send: a single, serialized stream of frames in arrival order. For independent concurrent RPCs, prefer OpenStream.

type Server

type Server struct {
	// contains filtered or unexported fields
}

Server is a QUIC listener that yields *Conn instances ready for ZAP-frame exchange.

Server is the QUIC analogue of net.Listener. The outer ZAP Node drives the accept loop; this type does not spawn its own goroutines.

func Listen

func Listen(cfg ServerConfig) (*Server, error)

Listen creates a new QUIC listener bound to cfg.Addr.

The returned Server is ready for Accept calls. Close releases the UDP socket and all in-flight connections.

func (*Server) Accept

func (s *Server) Accept(ctx context.Context) (*Conn, error)

Accept waits for and returns the next ZAP-QUIC connection. The returned Conn has already completed the node-identity handshake on the control stream.

func (*Server) Addr

func (s *Server) Addr() net.Addr

Addr returns the underlying UDP listen address. Useful when the caller bound to ":0" and needs to learn the kernel-assigned port.

func (*Server) Close

func (s *Server) Close() error

Close releases the listener and the underlying UDP socket. Any in-flight Accept call returns ErrServerClosed.

type ServerConfig

type ServerConfig struct {
	// NodeID is this node's ZAP identity. Sent on the control stream
	// of every accepted connection. Required.
	NodeID string

	// Addr is the UDP address to listen on. Empty means ":0" (random
	// kernel-assigned port). Resolved as a UDP address.
	Addr string

	// TLS is the TLS 1.3 server config. Required field
	// Certificates (or GetCertificate) must be populated by the caller.
	//
	// If MinVersion / CurvePreferences / NextProtos are not set, they
	// are defaulted to:
	//
	//   MinVersion       = TLS 1.3
	//   CurvePreferences = [X25519MLKEM768, X25519]
	//   NextProtos       = ["zap/1"]
	//
	// Any caller-supplied NextProtos list will have "zap/1" prepended
	// if missing.
	TLS *tls.Config

	// QUIC, if non-nil, is the quic-go Config. Defaults are picked
	// for the common ZAP workload (multiplex up to 1024 streams,
	// enable 0-RTT, allow connection migration).
	QUIC *quicgo.Config

	// RejectEarlyData, if true, disables 0-RTT resumption. Application
	// handlers that are NOT idempotent should set this to eliminate
	// the 0-RTT replay-attack surface.
	RejectEarlyData bool

	// Logger for transport events. Defaults to slog.Default().
	Logger *slog.Logger
}

ServerConfig configures a QUIC listener.

type Stream

type Stream struct {
	// contains filtered or unexported fields
}

Stream is a bidirectional QUIC stream carrying ZAP frames.

func (*Stream) Close

func (s *Stream) Close() error

Close shuts both directions of the stream.

func (*Stream) CloseWrite

func (s *Stream) CloseWrite() error

CloseWrite finishes the local write side. The peer will see io.EOF on subsequent ReadFrame calls after all queued data is delivered.

func (*Stream) ReadFrame

func (s *Stream) ReadFrame() ([]byte, error)

ReadFrame reads one length-prefixed ZAP frame from the stream.

func (*Stream) WriteFrame

func (s *Stream) WriteFrame(frame []byte) error

WriteFrame writes one length-prefixed ZAP frame to the stream.

type UniReceiveStream

type UniReceiveStream struct {
	// contains filtered or unexported fields
}

UniReceiveStream is a unidirectional receive-only QUIC stream.

func (*UniReceiveStream) ReadFrame

func (u *UniReceiveStream) ReadFrame() ([]byte, error)

ReadFrame reads the next ZAP frame from the stream.

type UniStream

type UniStream struct {
	// contains filtered or unexported fields
}

UniStream is a unidirectional send-only QUIC stream.

func (*UniStream) Close

func (u *UniStream) Close() error

Close finishes the stream. The receiver sees io.EOF after the last frame is delivered.

func (*UniStream) WriteFrame

func (u *UniStream) WriteFrame(frame []byte) error

WriteFrame writes one ZAP frame to the unidirectional stream.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL