Skip to content

Commit

Permalink
quic: tls handshake
Browse files Browse the repository at this point in the history
Exchange TLS handshake data in CRYPTO frames.
Receive packet protection keys from the TLS layer.
Discard packet protection keys as the handshake progresses.

Send and receive HANDSHAKE_DONE frames (used by the server
to inform the client of the handshake completing).

Add a very minimal implementation of CONNECTION_CLOSE,
just enough to let us write tests that trigger immediate
close of connections.

For golang/go#58547

Change-Id: I77496ca65bd72977565733739d563eaa2bb7d8d3
Reviewed-on: https://go-review.googlesource.com/c/net/+/510915
Reviewed-by: Jonathan Amsterdam <jba@google.com>
TryBot-Result: Gopher Robot <gobot@golang.org>
Run-TryBot: Damien Neil <dneil@google.com>
Auto-Submit: Damien Neil <dneil@google.com>
  • Loading branch information
neild authored and gopherbot committed Jul 27, 2023
1 parent 5e678bb commit dd0aa33
Show file tree
Hide file tree
Showing 13 changed files with 1,105 additions and 52 deletions.
20 changes: 20 additions & 0 deletions internal/quic/config.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
// 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 (
"crypto/tls"
)

// A Config structure configures a QUIC endpoint.
// A Config must not be modified after it has been passed to a QUIC function.
// A Config may be reused; the quic package will also not modify it.
type Config struct {
// TLSConfig is the endpoint's TLS configuration.
// It must be non-nil and include at least one certificate or else set GetCertificate.
TLSConfig *tls.Config
}
75 changes: 70 additions & 5 deletions internal/quic/conn.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
package quic

import (
"crypto/tls"
"errors"
"fmt"
"net/netip"
Expand All @@ -19,6 +20,7 @@ import (
type Conn struct {
side connSide
listener connListener
config *Config
testHooks connTestHooks
peerAddr netip.AddrPort

Expand All @@ -29,14 +31,27 @@ type Conn struct {
w packetWriter
acks [numberSpaceCount]ackState // indexed by number space
connIDState connIDState
tlsState tlsState
loss lossState

// errForPeer is set when the connection is being closed.
errForPeer error
connCloseSent [numberSpaceCount]bool

// idleTimeout is the time at which the connection will be closed due to inactivity.
// https://www.rfc-editor.org/rfc/rfc9000#section-10.1
maxIdleTimeout time.Duration
idleTimeout time.Time

// Packet protection keys, CRYPTO streams, and TLS state.
rkeys [numberSpaceCount]keys
wkeys [numberSpaceCount]keys
crypto [numberSpaceCount]cryptoStream
tls *tls.QUICConn

// handshakeConfirmed is set when the handshake is confirmed.
// For server connections, it tracks sending HANDSHAKE_DONE.
handshakeConfirmed sentVal

peerAckDelayExponent int8 // -1 when unknown

// Tests only: Send a PING in a specific number space.
Expand All @@ -53,12 +68,14 @@ type connListener interface {
// connTestHooks override conn behavior in tests.
type connTestHooks interface {
nextMessage(msgc chan any, nextTimeout time.Time) (now time.Time, message any)
handleTLSEvent(tls.QUICEvent)
}

func newConn(now time.Time, side connSide, initialConnID []byte, peerAddr netip.AddrPort, l connListener, hooks connTestHooks) (*Conn, error) {
func newConn(now time.Time, side connSide, initialConnID []byte, peerAddr netip.AddrPort, config *Config, l connListener, hooks connTestHooks) (*Conn, error) {
c := &Conn{
side: side,
listener: l,
config: config,
peerAddr: peerAddr,
msgc: make(chan any, 1),
donec: make(chan struct{}),
Expand Down Expand Up @@ -88,12 +105,58 @@ func newConn(now time.Time, side connSide, initialConnID []byte, peerAddr netip.
const maxDatagramSize = 1200
c.loss.init(c.side, maxDatagramSize, now)

c.tlsState.init(c.side, initialConnID)
c.startTLS(now, initialConnID, transportParameters{
initialSrcConnID: c.connIDState.srcConnID(),
ackDelayExponent: ackDelayExponent,
maxUDPPayloadSize: maxUDPPayloadSize,
maxAckDelay: maxAckDelay,
})

go c.loop(now)
return c, nil
}

// confirmHandshake is called when the handshake is confirmed.
// https://www.rfc-editor.org/rfc/rfc9001#section-4.1.2
func (c *Conn) confirmHandshake(now time.Time) {
// If handshakeConfirmed is unset, the handshake is not confirmed.
// If it is unsent, the handshake is confirmed and we need to send a HANDSHAKE_DONE.
// If it is sent, we have sent a HANDSHAKE_DONE.
// If it is received, the handshake is confirmed and we do not need to send anything.
if c.handshakeConfirmed.isSet() {
return // already confirmed
}
if c.side == serverSide {
// When the server confirms the handshake, it sends a HANDSHAKE_DONE.
c.handshakeConfirmed.setUnsent()
} else {
// The client never sends a HANDSHAKE_DONE, so we set handshakeConfirmed
// to the received state, indicating that the handshake is confirmed and we
// don't need to send anything.
c.handshakeConfirmed.setReceived()
}
c.loss.confirmHandshake()
// "An endpoint MUST discard its Handshake keys when the TLS handshake is confirmed"
// https://www.rfc-editor.org/rfc/rfc9001#section-4.9.2-1
c.discardKeys(now, handshakeSpace)
}

// discardKeys discards unused packet protection keys.
// https://www.rfc-editor.org/rfc/rfc9001#section-4.9
func (c *Conn) discardKeys(now time.Time, space numberSpace) {
c.rkeys[space].discard()
c.wkeys[space].discard()
c.loss.discardKeys(now, space)
}

// receiveTransportParameters applies transport parameters sent by the peer.
func (c *Conn) receiveTransportParameters(p transportParameters) {
c.peerAckDelayExponent = p.ackDelayExponent
c.loss.setMaxAckDelay(p.maxAckDelay)

// TODO: Many more transport parameters to come.
}

type timerEvent struct{}

// loop is the connection main loop.
Expand All @@ -104,6 +167,7 @@ type timerEvent struct{}
// Other goroutines may examine or modify conn state by sending the loop funcs to execute.
func (c *Conn) loop(now time.Time) {
defer close(c.donec)
defer c.tls.Close()

// The connection timer sends a message to the connection loop on expiry.
// We need to give it an expiry when creating it, so set the initial timeout to
Expand Down Expand Up @@ -201,8 +265,9 @@ func (c *Conn) runOnLoop(f func(now time.Time, c *Conn)) error {

// abort terminates a connection with an error.
func (c *Conn) abort(now time.Time, err error) {
// TODO: Send CONNECTION_CLOSE frames.
c.exit()
if c.errForPeer == nil {
c.errForPeer = err
}
}

// exit fully terminates a connection immediately.
Expand Down
7 changes: 6 additions & 1 deletion internal/quic/conn_loss.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ func (c *Conn) handleAckOrLoss(space numberSpace, sent *sentPacket, fate packetF
for !sent.done() {
switch f := sent.next(); f {
default:
panic(fmt.Sprintf("BUG: unhandled lost frame type %x", f))
panic(fmt.Sprintf("BUG: unhandled acked/lost frame type %x", f))
case frameTypeAck:
// Unlike most information, loss of an ACK frame does not trigger
// retransmission. ACKs are sent in response to ack-eliciting packets,
Expand All @@ -41,6 +41,11 @@ func (c *Conn) handleAckOrLoss(space numberSpace, sent *sentPacket, fate packetF
if fate == packetAcked {
c.acks[space].handleAck(largest)
}
case frameTypeCrypto:
start, end := sent.nextRange()
c.crypto[space].ackOrLoss(start, end, fate)
case frameTypeHandshakeDone:
c.handshakeConfirmed.ackOrLoss(sent.num, fate)
}
}
}
143 changes: 143 additions & 0 deletions internal/quic/conn_loss_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
// 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 (
"crypto/tls"
"testing"
)

// Frames may be retransmitted either when the packet containing the frame is lost, or on PTO.
// lostFrameTest runs a test in both configurations.
func lostFrameTest(t *testing.T, f func(t *testing.T, pto bool)) {
t.Run("lost", func(t *testing.T) {
f(t, false)
})
t.Run("pto", func(t *testing.T) {
f(t, true)
})
}

// triggerLossOrPTO causes the conn to declare the last sent packet lost,
// or advances to the PTO timer.
func (tc *testConn) triggerLossOrPTO(ptype packetType, pto bool) {
tc.t.Helper()
if pto {
if !tc.conn.loss.ptoTimerArmed {
tc.t.Fatalf("PTO timer not armed, expected it to be")
}
tc.advanceTo(tc.conn.loss.timer)
return
}
defer func(ignoreFrames map[byte]bool) {
tc.ignoreFrames = ignoreFrames
}(tc.ignoreFrames)
tc.ignoreFrames = map[byte]bool{
frameTypeAck: true,
frameTypePadding: true,
}
// Send three packets containing PINGs, and then respond with an ACK for the
// last one. This puts the last packet before the PINGs outside the packet
// reordering threshold, and it will be declared lost.
const lossThreshold = 3
var num packetNumber
for i := 0; i < lossThreshold; i++ {
tc.conn.ping(spaceForPacketType(ptype))
d := tc.readDatagram()
if d == nil {
tc.t.Fatalf("conn is idle; want PING frame")
}
if d.packets[0].ptype != ptype {
tc.t.Fatalf("conn sent %v packet; want %v", d.packets[0].ptype, ptype)
}
num = d.packets[0].num
}
tc.writeFrames(ptype, debugFrameAck{
ranges: []i64range[packetNumber]{
{num, num + 1},
},
})
}

func TestLostCRYPTOFrame(t *testing.T) {
// "Data sent in CRYPTO frames is retransmitted [...] until all data has been acknowledged."
// https://www.rfc-editor.org/rfc/rfc9000.html#section-13.3-3.1
lostFrameTest(t, func(t *testing.T, pto bool) {
tc := newTestConn(t, clientSide)
tc.ignoreFrame(frameTypeAck)

tc.wantFrame("client sends Initial CRYPTO frame",
packetTypeInitial, debugFrameCrypto{
data: tc.cryptoDataOut[tls.QUICEncryptionLevelInitial],
})
tc.triggerLossOrPTO(packetTypeInitial, pto)
tc.wantFrame("client resends Initial CRYPTO frame",
packetTypeInitial, debugFrameCrypto{
data: tc.cryptoDataOut[tls.QUICEncryptionLevelInitial],
})

tc.writeFrames(packetTypeInitial,
debugFrameCrypto{
data: tc.cryptoDataIn[tls.QUICEncryptionLevelInitial],
})
tc.writeFrames(packetTypeHandshake,
debugFrameCrypto{
data: tc.cryptoDataIn[tls.QUICEncryptionLevelHandshake],
})

tc.wantFrame("client sends Handshake CRYPTO frame",
packetTypeHandshake, debugFrameCrypto{
data: tc.cryptoDataOut[tls.QUICEncryptionLevelHandshake],
})
tc.triggerLossOrPTO(packetTypeHandshake, pto)
tc.wantFrame("client resends Handshake CRYPTO frame",
packetTypeHandshake, debugFrameCrypto{
data: tc.cryptoDataOut[tls.QUICEncryptionLevelHandshake],
})
})
}

func TestLostHandshakeDoneFrame(t *testing.T) {
// "The HANDSHAKE_DONE frame MUST be retransmitted until it is acknowledged."
// https://www.rfc-editor.org/rfc/rfc9000.html#section-13.3-3.16
lostFrameTest(t, func(t *testing.T, pto bool) {
tc := newTestConn(t, serverSide)
tc.ignoreFrame(frameTypeAck)

tc.writeFrames(packetTypeInitial,
debugFrameCrypto{
data: tc.cryptoDataIn[tls.QUICEncryptionLevelInitial],
})
tc.wantFrame("server sends Initial CRYPTO frame",
packetTypeInitial, debugFrameCrypto{
data: tc.cryptoDataOut[tls.QUICEncryptionLevelInitial],
})
tc.wantFrame("server sends Handshake CRYPTO frame",
packetTypeHandshake, debugFrameCrypto{
data: tc.cryptoDataOut[tls.QUICEncryptionLevelHandshake],
})
tc.writeFrames(packetTypeHandshake,
debugFrameCrypto{
data: tc.cryptoDataIn[tls.QUICEncryptionLevelHandshake],
})

tc.wantFrame("server sends HANDSHAKE_DONE after handshake completes",
packetType1RTT, debugFrameHandshakeDone{})
tc.wantFrame("server sends session ticket in CRYPTO frame",
packetType1RTT, debugFrameCrypto{
data: tc.cryptoDataOut[tls.QUICEncryptionLevelApplication],
})

tc.triggerLossOrPTO(packetType1RTT, pto)
tc.wantFrame("server resends HANDSHAKE_DONE",
packetType1RTT, debugFrameHandshakeDone{})
tc.wantFrame("server resends session ticket",
packetType1RTT, debugFrameCrypto{
data: tc.cryptoDataOut[tls.QUICEncryptionLevelApplication],
})
})
}

0 comments on commit dd0aa33

Please sign in to comment.