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
152 lines
5.2 KiB
Go
152 lines
5.2 KiB
Go
// Copyright (C) 2026, Lux Industries Inc. All rights reserved.
|
|
// See the file LICENSE for licensing terms.
|
|
|
|
package quic
|
|
|
|
import (
|
|
"crypto/ecdsa"
|
|
"crypto/elliptic"
|
|
"crypto/rand"
|
|
"crypto/tls"
|
|
"crypto/x509"
|
|
"crypto/x509/pkix"
|
|
"encoding/pem"
|
|
"errors"
|
|
"fmt"
|
|
"math/big"
|
|
"net"
|
|
"time"
|
|
)
|
|
|
|
// defaultCurvePreferences is the default TLS NamedGroup preference list.
|
|
//
|
|
// X25519MLKEM768 (IANA 0x11ec) is the hybrid post-quantum key exchange
|
|
// jointly defined by Cloudflare / Google / IETF and shipped by Go 1.24+.
|
|
// It combines:
|
|
//
|
|
// - X25519 (RFC 7748) — classical 128-bit ECC security
|
|
// - ML-KEM-768 (NIST FIPS 203) — Level 3 lattice security
|
|
//
|
|
// The combined shared secret is HKDF-Extract of X25519_ss || ML-KEM_ss,
|
|
// performed inside crypto/tls. We do not reimplement the combiner.
|
|
//
|
|
// X25519 is kept as a fallback for peers that have not yet adopted the
|
|
// hybrid, but it is listed after the hybrid so a PQ-capable peer always
|
|
// picks the hybrid first.
|
|
//
|
|
// We deliberately do not include a hybrid variant outside the IANA
|
|
// registry (e.g. a "z-wing" fork). Stay on 0x11ec because every other
|
|
// Hanzo / Lux service that ships a TLS 1.3 stack speaks the same group.
|
|
var defaultCurvePreferences = []tls.CurveID{
|
|
tls.X25519MLKEM768,
|
|
tls.X25519,
|
|
}
|
|
|
|
// defaultSessionTicketLifetime caps the maximum age of a TLS 1.3
|
|
// session ticket the server will accept for resumption. Short
|
|
// lifetimes blunt the 0-RTT replay window.
|
|
const defaultSessionTicketLifetime = time.Hour
|
|
|
|
// applyZAPDefaults mutates cfg in place to install the ZAP defaults if
|
|
// the caller has not already chosen otherwise. Used by both server and
|
|
// client. Returns cfg.
|
|
//
|
|
// The fields we set:
|
|
//
|
|
// - MinVersion = TLS 1.3 (lower versions cannot speak X25519MLKEM768
|
|
// because the named-group registry is TLS-1.3-only).
|
|
// - CurvePreferences = [X25519MLKEM768, X25519] if unset.
|
|
// - NextProtos = ["zap/1"] if unset (ALPN allowlist).
|
|
//
|
|
// We intentionally do NOT default Certificates or VerifyPeerCertificate;
|
|
// those are the caller's policy.
|
|
func applyZAPDefaults(cfg *tls.Config) *tls.Config {
|
|
if cfg.MinVersion == 0 {
|
|
cfg.MinVersion = tls.VersionTLS13
|
|
}
|
|
if len(cfg.CurvePreferences) == 0 {
|
|
cfg.CurvePreferences = append([]tls.CurveID(nil), defaultCurvePreferences...)
|
|
}
|
|
if len(cfg.NextProtos) == 0 {
|
|
cfg.NextProtos = []string{ALPN}
|
|
} else {
|
|
// Caller supplied ALPN values: enforce that "zap/1" is one of
|
|
// them. Otherwise QUIC negotiation will fail.
|
|
found := false
|
|
for _, p := range cfg.NextProtos {
|
|
if p == ALPN {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
// Prepend so we still get a chance to negotiate the
|
|
// ZAP protocol when the caller knows about others.
|
|
cfg.NextProtos = append([]string{ALPN}, cfg.NextProtos...)
|
|
}
|
|
}
|
|
if cfg.SessionTicketsDisabled == false && cfg.ClientSessionCache == nil {
|
|
// Client-side ticket cache; harmless on a server config.
|
|
cfg.ClientSessionCache = tls.NewLRUClientSessionCache(128)
|
|
}
|
|
return cfg
|
|
}
|
|
|
|
// 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.
|
|
func GenerateSelfSignedCert(hosts ...string) (tls.Certificate, error) {
|
|
if len(hosts) == 0 {
|
|
hosts = []string{"localhost"}
|
|
}
|
|
priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
|
if err != nil {
|
|
return tls.Certificate{}, fmt.Errorf("ecdsa keygen: %w", err)
|
|
}
|
|
serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
|
|
if err != nil {
|
|
return tls.Certificate{}, fmt.Errorf("serial: %w", err)
|
|
}
|
|
tmpl := &x509.Certificate{
|
|
SerialNumber: serial,
|
|
Subject: pkix.Name{CommonName: hosts[0], Organization: []string{"ZAP"}},
|
|
NotBefore: time.Now().Add(-time.Minute),
|
|
NotAfter: time.Now().Add(time.Hour),
|
|
KeyUsage: x509.KeyUsageDigitalSignature,
|
|
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth},
|
|
}
|
|
for _, h := range hosts {
|
|
if ip := net.ParseIP(h); ip != nil {
|
|
tmpl.IPAddresses = append(tmpl.IPAddresses, ip)
|
|
} else {
|
|
tmpl.DNSNames = append(tmpl.DNSNames, h)
|
|
}
|
|
}
|
|
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &priv.PublicKey, priv)
|
|
if err != nil {
|
|
return tls.Certificate{}, fmt.Errorf("create cert: %w", err)
|
|
}
|
|
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
|
|
keyDER, err := x509.MarshalECPrivateKey(priv)
|
|
if err != nil {
|
|
return tls.Certificate{}, fmt.Errorf("marshal key: %w", err)
|
|
}
|
|
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER})
|
|
return tls.X509KeyPair(certPEM, keyPEM)
|
|
}
|
|
|
|
// validateNegotiatedALPN is a belt-and-suspenders post-handshake check.
|
|
// quic-go already rejects mismatched ALPN at the TLS layer, but we
|
|
// re-check so that any future relaxation in quic-go does not silently
|
|
// allow non-zap protocols through this transport.
|
|
func validateNegotiatedALPN(st tls.ConnectionState) error {
|
|
if st.NegotiatedProtocol != ALPN {
|
|
return errors.New("zap/quic: peer negotiated ALPN " + st.NegotiatedProtocol + ", expected " + ALPN)
|
|
}
|
|
return nil
|
|
}
|