quic: support Retry

Add a RequireAddressValidation configuration setting to enable
sending Retry packets on the server.

Support receiving Retry packets on the client.

RFC 9000, Section 8.1.2.

For golang/go#58547

Change-Id: Ia78b9594a03ce1b1143b95cb3c1ef4c38b2b39ef
Reviewed-on: https://go-review.googlesource.com/c/net/+/535237
LUCI-TryBot-Result: Go LUCI <golang-scoped@luci-project-accounts.iam.gserviceaccount.com>
Reviewed-by: Roland Shoemaker <roland@golang.org>
Reviewed-by: Jonathan Amsterdam <jba@google.com>
diff --git a/internal/quic/config.go b/internal/quic/config.go
index b390d69..99ef68f 100644
--- a/internal/quic/config.go
+++ b/internal/quic/config.go
@@ -47,6 +47,14 @@
 	// If zero, the default value of 1MiB is used.
 	// If negative, the limit is zero.
 	MaxConnReadBufferSize int64
+
+	// RequireAddressValidation may be set to true to enable address validation
+	// of client connections prior to starting the handshake.
+	//
+	// Enabling this setting reduces the amount of work packets with spoofed
+	// source address information can cause a server to perform,
+	// at the cost of increased handshake latency.
+	RequireAddressValidation bool
 }
 
 func configDefault(v, def, limit int64) int64 {
diff --git a/internal/quic/conn.go b/internal/quic/conn.go
index ea03bbf..4acf5dd 100644
--- a/internal/quic/conn.go
+++ b/internal/quic/conn.go
@@ -48,6 +48,9 @@
 	crypto        [numberSpaceCount]cryptoStream
 	tls           *tls.QUICConn
 
+	// retryToken is the token provided by the peer in a Retry packet.
+	retryToken []byte
+
 	// handshakeConfirmed is set when the handshake is confirmed.
 	// For server connections, it tracks sending HANDSHAKE_DONE.
 	handshakeConfirmed sentVal
@@ -83,7 +86,7 @@
 	timeNow() time.Time
 }
 
-func newConn(now time.Time, side connSide, initialConnID []byte, peerAddr netip.AddrPort, config *Config, l *Listener) (*Conn, error) {
+func newConn(now time.Time, side connSide, originalDstConnID, retrySrcConnID []byte, peerAddr netip.AddrPort, config *Config, l *Listener) (*Conn, error) {
 	c := &Conn{
 		side:                 side,
 		listener:             l,
@@ -104,17 +107,21 @@
 		l.testHooks.newConn(c)
 	}
 
-	var originalDstConnID []byte
+	// initialConnID is the connection ID used to generate Initial packet protection keys.
+	var initialConnID []byte
 	if c.side == clientSide {
 		if err := c.connIDState.initClient(c); err != nil {
 			return nil, err
 		}
 		initialConnID, _ = c.connIDState.dstConnID()
 	} else {
+		initialConnID = originalDstConnID
+		if retrySrcConnID != nil {
+			initialConnID = retrySrcConnID
+		}
 		if err := c.connIDState.initServer(c, initialConnID); err != nil {
 			return nil, err
 		}
-		originalDstConnID = initialConnID
 	}
 
 	// The smallest allowed maximum QUIC datagram size is 1200 bytes.
@@ -125,10 +132,10 @@
 	c.streamsInit()
 	c.lifetimeInit()
 
-	// TODO: retry_source_connection_id
 	if err := c.startTLS(now, initialConnID, transportParameters{
 		initialSrcConnID:               c.connIDState.srcConnID(),
 		originalDstConnID:              originalDstConnID,
+		retrySrcConnID:                 retrySrcConnID,
 		ackDelayExponent:               ackDelayExponent,
 		maxUDPPayloadSize:              maxUDPPayloadSize,
 		maxAckDelay:                    maxAckDelay,
@@ -195,7 +202,8 @@
 
 // receiveTransportParameters applies transport parameters sent by the peer.
 func (c *Conn) receiveTransportParameters(p transportParameters) error {
-	if err := c.connIDState.validateTransportParameters(c.side, p); err != nil {
+	isRetry := c.retryToken != nil
+	if err := c.connIDState.validateTransportParameters(c.side, isRetry, p); err != nil {
 		return err
 	}
 	c.streams.outflow.setMaxData(p.initialMaxData)
@@ -220,9 +228,11 @@
 			return err
 		}
 	}
-
-	// TODO: Many more transport parameters to come.
-
+	// TODO: max_idle_timeout
+	// TODO: stateless_reset_token
+	// TODO: max_udp_payload_size
+	// TODO: disable_active_migration
+	// TODO: preferred_address
 	return nil
 }
 
diff --git a/internal/quic/conn_id.go b/internal/quic/conn_id.go
index 045e646..ff7e2d1 100644
--- a/internal/quic/conn_id.go
+++ b/internal/quic/conn_id.go
@@ -28,6 +28,9 @@
 	retireRemotePriorTo   int64 // largest Retire Prior To value sent by the peer
 	peerActiveConnIDLimit int64 // peer's active_connection_id_limit transport parameter
 
+	originalDstConnID []byte // expected original_destination_connection_id param
+	retrySrcConnID    []byte // expected retry_source_connection_id param
+
 	needSend bool
 }
 
@@ -78,6 +81,7 @@
 		seq: -1,
 		cid: remid,
 	})
+	s.originalDstConnID = remid
 	const retired = false
 	c.listener.connIDsChanged(c, retired, s.local[:])
 	return nil
@@ -163,27 +167,21 @@
 
 // validateTransportParameters verifies the original_destination_connection_id and
 // initial_source_connection_id transport parameters match the expected values.
-func (s *connIDState) validateTransportParameters(side connSide, p transportParameters) error {
+func (s *connIDState) validateTransportParameters(side connSide, isRetry bool, p transportParameters) error {
 	// TODO: Consider returning more detailed errors, for debugging.
-	switch side {
-	case clientSide:
-		// Verify original_destination_connection_id matches
-		// the transient remote connection ID we chose.
-		if len(s.remote) == 0 || s.remote[0].seq != -1 {
-			return localTransportError(errInternal)
-		}
-		if !bytes.Equal(s.remote[0].cid, p.originalDstConnID) {
-			return localTransportError(errTransportParameter)
-		}
-		// Remove the transient remote connection ID.
-		// We have no further need for it.
-		s.remote = append(s.remote[:0], s.remote[1:]...)
-	case serverSide:
-		if p.originalDstConnID != nil {
-			// Clients do not send original_destination_connection_id.
-			return localTransportError(errTransportParameter)
-		}
+	// Verify original_destination_connection_id matches
+	// the transient remote connection ID we chose (client)
+	// or is empty (server).
+	if !bytes.Equal(s.originalDstConnID, p.originalDstConnID) {
+		return localTransportError(errTransportParameter)
 	}
+	s.originalDstConnID = nil // we have no further need for this
+	// Verify retry_source_connection_id matches the value from
+	// the server's Retry packet (when one was sent), or is empty.
+	if !bytes.Equal(p.retrySrcConnID, s.retrySrcConnID) {
+		return localTransportError(errTransportParameter)
+	}
+	s.retrySrcConnID = nil // we have no further need for this
 	// Verify initial_source_connection_id matches the first remote connection ID.
 	if len(s.remote) == 0 || s.remote[0].seq != 0 {
 		return localTransportError(errInternal)
@@ -203,13 +201,10 @@
 			// We're a client connection processing the first Initial packet
 			// from the server. Replace the transient remote connection ID
 			// with the Source Connection ID from the packet.
-			// Leave the transient ID the list for now, since we'll need it when
-			// processing the transport parameters.
-			s.remote[0].retired = true
-			s.remote = append(s.remote, connID{
+			s.remote[0] = connID{
 				seq: 0,
 				cid: cloneBytes(srcConnID),
-			})
+			}
 		}
 	case ptype == packetTypeInitial && c.side == serverSide:
 		if len(s.remote) == 0 {
@@ -232,6 +227,14 @@
 	}
 }
 
+func (s *connIDState) handleRetryPacket(srcConnID []byte) {
+	if len(s.remote) != 1 || s.remote[0].seq != -1 {
+		panic("BUG: handling retry with non-transient remote conn id")
+	}
+	s.retrySrcConnID = cloneBytes(srcConnID)
+	s.remote[0].cid = s.retrySrcConnID
+}
+
 func (s *connIDState) handleNewConnID(seq, retire int64, cid []byte, resetToken [16]byte) error {
 	if len(s.remote[0].cid) == 0 {
 		// "An endpoint that is sending packets with a zero-length
diff --git a/internal/quic/conn_id_test.go b/internal/quic/conn_id_test.go
index 44755ec..784c5e2 100644
--- a/internal/quic/conn_id_test.go
+++ b/internal/quic/conn_id_test.go
@@ -48,9 +48,6 @@
 		t.Errorf("local ids: %v, want %v", fmtConnIDList(got), fmtConnIDList(wantLocal))
 	}
 	wantRemote := []connID{{
-		cid: testLocalConnID(-1),
-		seq: -1,
-	}, {
 		cid: testPeerConnID(0),
 		seq: 0,
 	}}
diff --git a/internal/quic/conn_recv.go b/internal/quic/conn_recv.go
index 9b1ba1a..e789ae0 100644
--- a/internal/quic/conn_recv.go
+++ b/internal/quic/conn_recv.go
@@ -34,6 +34,9 @@
 			n = c.handleLongHeader(now, ptype, handshakeSpace, c.keysHandshake.r, buf)
 		case packetType1RTT:
 			n = c.handle1RTT(now, buf)
+		case packetTypeRetry:
+			c.handleRetry(now, buf)
+			return
 		case packetTypeVersionNegotiation:
 			c.handleVersionNegotiation(now, buf)
 			return
@@ -128,6 +131,42 @@
 	return len(buf)
 }
 
+func (c *Conn) handleRetry(now time.Time, pkt []byte) {
+	if c.side != clientSide {
+		return // clients don't send Retry packets
+	}
+	// "After the client has received and processed an Initial or Retry packet
+	// from the server, it MUST discard any subsequent Retry packets that it receives."
+	// https://www.rfc-editor.org/rfc/rfc9000#section-17.2.5.2-1
+	if !c.keysInitial.canRead() {
+		return // discarded Initial keys, connection is already established
+	}
+	if c.acks[initialSpace].seen.numRanges() != 0 {
+		return // processed at least one packet
+	}
+	if c.retryToken != nil {
+		return // received a Retry already
+	}
+	// "Clients MUST discard Retry packets that have a Retry Integrity Tag
+	// that cannot be validated."
+	// https://www.rfc-editor.org/rfc/rfc9000#section-17.2.5.2-2
+	p, ok := parseRetryPacket(pkt, c.connIDState.originalDstConnID)
+	if !ok {
+		return
+	}
+	// "A client MUST discard a Retry packet with a zero-length Retry Token field."
+	// https://www.rfc-editor.org/rfc/rfc9000#section-17.2.5.2-2
+	if len(p.token) == 0 {
+		return
+	}
+	c.retryToken = cloneBytes(p.token)
+	c.connIDState.handleRetryPacket(p.srcConnID)
+	// We need to resend any data we've already sent in Initial packets.
+	// We must not reuse already sent packet numbers.
+	c.loss.discardPackets(initialSpace, c.handleAckOrLoss)
+	// TODO: Discard 0-RTT packets as well, once we support 0-RTT.
+}
+
 var errVersionNegotiation = errors.New("server does not support QUIC version 1")
 
 func (c *Conn) handleVersionNegotiation(now time.Time, pkt []byte) {
diff --git a/internal/quic/conn_send.go b/internal/quic/conn_send.go
index 00b02c2..efeb04f 100644
--- a/internal/quic/conn_send.go
+++ b/internal/quic/conn_send.go
@@ -68,6 +68,7 @@
 				num:       pnum,
 				dstConnID: dstConnID,
 				srcConnID: c.connIDState.srcConnID(),
+				extra:     c.retryToken,
 			}
 			c.w.startProtectedLongHeaderPacket(pnumMaxAcked, p)
 			c.appendFrames(now, initialSpace, pnum, limit)
diff --git a/internal/quic/conn_test.go b/internal/quic/conn_test.go
index ea47b0b..cfb0d06 100644
--- a/internal/quic/conn_test.go
+++ b/internal/quic/conn_test.go
@@ -218,6 +218,7 @@
 		listener.now,
 		side,
 		initialConnID,
+		nil,
 		netip.MustParseAddrPort("127.0.0.1:443"))
 	if err != nil {
 		t.Fatal(err)
@@ -545,7 +546,13 @@
 		if d == nil {
 			return nil
 		}
-		tc.sentPackets = d.packets
+		for _, p := range d.packets {
+			if len(p.frames) == 0 {
+				tc.lastPacket = p
+				continue
+			}
+			tc.sentPackets = append(tc.sentPackets, p)
+		}
 	}
 	p := tc.sentPackets[0]
 	tc.sentPackets = tc.sentPackets[1:]
@@ -638,6 +645,12 @@
 	w.reset(1200)
 	var pnumMaxAcked packetNumber
 	switch p.ptype {
+	case packetTypeRetry:
+		return encodeRetryPacket(p.originalDstConnID, retryPacket{
+			srcConnID: p.srcConnID,
+			dstConnID: p.dstConnID,
+			token:     p.token,
+		})
 	case packetType1RTT:
 		w.start1RTTPacket(p.num, pnumMaxAcked, p.dstConnID)
 	default:
@@ -717,6 +730,19 @@
 		}
 		ptype := getPacketType(buf)
 		switch ptype {
+		case packetTypeRetry:
+			retry, ok := parseRetryPacket(buf, tl.lastInitialDstConnID)
+			if !ok {
+				t.Fatalf("could not parse %v packet", ptype)
+			}
+			return &testDatagram{
+				packets: []*testPacket{{
+					ptype:     packetTypeRetry,
+					dstConnID: retry.dstConnID,
+					srcConnID: retry.srcConnID,
+					token:     retry.token,
+				}},
+			}
 		case packetTypeInitial, packetTypeHandshake:
 			var k fixedKeys
 			if tc == nil {
diff --git a/internal/quic/listener.go b/internal/quic/listener.go
index 9f14b09..aa25839 100644
--- a/internal/quic/listener.go
+++ b/internal/quic/listener.go
@@ -24,6 +24,7 @@
 	config    *Config
 	udpConn   udpConn
 	testHooks listenerTestHooks
+	retry     retryState
 
 	acceptQueue queue[*Conn] // new inbound connections
 
@@ -74,10 +75,10 @@
 	if err != nil {
 		return nil, err
 	}
-	return newListener(udpConn, config, nil), nil
+	return newListener(udpConn, config, nil)
 }
 
-func newListener(udpConn udpConn, config *Config, hooks listenerTestHooks) *Listener {
+func newListener(udpConn udpConn, config *Config, hooks listenerTestHooks) (*Listener, error) {
 	l := &Listener{
 		config:      config,
 		udpConn:     udpConn,
@@ -86,8 +87,13 @@
 		acceptQueue: newQueue[*Conn](),
 		closec:      make(chan struct{}),
 	}
+	if config.RequireAddressValidation {
+		if err := l.retry.init(); err != nil {
+			return nil, err
+		}
+	}
 	go l.listen()
-	return l
+	return l, nil
 }
 
 // LocalAddr returns the local network address.
@@ -142,7 +148,7 @@
 	}
 	addr := u.AddrPort()
 	addr = netip.AddrPortFrom(addr.Addr().Unmap(), addr.Port())
-	c, err := l.newConn(time.Now(), clientSide, nil, addr)
+	c, err := l.newConn(time.Now(), clientSide, nil, nil, addr)
 	if err != nil {
 		return nil, err
 	}
@@ -153,13 +159,13 @@
 	return c, nil
 }
 
-func (l *Listener) newConn(now time.Time, side connSide, initialConnID []byte, peerAddr netip.AddrPort) (*Conn, error) {
+func (l *Listener) newConn(now time.Time, side connSide, originalDstConnID, retrySrcConnID []byte, peerAddr netip.AddrPort) (*Conn, error) {
 	l.connsMu.Lock()
 	defer l.connsMu.Unlock()
 	if l.closing {
 		return nil, errors.New("listener closed")
 	}
-	c, err := newConn(now, side, initialConnID, peerAddr, l.config, l)
+	c, err := newConn(now, side, originalDstConnID, retrySrcConnID, peerAddr, l.config, l)
 	if err != nil {
 		return nil, err
 	}
@@ -300,8 +306,19 @@
 	} else {
 		now = time.Now()
 	}
+	var originalDstConnID, retrySrcConnID []byte
+	if l.config.RequireAddressValidation {
+		var ok bool
+		retrySrcConnID = p.dstConnID
+		originalDstConnID, ok = l.validateInitialAddress(now, p, m.addr)
+		if !ok {
+			return
+		}
+	} else {
+		originalDstConnID = p.dstConnID
+	}
 	var err error
-	c, err := l.newConn(now, serverSide, p.dstConnID, m.addr)
+	c, err := l.newConn(now, serverSide, originalDstConnID, retrySrcConnID, m.addr)
 	if err != nil {
 		// The accept queue is probably full.
 		// We could send a CONNECTION_CLOSE to the peer to reject the connection.
@@ -320,6 +337,28 @@
 	m.recycle()
 }
 
+func (l *Listener) sendConnectionClose(in genericLongPacket, addr netip.AddrPort, code transportError) {
+	keys := initialKeys(in.dstConnID, serverSide)
+	var w packetWriter
+	p := longPacket{
+		ptype:     packetTypeInitial,
+		version:   quicVersion1,
+		num:       0,
+		dstConnID: in.srcConnID,
+		srcConnID: in.dstConnID,
+	}
+	const pnumMaxAcked = 0
+	w.reset(minimumClientInitialDatagramSize)
+	w.startProtectedLongHeaderPacket(pnumMaxAcked, p)
+	w.appendConnectionCloseTransportFrame(code, 0, "")
+	w.finishProtectedLongHeaderPacket(pnumMaxAcked, keys.w, p)
+	buf := w.datagram()
+	if len(buf) == 0 {
+		return
+	}
+	l.sendDatagram(buf, addr)
+}
+
 func (l *Listener) sendDatagram(p []byte, addr netip.AddrPort) error {
 	_, err := l.udpConn.WriteToUDPAddrPort(p, addr)
 	return err
diff --git a/internal/quic/listener_test.go b/internal/quic/listener_test.go
index 77362aa..346f81c 100644
--- a/internal/quic/listener_test.go
+++ b/internal/quic/listener_test.go
@@ -114,7 +114,11 @@
 		idlec: make(chan struct{}),
 		conns: make(map[*Conn]*testConn),
 	}
-	tl.l = newListener((*testListenerUDPConn)(tl), config, (*testListenerHooks)(tl))
+	var err error
+	tl.l, err = newListener((*testListenerUDPConn)(tl), config, (*testListenerHooks)(tl))
+	if err != nil {
+		t.Fatal(err)
+	}
 	t.Cleanup(tl.cleanup)
 	return tl
 }
@@ -237,6 +241,13 @@
 	}
 }
 
+// wantIdle indicates that we expect the Listener to not send any more datagrams.
+func (tl *testListener) wantIdle(expectation string) {
+	if got := tl.readDatagram(); got != nil {
+		tl.t.Fatalf("expect: %v\nunexpectedly got: %v", expectation, got)
+	}
+}
+
 func (tl *testListener) newClientTLS(srcConnID, dstConnID []byte) []byte {
 	peerProvidedParams := defaultTransportParameters()
 	peerProvidedParams.initialSrcConnID = srcConnID
diff --git a/internal/quic/loss.go b/internal/quic/loss.go
index 152815a..c0f915b 100644
--- a/internal/quic/loss.go
+++ b/internal/quic/loss.go
@@ -281,6 +281,19 @@
 	c.cc.packetBatchEnd(now, space, &c.rtt, c.maxAckDelay)
 }
 
+// discardPackets declares that packets within a number space will not be delivered
+// and that data contained in them should be resent.
+// For example, after receiving a Retry packet we discard already-sent Initial packets.
+func (c *lossState) discardPackets(space numberSpace, lossf func(numberSpace, *sentPacket, packetFate)) {
+	for i := 0; i < c.spaces[space].size; i++ {
+		sent := c.spaces[space].nth(i)
+		sent.lost = true
+		c.cc.packetDiscarded(sent)
+		lossf(numberSpace(space), sent, packetLost)
+	}
+	c.spaces[space].clean()
+}
+
 // discardKeys is called when dropping packet protection keys for a number space.
 func (c *lossState) discardKeys(now time.Time, space numberSpace) {
 	// https://www.rfc-editor.org/rfc/rfc9002.html#section-6.4
diff --git a/internal/quic/packet.go b/internal/quic/packet.go
index 7d69f96..df589cc 100644
--- a/internal/quic/packet.go
+++ b/internal/quic/packet.go
@@ -97,6 +97,9 @@
 	streamFinBit = 0x01
 )
 
+// Maximum length of a connection ID.
+const maxConnIDLen = 20
+
 // isLongHeader returns true if b is the first byte of a long header.
 func isLongHeader(b byte) bool {
 	return b&headerFormLong == headerFormLong
diff --git a/internal/quic/packet_parser.go b/internal/quic/packet_parser.go
index ce04339..8bcd866 100644
--- a/internal/quic/packet_parser.go
+++ b/internal/quic/packet_parser.go
@@ -47,7 +47,7 @@
 	// Destination Connection ID Length (8),
 	// Destination Connection ID (0..160),
 	p.dstConnID, n = consumeUint8Bytes(b)
-	if n < 0 || len(p.dstConnID) > 20 {
+	if n < 0 || len(p.dstConnID) > maxConnIDLen {
 		return longPacket{}, -1
 	}
 	b = b[n:]
@@ -55,7 +55,7 @@
 	// Source Connection ID Length (8),
 	// Source Connection ID (0..160),
 	p.srcConnID, n = consumeUint8Bytes(b)
-	if n < 0 || len(p.dstConnID) > 20 {
+	if n < 0 || len(p.dstConnID) > maxConnIDLen {
 		return longPacket{}, -1
 	}
 	b = b[n:]
diff --git a/internal/quic/retry.go b/internal/quic/retry.go
new file mode 100644
index 0000000..e3d9f4d
--- /dev/null
+++ b/internal/quic/retry.go
@@ -0,0 +1,235 @@
+// Copyright 2023 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+//go:build go1.21
+
+package quic
+
+import (
+	"bytes"
+	"crypto/aes"
+	"crypto/cipher"
+	"crypto/rand"
+	"encoding/binary"
+	"net/netip"
+	"time"
+
+	"golang.org/x/crypto/chacha20poly1305"
+)
+
+// AEAD and nonce used to compute the Retry Integrity Tag.
+// https://www.rfc-editor.org/rfc/rfc9001#section-5.8
+var (
+	retrySecret = []byte{0xbe, 0x0c, 0x69, 0x0b, 0x9f, 0x66, 0x57, 0x5a, 0x1d, 0x76, 0x6b, 0x54, 0xe3, 0x68, 0xc8, 0x4e}
+	retryNonce  = []byte{0x46, 0x15, 0x99, 0xd3, 0x5d, 0x63, 0x2b, 0xf2, 0x23, 0x98, 0x25, 0xbb}
+	retryAEAD   = func() cipher.AEAD {
+		c, err := aes.NewCipher(retrySecret)
+		if err != nil {
+			panic(err)
+		}
+		aead, err := cipher.NewGCM(c)
+		if err != nil {
+			panic(err)
+		}
+		return aead
+	}()
+)
+
+// retryTokenValidityPeriod is how long we accept a Retry packet token after sending it.
+const retryTokenValidityPeriod = 5 * time.Second
+
+// retryState generates and validates a listener's retry tokens.
+type retryState struct {
+	aead cipher.AEAD
+}
+
+func (rs *retryState) init() error {
+	// Retry tokens are authenticated using a per-server key chosen at start time.
+	// TODO: Provide a way for the user to set this key.
+	secret := make([]byte, chacha20poly1305.KeySize)
+	if _, err := rand.Read(secret); err != nil {
+		return err
+	}
+	aead, err := chacha20poly1305.NewX(secret)
+	if err != nil {
+		panic(err)
+	}
+	rs.aead = aead
+	return nil
+}
+
+// Retry tokens are encrypted with an AEAD.
+// The plaintext contains the time the token was created and
+// the original destination connection ID.
+// The additional data contains the sender's source address and original source connection ID.
+// The token nonce is randomly generated.
+// We use the nonce as the Source Connection ID of the Retry packet.
+// Since the 24-byte XChaCha20-Poly1305 nonce is too large to fit in a 20-byte connection ID,
+// we include the remaining 4 bytes of nonce in the token.
+//
+// Token {
+//   Last 4 Bytes of Nonce (32),
+//   Ciphertext (..),
+// }
+//
+// Plaintext {
+//   Timestamp (64),
+//   Original Destination Connection ID,
+// }
+//
+//
+// Additional Data {
+//   Original Source Connection ID Length (8),
+//   Original Source Connection ID (..),
+//   IP Address (32..128),
+//   Port (16),
+// }
+//
+// TODO: Consider using AES-256-GCM-SIV once crypto/tls supports it.
+
+func (rs *retryState) makeToken(now time.Time, srcConnID, origDstConnID []byte, addr netip.AddrPort) (token, newDstConnID []byte, err error) {
+	nonce := make([]byte, rs.aead.NonceSize())
+	if _, err := rand.Read(nonce); err != nil {
+		return nil, nil, err
+	}
+
+	var plaintext []byte
+	plaintext = binary.BigEndian.AppendUint64(plaintext, uint64(now.Unix()))
+	plaintext = append(plaintext, origDstConnID...)
+
+	token = append(token, nonce[maxConnIDLen:]...)
+	token = rs.aead.Seal(token, nonce, plaintext, rs.additionalData(srcConnID, addr))
+	return token, nonce[:maxConnIDLen], nil
+}
+
+func (rs *retryState) validateToken(now time.Time, token, srcConnID, dstConnID []byte, addr netip.AddrPort) (origDstConnID []byte, ok bool) {
+	tokenNonceLen := rs.aead.NonceSize() - maxConnIDLen
+	if len(token) < tokenNonceLen {
+		return nil, false
+	}
+	nonce := append([]byte{}, dstConnID...)
+	nonce = append(nonce, token[:tokenNonceLen]...)
+	ciphertext := token[tokenNonceLen:]
+
+	plaintext, err := rs.aead.Open(nil, nonce, ciphertext, rs.additionalData(srcConnID, addr))
+	if err != nil {
+		return nil, false
+	}
+	if len(plaintext) < 8 {
+		return nil, false
+	}
+	when := time.Unix(int64(binary.BigEndian.Uint64(plaintext)), 0)
+	origDstConnID = plaintext[8:]
+
+	// We allow for tokens created in the future (up to the validity period),
+	// which likely indicates that the system clock was adjusted backwards.
+	if d := abs(now.Sub(when)); d > retryTokenValidityPeriod {
+		return nil, false
+	}
+
+	return origDstConnID, true
+}
+
+func (rs *retryState) additionalData(srcConnID []byte, addr netip.AddrPort) []byte {
+	var additional []byte
+	additional = appendUint8Bytes(additional, srcConnID)
+	additional = append(additional, addr.Addr().AsSlice()...)
+	additional = binary.BigEndian.AppendUint16(additional, addr.Port())
+	return additional
+}
+
+func (l *Listener) validateInitialAddress(now time.Time, p genericLongPacket, addr netip.AddrPort) (origDstConnID []byte, ok bool) {
+	// The retry token is at the start of an Initial packet's data.
+	token, n := consumeUint8Bytes(p.data)
+	if n < 0 {
+		// We've already validated that the packet is at least 1200 bytes long,
+		// so there's no way for even a maximum size token to not fit.
+		// Check anyway.
+		return nil, false
+	}
+	if len(token) == 0 {
+		// The sender has not provided a token.
+		// Send a Retry packet to them with one.
+		l.sendRetry(now, p, addr)
+		return nil, false
+	}
+	origDstConnID, ok = l.retry.validateToken(now, token, p.srcConnID, p.dstConnID, addr)
+	if !ok {
+		// This does not seem to be a valid token.
+		// Close the connection with an INVALID_TOKEN error.
+		// https://www.rfc-editor.org/rfc/rfc9000#section-8.1.2-5
+		l.sendConnectionClose(p, addr, errInvalidToken)
+		return nil, false
+	}
+	return origDstConnID, true
+}
+
+func (l *Listener) sendRetry(now time.Time, p genericLongPacket, addr netip.AddrPort) {
+	token, srcConnID, err := l.retry.makeToken(now, p.srcConnID, p.dstConnID, addr)
+	if err != nil {
+		return
+	}
+	b := encodeRetryPacket(p.dstConnID, retryPacket{
+		dstConnID: p.srcConnID,
+		srcConnID: srcConnID,
+		token:     token,
+	})
+	l.sendDatagram(b, addr)
+}
+
+type retryPacket struct {
+	dstConnID []byte
+	srcConnID []byte
+	token     []byte
+}
+
+func encodeRetryPacket(originalDstConnID []byte, p retryPacket) []byte {
+	// Retry packets include an integrity tag, computed by AEAD_AES_128_GCM over
+	// the original destination connection ID followed by the Retry packet
+	// (less the integrity tag itself).
+	// https://www.rfc-editor.org/rfc/rfc9001#section-5.8
+	//
+	// Create the pseudo-packet (including the original DCID), append the tag,
+	// and return the Retry packet.
+	var b []byte
+	b = appendUint8Bytes(b, originalDstConnID) // Original Destination Connection ID
+	start := len(b)                            // start of the Retry packet
+	b = append(b, headerFormLong|fixedBit|longPacketTypeRetry)
+	b = binary.BigEndian.AppendUint32(b, quicVersion1) // Version
+	b = appendUint8Bytes(b, p.dstConnID)               // Destination Connection ID
+	b = appendUint8Bytes(b, p.srcConnID)               // Source Connection ID
+	b = append(b, p.token...)                          // Token
+	b = retryAEAD.Seal(b, retryNonce, nil, b)          // Retry Integrity Tag
+	return b[start:]
+}
+
+func parseRetryPacket(b, origDstConnID []byte) (p retryPacket, ok bool) {
+	const retryIntegrityTagLength = 128 / 8
+
+	lp, ok := parseGenericLongHeaderPacket(b)
+	if !ok {
+		return retryPacket{}, false
+	}
+	if len(lp.data) < retryIntegrityTagLength {
+		return retryPacket{}, false
+	}
+	gotTag := lp.data[len(lp.data)-retryIntegrityTagLength:]
+
+	// Create the pseudo-packet consisting of the original destination connection ID
+	// followed by the Retry packet (less the integrity tag).
+	// Use this to validate the packet integrity tag.
+	pseudo := appendUint8Bytes(nil, origDstConnID)
+	pseudo = append(pseudo, b[:len(b)-retryIntegrityTagLength]...)
+	wantTag := retryAEAD.Seal(nil, retryNonce, nil, pseudo)
+	if !bytes.Equal(gotTag, wantTag) {
+		return retryPacket{}, false
+	}
+
+	token := lp.data[:len(lp.data)-retryIntegrityTagLength]
+	return retryPacket{
+		dstConnID: lp.dstConnID,
+		srcConnID: lp.srcConnID,
+		token:     token,
+	}, true
+}
diff --git a/internal/quic/retry_test.go b/internal/quic/retry_test.go
new file mode 100644
index 0000000..f754270
--- /dev/null
+++ b/internal/quic/retry_test.go
@@ -0,0 +1,568 @@
+// Copyright 2023 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+//go:build go1.21
+
+package quic
+
+import (
+	"bytes"
+	"context"
+	"crypto/tls"
+	"net/netip"
+	"testing"
+	"time"
+)
+
+type retryServerTest struct {
+	tl                *testListener
+	originalSrcConnID []byte
+	originalDstConnID []byte
+	retry             retryPacket
+	initialCrypto     []byte
+}
+
+// newRetryServerTest creates a test server connection,
+// sends the connection an Initial packet,
+// and expects a Retry in response.
+func newRetryServerTest(t *testing.T) *retryServerTest {
+	t.Helper()
+	config := &Config{
+		TLSConfig:                newTestTLSConfig(serverSide),
+		RequireAddressValidation: true,
+	}
+	tl := newTestListener(t, config)
+	srcID := testPeerConnID(0)
+	dstID := testLocalConnID(-1)
+	params := defaultTransportParameters()
+	params.initialSrcConnID = srcID
+	initialCrypto := initialClientCrypto(t, tl, params)
+
+	// Initial packet with no Token.
+	// Server responds with a Retry containing a token.
+	tl.writeDatagram(&testDatagram{
+		packets: []*testPacket{{
+			ptype:     packetTypeInitial,
+			num:       0,
+			version:   quicVersion1,
+			srcConnID: srcID,
+			dstConnID: dstID,
+			frames: []debugFrame{
+				debugFrameCrypto{
+					data: initialCrypto,
+				},
+			},
+		}},
+		paddedSize: 1200,
+	})
+	got := tl.readDatagram()
+	if len(got.packets) != 1 || got.packets[0].ptype != packetTypeRetry {
+		t.Fatalf("got datagram: %v\nwant Retry", got)
+	}
+	p := got.packets[0]
+	if got, want := p.dstConnID, srcID; !bytes.Equal(got, want) {
+		t.Fatalf("Retry destination = {%x}, want {%x}", got, want)
+	}
+
+	return &retryServerTest{
+		tl:                tl,
+		originalSrcConnID: srcID,
+		originalDstConnID: dstID,
+		retry: retryPacket{
+			dstConnID: p.dstConnID,
+			srcConnID: p.srcConnID,
+			token:     p.token,
+		},
+		initialCrypto: initialCrypto,
+	}
+}
+
+func TestRetryServerSucceeds(t *testing.T) {
+	rt := newRetryServerTest(t)
+	tl := rt.tl
+	tl.advance(retryTokenValidityPeriod)
+	tl.writeDatagram(&testDatagram{
+		packets: []*testPacket{{
+			ptype:     packetTypeInitial,
+			num:       1,
+			version:   quicVersion1,
+			srcConnID: rt.originalSrcConnID,
+			dstConnID: rt.retry.srcConnID,
+			token:     rt.retry.token,
+			frames: []debugFrame{
+				debugFrameCrypto{
+					data: rt.initialCrypto,
+				},
+			},
+		}},
+		paddedSize: 1200,
+	})
+	tc := tl.accept()
+	initial := tc.readPacket()
+	if initial == nil || initial.ptype != packetTypeInitial {
+		t.Fatalf("got packet:\n%v\nwant: Initial", initial)
+	}
+	handshake := tc.readPacket()
+	if handshake == nil || handshake.ptype != packetTypeHandshake {
+		t.Fatalf("got packet:\n%v\nwant: Handshake", initial)
+	}
+	if got, want := tc.sentTransportParameters.retrySrcConnID, rt.retry.srcConnID; !bytes.Equal(got, want) {
+		t.Errorf("retry_source_connection_id = {%x}, want {%x}", got, want)
+	}
+	if got, want := tc.sentTransportParameters.initialSrcConnID, initial.srcConnID; !bytes.Equal(got, want) {
+		t.Errorf("initial_source_connection_id = {%x}, want {%x}", got, want)
+	}
+	if got, want := tc.sentTransportParameters.originalDstConnID, rt.originalDstConnID; !bytes.Equal(got, want) {
+		t.Errorf("original_destination_connection_id = {%x}, want {%x}", got, want)
+	}
+}
+
+func TestRetryServerTokenInvalid(t *testing.T) {
+	// "If a server receives a client Initial that contains an invalid Retry token [...]
+	// the server SHOULD immediately close [...] the connection with an
+	// INVALID_TOKEN error."
+	// https://www.rfc-editor.org/rfc/rfc9000#section-8.1.2-5
+	rt := newRetryServerTest(t)
+	tl := rt.tl
+	tl.writeDatagram(&testDatagram{
+		packets: []*testPacket{{
+			ptype:     packetTypeInitial,
+			num:       1,
+			version:   quicVersion1,
+			srcConnID: rt.originalSrcConnID,
+			dstConnID: rt.retry.srcConnID,
+			token:     append(rt.retry.token, 0),
+			frames: []debugFrame{
+				debugFrameCrypto{
+					data: rt.initialCrypto,
+				},
+			},
+		}},
+		paddedSize: 1200,
+	})
+	tl.wantDatagram("server closes connection after Initial with invalid Retry token",
+		initialConnectionCloseDatagram(
+			rt.retry.srcConnID,
+			rt.originalSrcConnID,
+			errInvalidToken))
+}
+
+func TestRetryServerTokenTooOld(t *testing.T) {
+	// "[...] a token SHOULD have an expiration time [...]"
+	// https://www.rfc-editor.org/rfc/rfc9000#section-8.1.3-3
+	rt := newRetryServerTest(t)
+	tl := rt.tl
+	tl.advance(retryTokenValidityPeriod + time.Second)
+	tl.writeDatagram(&testDatagram{
+		packets: []*testPacket{{
+			ptype:     packetTypeInitial,
+			num:       1,
+			version:   quicVersion1,
+			srcConnID: rt.originalSrcConnID,
+			dstConnID: rt.retry.srcConnID,
+			token:     rt.retry.token,
+			frames: []debugFrame{
+				debugFrameCrypto{
+					data: rt.initialCrypto,
+				},
+			},
+		}},
+		paddedSize: 1200,
+	})
+	tl.wantDatagram("server closes connection after Initial with expired token",
+		initialConnectionCloseDatagram(
+			rt.retry.srcConnID,
+			rt.originalSrcConnID,
+			errInvalidToken))
+}
+
+func TestRetryServerTokenWrongIP(t *testing.T) {
+	// "Tokens sent in Retry packets SHOULD include information that allows the server
+	// to verify that the source IP address and port in client packets remain constant."
+	// https://www.rfc-editor.org/rfc/rfc9000#section-8.1.4-3
+	rt := newRetryServerTest(t)
+	tl := rt.tl
+	tl.writeDatagram(&testDatagram{
+		packets: []*testPacket{{
+			ptype:     packetTypeInitial,
+			num:       1,
+			version:   quicVersion1,
+			srcConnID: rt.originalSrcConnID,
+			dstConnID: rt.retry.srcConnID,
+			token:     rt.retry.token,
+			frames: []debugFrame{
+				debugFrameCrypto{
+					data: rt.initialCrypto,
+				},
+			},
+		}},
+		paddedSize: 1200,
+		addr:       netip.MustParseAddrPort("10.0.0.2:8000"),
+	})
+	tl.wantDatagram("server closes connection after Initial from wrong address",
+		initialConnectionCloseDatagram(
+			rt.retry.srcConnID,
+			rt.originalSrcConnID,
+			errInvalidToken))
+}
+
+func TestRetryServerIgnoresRetry(t *testing.T) {
+	tc := newTestConn(t, serverSide)
+	tc.handshake()
+	tc.write(&testDatagram{
+		packets: []*testPacket{{
+			ptype:             packetTypeRetry,
+			originalDstConnID: testLocalConnID(-1),
+			srcConnID:         testPeerConnID(0),
+			dstConnID:         testLocalConnID(0),
+			token:             []byte{1, 2, 3, 4},
+		}},
+	})
+	// Send two packets, to trigger an immediate ACK.
+	tc.writeFrames(packetType1RTT, debugFramePing{})
+	tc.writeFrames(packetType1RTT, debugFramePing{})
+	tc.wantFrameType("server connection ignores spurious Retry packet",
+		packetType1RTT, debugFrameAck{})
+}
+
+func TestRetryClientSuccess(t *testing.T) {
+	// "This token MUST be repeated by the client in all Initial packets it sends
+	// for that connection after it receives the Retry packet."
+	// https://www.rfc-editor.org/rfc/rfc9000#section-8.1.2-1
+	tc := newTestConn(t, clientSide)
+	tc.wantFrame("client Initial CRYPTO data",
+		packetTypeInitial, debugFrameCrypto{
+			data: tc.cryptoDataOut[tls.QUICEncryptionLevelInitial],
+		})
+	newServerConnID := []byte("new_conn_id")
+	token := []byte("token")
+	tc.write(&testDatagram{
+		packets: []*testPacket{{
+			ptype:             packetTypeRetry,
+			originalDstConnID: testLocalConnID(-1),
+			srcConnID:         newServerConnID,
+			dstConnID:         testLocalConnID(0),
+			token:             token,
+		}},
+	})
+	tc.wantPacket("client sends a new Initial packet with a token",
+		&testPacket{
+			ptype:     packetTypeInitial,
+			num:       1,
+			version:   quicVersion1,
+			srcConnID: testLocalConnID(0),
+			dstConnID: newServerConnID,
+			token:     token,
+			frames: []debugFrame{
+				debugFrameCrypto{
+					data: tc.cryptoDataOut[tls.QUICEncryptionLevelInitial],
+				},
+			},
+		},
+	)
+	tc.advanceToTimer()
+	tc.wantPacket("after PTO client sends another Initial packet with a token",
+		&testPacket{
+			ptype:     packetTypeInitial,
+			num:       2,
+			version:   quicVersion1,
+			srcConnID: testLocalConnID(0),
+			dstConnID: newServerConnID,
+			token:     token,
+			frames: []debugFrame{
+				debugFrameCrypto{
+					data: tc.cryptoDataOut[tls.QUICEncryptionLevelInitial],
+				},
+			},
+		},
+	)
+}
+
+func TestRetryClientInvalidServerTransportParameters(t *testing.T) {
+	// Various permutations of missing or invalid values for transport parameters
+	// after a Retry.
+	// https://www.rfc-editor.org/rfc/rfc9000#section-7.3
+	initialSrcConnID := testPeerConnID(0)
+	originalDstConnID := testLocalConnID(-1)
+	retrySrcConnID := testPeerConnID(100)
+	for _, test := range []struct {
+		name string
+		f    func(*transportParameters)
+		ok   bool
+	}{{
+		name: "valid",
+		f:    func(p *transportParameters) {},
+		ok:   true,
+	}, {
+		name: "missing initial_source_connection_id",
+		f: func(p *transportParameters) {
+			p.initialSrcConnID = nil
+		},
+	}, {
+		name: "invalid initial_source_connection_id",
+		f: func(p *transportParameters) {
+			p.initialSrcConnID = []byte("invalid")
+		},
+	}, {
+		name: "missing original_destination_connection_id",
+		f: func(p *transportParameters) {
+			p.originalDstConnID = nil
+		},
+	}, {
+		name: "invalid original_destination_connection_id",
+		f: func(p *transportParameters) {
+			p.originalDstConnID = []byte("invalid")
+		},
+	}, {
+		name: "missing retry_source_connection_id",
+		f: func(p *transportParameters) {
+			p.retrySrcConnID = nil
+		},
+	}, {
+		name: "invalid retry_source_connection_id",
+		f: func(p *transportParameters) {
+			p.retrySrcConnID = []byte("invalid")
+		},
+	}} {
+		t.Run(test.name, func(t *testing.T) {
+			tc := newTestConn(t, clientSide,
+				func(p *transportParameters) {
+					p.initialSrcConnID = initialSrcConnID
+					p.originalDstConnID = originalDstConnID
+					p.retrySrcConnID = retrySrcConnID
+				},
+				test.f)
+			tc.ignoreFrame(frameTypeAck)
+			tc.wantFrameType("client Initial CRYPTO data",
+				packetTypeInitial, debugFrameCrypto{})
+			tc.write(&testDatagram{
+				packets: []*testPacket{{
+					ptype:             packetTypeRetry,
+					originalDstConnID: originalDstConnID,
+					srcConnID:         retrySrcConnID,
+					dstConnID:         testLocalConnID(0),
+					token:             []byte{1, 2, 3, 4},
+				}},
+			})
+			tc.wantFrameType("client resends Initial CRYPTO data",
+				packetTypeInitial, debugFrameCrypto{})
+			tc.writeFrames(packetTypeInitial,
+				debugFrameCrypto{
+					data: tc.cryptoDataIn[tls.QUICEncryptionLevelInitial],
+				})
+			tc.writeFrames(packetTypeHandshake,
+				debugFrameCrypto{
+					data: tc.cryptoDataIn[tls.QUICEncryptionLevelHandshake],
+				})
+			if test.ok {
+				tc.wantFrameType("valid params, client sends Handshake",
+					packetTypeHandshake, debugFrameCrypto{})
+			} else {
+				tc.wantFrame("invalid transport parameters",
+					packetTypeInitial, debugFrameConnectionCloseTransport{
+						code: errTransportParameter,
+					})
+			}
+		})
+	}
+}
+
+func TestRetryClientIgnoresRetryAfterReceivingPacket(t *testing.T) {
+	// "After the client has received and processed an Initial or Retry packet
+	// from the server, it MUST discard any subsequent Retry packets that it receives."
+	// https://www.rfc-editor.org/rfc/rfc9000#section-17.2.5.2-1
+	tc := newTestConn(t, clientSide)
+	tc.ignoreFrame(frameTypeAck)
+	tc.ignoreFrame(frameTypeNewConnectionID)
+	tc.wantFrameType("client Initial CRYPTO data",
+		packetTypeInitial, debugFrameCrypto{})
+	tc.writeFrames(packetTypeInitial,
+		debugFrameCrypto{
+			data: tc.cryptoDataIn[tls.QUICEncryptionLevelInitial],
+		})
+	retry := &testDatagram{
+		packets: []*testPacket{{
+			ptype:             packetTypeRetry,
+			originalDstConnID: testLocalConnID(-1),
+			srcConnID:         testPeerConnID(100),
+			dstConnID:         testLocalConnID(0),
+			token:             []byte{1, 2, 3, 4},
+		}},
+	}
+	tc.write(retry)
+	tc.wantIdle("client ignores Retry after receiving Initial packet")
+	tc.writeFrames(packetTypeHandshake,
+		debugFrameCrypto{
+			data: tc.cryptoDataIn[tls.QUICEncryptionLevelHandshake],
+		})
+	tc.wantFrameType("client Handshake CRYPTO data",
+		packetTypeHandshake, debugFrameCrypto{})
+	tc.write(retry)
+	tc.wantIdle("client ignores Retry after discarding Initial keys")
+}
+
+func TestRetryClientIgnoresRetryAfterReceivingRetry(t *testing.T) {
+	// "After the client has received and processed an Initial or Retry packet
+	// from the server, it MUST discard any subsequent Retry packets that it receives."
+	// https://www.rfc-editor.org/rfc/rfc9000#section-17.2.5.2-1
+	tc := newTestConn(t, clientSide)
+	tc.wantFrameType("client Initial CRYPTO data",
+		packetTypeInitial, debugFrameCrypto{})
+	retry := &testDatagram{
+		packets: []*testPacket{{
+			ptype:             packetTypeRetry,
+			originalDstConnID: testLocalConnID(-1),
+			srcConnID:         testPeerConnID(100),
+			dstConnID:         testLocalConnID(0),
+			token:             []byte{1, 2, 3, 4},
+		}},
+	}
+	tc.write(retry)
+	tc.wantFrameType("client resends Initial CRYPTO data",
+		packetTypeInitial, debugFrameCrypto{})
+	tc.write(retry)
+	tc.wantIdle("client ignores second Retry")
+}
+
+func TestRetryClientIgnoresRetryWithInvalidIntegrityTag(t *testing.T) {
+	tc := newTestConn(t, clientSide)
+	tc.wantFrameType("client Initial CRYPTO data",
+		packetTypeInitial, debugFrameCrypto{})
+	pkt := encodeRetryPacket(testLocalConnID(-1), retryPacket{
+		srcConnID: testPeerConnID(100),
+		dstConnID: testLocalConnID(0),
+		token:     []byte{1, 2, 3, 4},
+	})
+	pkt[len(pkt)-1] ^= 1 // invalidate the integrity tag
+	tc.listener.write(&datagram{
+		b:    pkt,
+		addr: testClientAddr,
+	})
+	tc.wantIdle("client ignores Retry with invalid integrity tag")
+}
+
+func TestRetryClientIgnoresRetryWithZeroLengthToken(t *testing.T) {
+	// "A client MUST discard a Retry packet with a zero-length Retry Token field."
+	// https://www.rfc-editor.org/rfc/rfc9000#section-17.2.5.2-2
+	tc := newTestConn(t, clientSide)
+	tc.wantFrameType("client Initial CRYPTO data",
+		packetTypeInitial, debugFrameCrypto{})
+	tc.write(&testDatagram{
+		packets: []*testPacket{{
+			ptype:             packetTypeRetry,
+			originalDstConnID: testLocalConnID(-1),
+			srcConnID:         testPeerConnID(100),
+			dstConnID:         testLocalConnID(0),
+			token:             []byte{},
+		}},
+	})
+	tc.wantIdle("client ignores Retry with zero-length token")
+}
+
+func TestRetryStateValidateInvalidToken(t *testing.T) {
+	// Test handling of tokens that may have a valid signature,
+	// but unexpected contents.
+	var rs retryState
+	if err := rs.init(); err != nil {
+		t.Fatal(err)
+	}
+	nonce := make([]byte, rs.aead.NonceSize())
+	now := time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC)
+	srcConnID := []byte{1, 2, 3, 4}
+	dstConnID := nonce[:20]
+	addr := testClientAddr
+
+	for _, test := range []struct {
+		name  string
+		token []byte
+	}{{
+		name:  "token too short",
+		token: []byte{1, 2, 3},
+	}, {
+		name: "token plaintext too short",
+		token: func() []byte {
+			plaintext := make([]byte, 7) // not enough bytes of content
+			token := append([]byte{}, nonce[20:]...)
+			return rs.aead.Seal(token, nonce, plaintext, rs.additionalData(srcConnID, addr))
+		}(),
+	}} {
+		t.Run(test.name, func(t *testing.T) {
+			if _, ok := rs.validateToken(now, test.token, srcConnID, dstConnID, addr); ok {
+				t.Errorf("validateToken succeeded, want failure")
+			}
+		})
+	}
+}
+
+func TestParseInvalidRetryPackets(t *testing.T) {
+	originalDstConnID := []byte{1, 2, 3, 4}
+	goodPkt := encodeRetryPacket(originalDstConnID, retryPacket{
+		dstConnID: []byte{1},
+		srcConnID: []byte{2},
+		token:     []byte{3},
+	})
+	for _, test := range []struct {
+		name string
+		pkt  []byte
+	}{{
+		name: "packet too short",
+		pkt:  goodPkt[:len(goodPkt)-4],
+	}, {
+		name: "packet header invalid",
+		pkt:  goodPkt[:5],
+	}, {
+		name: "integrity tag invalid",
+		pkt: func() []byte {
+			pkt := cloneBytes(goodPkt)
+			pkt[len(pkt)-1] ^= 1
+			return pkt
+		}(),
+	}} {
+		t.Run(test.name, func(t *testing.T) {
+			if _, ok := parseRetryPacket(test.pkt, originalDstConnID); ok {
+				t.Errorf("parseRetryPacket succeded, want failure")
+			}
+		})
+	}
+}
+
+func initialClientCrypto(t *testing.T, l *testListener, p transportParameters) []byte {
+	t.Helper()
+	config := &tls.QUICConfig{TLSConfig: newTestTLSConfig(clientSide)}
+	tlsClient := tls.QUICClient(config)
+	tlsClient.SetTransportParameters(marshalTransportParameters(p))
+	tlsClient.Start(context.Background())
+	//defer tlsClient.Close()
+	l.peerTLSConn = tlsClient
+	var data []byte
+	for {
+		e := tlsClient.NextEvent()
+		switch e.Kind {
+		case tls.QUICNoEvent:
+			return data
+		case tls.QUICWriteData:
+			if e.Level != tls.QUICEncryptionLevelInitial {
+				t.Fatal("initial data at unexpected level")
+			}
+			data = append(data, e.Data...)
+		}
+	}
+}
+
+func initialConnectionCloseDatagram(srcConnID, dstConnID []byte, code transportError) *testDatagram {
+	return &testDatagram{
+		packets: []*testPacket{{
+			ptype:     packetTypeInitial,
+			num:       0,
+			version:   quicVersion1,
+			srcConnID: srcConnID,
+			dstConnID: dstConnID,
+			frames: []debugFrame{
+				debugFrameConnectionCloseTransport{
+					code: code,
+				},
+			},
+		}},
+	}
+}