Files
golang.net/quic/retry_test.go
Nicholas S. Husin 157f4d57d9 quic: prevent server panic when handling Retry packets with short connection ID
Per RFC 9000, a Destination Connection ID in a Retry packet is of valid
length as long as it is 20 bytes or shorter. However, when validating
tokens for Retry packets, our server currently makes an assumption that
it will only deal with a Destination Connection ID that is exactly 20
bytes long. As a result, a misbehaving or malicious client can cause our
server to panic.

When our server validates a token for a Retry packet, the nonce is
partially constructed from the Destination Connection ID. Due to our
server's bad assumption, a shorter-than-expected Destination Connection
ID will result in cipher.AEAD.Open being given a nonce of bad length. We
currently use XChaCha20-Poly1305 from x/crypto for our AEAD
implementation, which will panic when given a nonce of bad length.

Fixes golang/go#78292

Change-Id: Ieb65d8b84bcb5c531bdb46d5c75b13dad9c76eb2
Reviewed-on: https://go-review.googlesource.com/c/net/+/758360
LUCI-TryBot-Result: Go LUCI <golang-scoped@luci-project-accounts.iam.gserviceaccount.com>
Reviewed-by: Nicholas Husin <husin@google.com>
Reviewed-by: Damien Neil <dneil@google.com>
2026-03-23 13:38:59 -07:00

631 lines
18 KiB
Go

// 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.
package quic
import (
"bytes"
"context"
"crypto/tls"
"net/netip"
"testing"
"testing/synctest"
"time"
)
type retryServerTest struct {
te *testEndpoint
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,
}
te := newTestEndpoint(t, config)
srcID := testPeerConnID(0)
dstID := testLocalConnID(-1)
params := defaultTransportParameters()
params.initialSrcConnID = srcID
initialCrypto := initialClientCrypto(t, te, params)
// Initial packet with no Token.
// Server responds with a Retry containing a token.
te.writeDatagram(&testDatagram{
packets: []*testPacket{{
ptype: packetTypeInitial,
num: 0,
version: quicVersion1,
srcConnID: srcID,
dstConnID: dstID,
frames: []debugFrame{
debugFrameCrypto{
data: initialCrypto,
},
},
}},
paddedSize: 1200,
})
got := te.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{
te: te,
originalSrcConnID: srcID,
originalDstConnID: dstID,
retry: retryPacket{
dstConnID: p.dstConnID,
srcConnID: p.srcConnID,
token: p.token,
},
initialCrypto: initialCrypto,
}
}
func TestRetryServerSucceeds(t *testing.T) {
synctest.Test(t, testRetryServerSucceeds)
}
func testRetryServerSucceeds(t *testing.T) {
rt := newRetryServerTest(t)
te := rt.te
time.Sleep(retryTokenValidityPeriod)
te.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 := te.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) {
synctest.Test(t, testRetryServerTokenInvalid)
}
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)
te := rt.te
te.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,
})
te.wantDatagram("server closes connection after Initial with invalid Retry token",
initialConnectionCloseDatagram(
rt.retry.srcConnID,
rt.originalSrcConnID,
errInvalidToken))
}
func TestRetryServerTokenTooOld(t *testing.T) {
synctest.Test(t, testRetryServerTokenTooOld)
}
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)
te := rt.te
time.Sleep(retryTokenValidityPeriod + time.Second)
te.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,
})
te.wantDatagram("server closes connection after Initial with expired token",
initialConnectionCloseDatagram(
rt.retry.srcConnID,
rt.originalSrcConnID,
errInvalidToken))
}
func TestRetryServerTokenWrongIP(t *testing.T) {
synctest.Test(t, testRetryServerTokenWrongIP)
}
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)
te := rt.te
te.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"),
})
te.wantDatagram("server closes connection after Initial from wrong address",
initialConnectionCloseDatagram(
rt.retry.srcConnID,
rt.originalSrcConnID,
errInvalidToken))
}
func TestRetryServerShortDstConnID(t *testing.T) {
synctest.Test(t, testRetryServerShortDstConnID)
}
func testRetryServerShortDstConnID(t *testing.T) {
// Verify that a shorter-than-expected Destination Connection ID does not
// cause a panic due to bad nonce length. https://go.dev/issue/78292.
rt := newRetryServerTest(t)
te := rt.te
te.writeDatagram(&testDatagram{
packets: []*testPacket{{
ptype: packetTypeInitial,
num: 1,
version: quicVersion1,
srcConnID: rt.originalSrcConnID,
dstConnID: []byte("short id"),
token: rt.retry.token,
frames: []debugFrame{
debugFrameCrypto{
data: rt.initialCrypto,
},
},
}},
paddedSize: 1200,
})
te.wantDatagram("server closes connection after Initial from wrong address",
initialConnectionCloseDatagram(
[]byte("short id"),
rt.originalSrcConnID,
errInvalidToken))
}
func TestRetryServerIgnoresRetry(t *testing.T) {
synctest.Test(t, testRetryServerIgnoresRetry)
}
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) {
synctest.Test(t, testRetryClientSuccess)
}
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")
},
}} {
synctestSubtest(t, 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) {
synctest.Test(t, testRetryClientIgnoresRetryAfterReceivingPacket)
}
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) {
synctest.Test(t, testRetryClientIgnoresRetryAfterReceivingRetry)
}
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) {
synctest.Test(t, testRetryClientIgnoresRetryWithInvalidIntegrityTag)
}
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.endpoint.write(&datagram{
b: pkt,
peerAddr: testClientAddr,
})
tc.wantIdle("client ignores Retry with invalid integrity tag")
}
func TestRetryClientIgnoresRetryWithZeroLengthToken(t *testing.T) {
synctest.Test(t, testRetryClientIgnoresRetryWithZeroLengthToken)
}
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 succeeded, want failure")
}
})
}
}
func initialClientCrypto(t *testing.T, e *testEndpoint, p transportParameters) []byte {
t.Helper()
config := &tls.QUICConfig{TLSConfig: newTestTLSConfig(clientSide)}
tlsClient := tls.QUICClient(config)
tlsClient.SetTransportParameters(marshalTransportParameters(p))
tlsClient.Start(context.Background())
t.Cleanup(func() {
tlsClient.Close()
})
e.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,
},
},
}},
}
}