Files
zeekay e7539555a2 fix(zap): close LP-023 v3.1 Red round 2 wire gaps (HIGH-1, HIGH-2, MEDIUM-1)
RED-HIGH-1: Object.List length is now clamped against len(data) at parse.
Attacker-set length=0xFFFFFFFF used to flow through to consumers, where
`for i := 0; i < l.Len()` would iterate 4G times.

RED-HIGH-2: Object.Object, Object.List, and Object.Bytes now reject any
absOffset < HeaderSize. A negative relOffset can no longer alias the wire
header (Magic/Version/Flags/RootOffset/Size). The signed-cast remains so
honest builders can finalize nested items before their parent.

RED-MEDIUM-1: bump wire Version from 1 to 2 (Version1 still accepted at
Parse for backward compat). NewBuilder emits Version2 by default;
NewBuilderV1 is kept for explicit-legacy paths. Message.Version() exposes
the parsed version so v3 platformvm Wrap*Tx accessors can gate on
Version2 to reject v1-vs-v2 schema confusion (e.g. NetworkID=11 byte 0
collision with TxKindBaseFull).

RED-V18: Parse now rejects size < HeaderSize. A buffer with size=0
previously slipped through and panicked on subsequent Root()/Flags().

Regression tests pinned in-tree at zap_test.go:
  TestRedRound2_HIGH1_UncappedListLength
  TestRedRound2_HIGH2_BackwardListPointer
  TestRedRound2_HIGH2_BackwardObjectPointer
  TestRedRound2_HIGH2_BackwardBytesPointer
  TestRedRound2_MEDIUM1_VersionParse
  TestRedRound2_MEDIUM1_NewBuilderEmitsV2
  TestRedRound2_V18_SizeZeroRejected
  TestRedRound2_F1_NegativeBitPatternSweep

Originals at /tmp/red_zap_attacks/*_test.go.
2026-06-02 15:11:11 -07:00

437 lines
13 KiB
Go

// Copyright (C) 2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package quic
import (
"context"
"crypto/tls"
"encoding/binary"
"errors"
"fmt"
"io"
"net"
"sync"
"sync/atomic"
"time"
quicgo "github.com/quic-go/quic-go"
)
// ALPN is the ALPN protocol identifier negotiated for every ZAP-QUIC
// connection. Servers reject TLS handshakes that propose any other ALPN.
const ALPN = "zap/1"
// 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.
const MaxFrameSize = 10 * 1024 * 1024
// handshakeTimeout is how long we wait for the 64-byte node-identity
// handshake on the control stream before tearing down the connection.
const handshakeTimeout = 10 * time.Second
// nodeIDFrameSize is the fixed-length envelope used for the node-ID
// exchange. Mirrors the TCP transport's choice for symmetry with node.go.
const nodeIDFrameSize = 64
// Common errors.
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")
)
// 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.
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
qc *quicgo.Conn
control *quicgo.Stream // bidirectional control stream
ctrlMu sync.Mutex // serializes writes on `control`
closed atomic.Bool
}
// newConnFromQUIC wraps an already-established *quic.Conn. The dialer
// and the listener share this entry point so that all post-handshake
// state lives in one struct.
func newConnFromQUIC(qc *quicgo.Conn, control *quicgo.Stream, peerID string) *Conn {
return &Conn{
PeerID: peerID,
LocalAddr: qc.LocalAddr(),
RemoteAddr: qc.RemoteAddr(),
qc: qc,
control: control,
}
}
// 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 (c *Conn) QUIC() *quicgo.Conn { return c.qc }
// 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 (c *Conn) ConnectionState() tls.ConnectionState {
return c.qc.ConnectionState().TLS
}
// 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 (c *Conn) IsZeroRTT() bool {
return c.qc.ConnectionState().Used0RTT
}
// 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.
func (c *Conn) Send(frame []byte) error {
if c.closed.Load() {
return ErrConnClosed
}
c.ctrlMu.Lock()
defer c.ctrlMu.Unlock()
return writeFrame(c.control, frame)
}
// Recv reads the next ZAP frame from the control stream. Frames are
// read in the same order they were sent.
func (c *Conn) Recv() ([]byte, error) {
if c.closed.Load() {
return nil, ErrConnClosed
}
return readFrame(c.control)
}
// 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 (c *Conn) OpenStream(ctx context.Context) (*Stream, error) {
if c.closed.Load() {
return nil, ErrConnClosed
}
s, err := c.qc.OpenStreamSync(ctx)
if err != nil {
return nil, fmt.Errorf("zap/quic: open stream: %w", err)
}
return &Stream{s: s}, nil
}
// AcceptStream blocks until the peer opens a new bidirectional QUIC
// stream. Used by the server to serve per-RPC streams from the client.
func (c *Conn) AcceptStream(ctx context.Context) (*Stream, error) {
if c.closed.Load() {
return nil, ErrConnClosed
}
s, err := c.qc.AcceptStream(ctx)
if err != nil {
return nil, err
}
return &Stream{s: s}, nil
}
// OpenUniStream opens a unidirectional send stream, used for
// fire-and-forget subscription deliveries and broadcasts where the
// peer never replies.
func (c *Conn) OpenUniStream(ctx context.Context) (*UniStream, error) {
if c.closed.Load() {
return nil, ErrConnClosed
}
s, err := c.qc.OpenUniStreamSync(ctx)
if err != nil {
return nil, fmt.Errorf("zap/quic: open uni stream: %w", err)
}
return &UniStream{s: s}, nil
}
// AcceptUniStream blocks until the peer opens a unidirectional QUIC
// stream addressed to this side.
func (c *Conn) AcceptUniStream(ctx context.Context) (*UniReceiveStream, error) {
if c.closed.Load() {
return nil, ErrConnClosed
}
s, err := c.qc.AcceptUniStream(ctx)
if err != nil {
return nil, err
}
return &UniReceiveStream{s: s}, nil
}
// 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 (c *Conn) Close() error {
if !c.closed.CompareAndSwap(false, true) {
return nil
}
// Step 1: half-close control stream (signal DRAIN).
_ = c.control.Close()
// Step 2: best-effort drain wait. We bound it so we don't hang
// on a misbehaving peer.
drainTimeout := 200 * time.Millisecond
done := make(chan struct{})
go func() {
defer close(done)
_, _ = io.Copy(io.Discard, c.control)
}()
select {
case <-done:
case <-time.After(drainTimeout):
}
// Step 3: cleanly tear down the QUIC connection.
return c.qc.CloseWithError(0, "")
}
// Context returns the underlying connection's context, which is
// canceled when the connection terminates (either side).
func (c *Conn) Context() context.Context {
return c.qc.Context()
}
// Stream is a bidirectional QUIC stream carrying ZAP frames.
type Stream struct {
s *quicgo.Stream
}
// WriteFrame writes one length-prefixed ZAP frame to the stream.
func (s *Stream) WriteFrame(frame []byte) error {
return writeFrame(s.s, frame)
}
// ReadFrame reads one length-prefixed ZAP frame from the stream.
func (s *Stream) ReadFrame() ([]byte, error) {
return readFrame(s.s)
}
// Close shuts both directions of the stream.
func (s *Stream) Close() error {
return s.s.Close()
}
// CloseWrite finishes the local write side. The peer will see
// io.EOF on subsequent ReadFrame calls after all queued data is
// delivered.
func (s *Stream) CloseWrite() error {
return s.s.Close()
}
// UniStream is a unidirectional send-only QUIC stream.
type UniStream struct {
s *quicgo.SendStream
}
// WriteFrame writes one ZAP frame to the unidirectional stream.
func (u *UniStream) WriteFrame(frame []byte) error {
return writeFrame(u.s, frame)
}
// Close finishes the stream. The receiver sees io.EOF after the
// last frame is delivered.
func (u *UniStream) Close() error {
return u.s.Close()
}
// UniReceiveStream is a unidirectional receive-only QUIC stream.
type UniReceiveStream struct {
s *quicgo.ReceiveStream
}
// ReadFrame reads the next ZAP frame from the stream.
func (u *UniReceiveStream) ReadFrame() ([]byte, error) {
return readFrame(u.s)
}
// writeFrame writes one length-prefixed ZAP frame.
//
// Wire format: [4-byte little-endian length][frame bytes].
// Identical to ZAP's TCP transport.
func writeFrame(w io.Writer, frame []byte) error {
if uint64(len(frame)) > MaxFrameSize {
return ErrFrameTooLarge
}
var hdr [4]byte
binary.LittleEndian.PutUint32(hdr[:], uint32(len(frame)))
if _, err := w.Write(hdr[:]); err != nil {
return err
}
if len(frame) == 0 {
return nil
}
_, err := w.Write(frame)
return err
}
// readFrame reads the next length-prefixed ZAP frame.
func readFrame(r io.Reader) ([]byte, error) {
var hdr [4]byte
if _, err := io.ReadFull(r, hdr[:]); err != nil {
return nil, err
}
n := binary.LittleEndian.Uint32(hdr[:])
if uint64(n) > MaxFrameSize {
return nil, ErrFrameTooLarge
}
if n == 0 {
return []byte{}, nil
}
buf := make([]byte, n)
if _, err := io.ReadFull(r, buf); err != nil {
return nil, err
}
return buf, nil
}
// performServerHandshake reads the client's node-ID then writes the
// server's. Returns the client's asserted node identity.
func performServerHandshake(ctx context.Context, ctrl *quicgo.Stream, localID string) (string, error) {
deadlineCtx, cancel := context.WithTimeout(ctx, handshakeTimeout)
defer cancel()
_ = ctrl.SetReadDeadline(time.Now().Add(handshakeTimeout))
_ = ctrl.SetWriteDeadline(time.Now().Add(handshakeTimeout))
defer func() {
_ = ctrl.SetReadDeadline(time.Time{})
_ = ctrl.SetWriteDeadline(time.Time{})
}()
type result struct {
peerID string
err error
}
out := make(chan result, 1)
go func() {
peerID, err := readHandshake(ctrl)
if err == nil {
err = writeHandshake(ctrl, localID)
}
out <- result{peerID, err}
}()
select {
case r := <-out:
return r.peerID, r.err
case <-deadlineCtx.Done():
return "", deadlineCtx.Err()
}
}
// performClientHandshake writes our node-ID then reads the server's.
func performClientHandshake(ctx context.Context, ctrl *quicgo.Stream, localID string) (string, error) {
deadlineCtx, cancel := context.WithTimeout(ctx, handshakeTimeout)
defer cancel()
_ = ctrl.SetReadDeadline(time.Now().Add(handshakeTimeout))
_ = ctrl.SetWriteDeadline(time.Now().Add(handshakeTimeout))
defer func() {
_ = ctrl.SetReadDeadline(time.Time{})
_ = ctrl.SetWriteDeadline(time.Time{})
}()
type result struct {
peerID string
err error
}
out := make(chan result, 1)
go func() {
if err := writeHandshake(ctrl, localID); err != nil {
out <- result{"", err}
return
}
peerID, err := readHandshake(ctrl)
out <- result{peerID, err}
}()
select {
case r := <-out:
return r.peerID, r.err
case <-deadlineCtx.Done():
return "", deadlineCtx.Err()
}
}
// writeHandshake writes a fixed-size 64-byte identity frame: the first
// N bytes are the node-ID UTF-8, the trailing 4 bytes encode the length.
// This mirrors node.go's TCP handshake exactly.
func writeHandshake(w io.Writer, nodeID string) error {
var buf [nodeIDFrameSize]byte
idBytes := []byte(nodeID)
n := len(idBytes)
if n > nodeIDFrameSize-4 {
n = nodeIDFrameSize - 4
}
copy(buf[:n], idBytes[:n])
binary.LittleEndian.PutUint32(buf[nodeIDFrameSize-4:], uint32(n))
// Wrap in framed write so the receiver can decode without
// special-casing handshake parsing.
return writeFrame(w, buf[:])
}
// readHandshake reads and decodes a 64-byte identity frame.
func readHandshake(r io.Reader) (string, error) {
frame, err := readFrame(r)
if err != nil {
return "", err
}
if len(frame) != nodeIDFrameSize {
return "", ErrBadHandshake
}
n := binary.LittleEndian.Uint32(frame[nodeIDFrameSize-4:])
if n == 0 || n > nodeIDFrameSize-4 {
return "", ErrBadHandshake
}
return string(frame[:n]), nil
}