diff --git a/13.go b/13.go index e12caa674d..e6c1655cf8 100644 --- a/13.go +++ b/13.go @@ -900,6 +900,12 @@ func (hs *clientHandshakeState) processEncryptedExtensions(ee *encryptedExtensio c.clientProtocol = ee.alpnProtocol c.clientProtocolFallback = false } + if len(hs.esniNonce) != 0 { + // ESNI was requested, it must be present with a valid nonce. + if subtle.ConstantTimeCompare(ee.esniNonce, hs.esniNonce) != 1 { + return c.sendAlert(alertIllegalParameter) + } + } return nil } diff --git a/_dev/.gitignore b/_dev/.gitignore index 23c0b93cd2..65bcef3b52 100644 --- a/_dev/.gitignore +++ b/_dev/.gitignore @@ -4,3 +4,4 @@ /tris-testclient/tris-testclient /caddy/caddy /caddy/echo +/esnitool/esnitool diff --git a/_dev/Makefile b/_dev/Makefile index 0e40cb1df2..be4fde1b8d 100644 --- a/_dev/Makefile +++ b/_dev/Makefile @@ -99,18 +99,28 @@ build-all: \ build-test-tris-client \ build-test-tris-server \ build-test-bogo \ + build-esnitool \ $(addprefix build-test-,$(TARGET_TEST_COMPAT)) # Builds TRIS client build-test-tris-client: $(BUILD_DIR)/$(OS_ARCH)/.ok_$(VER_OS_ARCH) - cd $(DEV_DIR)/tris-testclient; CGO_ENABLED=0 GOROOT="$(GOROOT_LOCAL)" $(GO) build -v -i . + cd $(DEV_DIR)/tris-testclient && \ + GOROOT="$(GOROOT_LOCAL)" $(GO) get -d -v && \ + GOROOT="$(GOROOT_LOCAL)" GOOS=linux CGO_ENABLED=0 $(GO) build -v -i . $(DOCKER) build -t tris-testclient $(DEV_DIR)/tris-testclient # Builds TRIS server build-test-tris-server: $(BUILD_DIR)/$(OS_ARCH)/.ok_$(VER_OS_ARCH) - cd $(DEV_DIR)/tris-localserver; CGO_ENABLED=0 GOROOT="$(GOROOT_LOCAL)" $(GO) build -v -i . + cd $(DEV_DIR)/tris-localserver && \ + GOROOT="$(GOROOT_LOCAL)" GOOS=linux CGO_ENABLED=0 $(GO) build -v -i . $(DOCKER) build -t tris-localserver $(DEV_DIR)/tris-localserver +# Builds esnitool for ESNI tests +build-esnitool: $(BUILD_DIR)/$(OS_ARCH)/.ok_$(VER_OS_ARCH) + cd $(DEV_DIR)/esnitool && \ + GOROOT="$(GOROOT_LOCAL)" $(GO) get -d -v && \ + GOROOT="$(GOROOT_LOCAL)" $(GO) build -v -i . + # BoringSSL specific stuff build-test-boring: BUILDARG=--build-arg REVISION=$(BORINGSSL_REVISION) diff --git a/_dev/esnitool/esnitool.go b/_dev/esnitool/esnitool.go new file mode 100644 index 0000000000..2fc64ef81d --- /dev/null +++ b/_dev/esnitool/esnitool.go @@ -0,0 +1,137 @@ +// Standalone utility to generate ESNI keys. +// Can be run independently of tris. +package main + +import ( + "crypto/rand" + "crypto/sha256" + "crypto/tls" + "encoding/base64" + "flag" + "fmt" + "io/ioutil" + "log" + "time" + + "golang.org/x/crypto/cryptobyte" + "golang.org/x/crypto/curve25519" +) + +// Internal definitions, copied from common.go and esni.go + +type keyShare struct { + group tls.CurveID + data []byte +} + +const esniKeysVersionDraft01 uint16 = 0xff01 + +func addUint64(b *cryptobyte.Builder, v uint64) { + b.AddUint32(uint32(v >> 32)) + b.AddUint32(uint32(v)) +} + +// ESNIKeys structure that is exposed through DNS. +type ESNIKeys struct { + version uint16 + checksum [4]uint8 + // (Draft -03 introduces "public_name" here) + keys []keyShare // 16-bit vector length + cipherSuites []uint16 // 16-bit vector length + paddedLength uint16 + notBefore uint64 + notAfter uint64 + extensions []byte // 16-bit vector length. No extensions are defined in draft -01 +} + +func (k *ESNIKeys) serialize() []byte { + var b cryptobyte.Builder + b.AddUint16(k.version) + b.AddBytes(k.checksum[:]) + b.AddUint16LengthPrefixed(func(b *cryptobyte.Builder) { + for _, ks := range k.keys { + b.AddUint16(uint16(ks.group)) + b.AddUint16LengthPrefixed(func(b *cryptobyte.Builder) { + b.AddBytes(ks.data) + }) + } + }) + b.AddUint16LengthPrefixed(func(b *cryptobyte.Builder) { + for _, cs := range k.cipherSuites { + b.AddUint16(cs) + } + }) + b.AddUint16(k.paddedLength) + addUint64(&b, k.notBefore) + addUint64(&b, k.notAfter) + // No extensions are defined in the initial draft. + b.AddUint16(0) + // Should always succeed as we use simple types only. + return b.BytesOrPanic() +} + +func generateX25519() ([]byte, keyShare) { + var scalar, public [32]byte + if _, err := rand.Read(scalar[:]); err != nil { + panic(err) + } + curve25519.ScalarBaseMult(&public, &scalar) + ks := keyShare{ + group: tls.X25519, + data: public[:], + } + return scalar[:], ks +} + +// Creates a new ESNIKeys structure with a new semi-static key share. +// Returns the private key and a new ESNIKeys structure. +func NewESNIKeys(validity time.Duration) ([]byte, *ESNIKeys) { + serverPrivate, serverKS := generateX25519() + notBefore := time.Now() + notAfter := notBefore.Add(validity) + k := &ESNIKeys{ + version: esniKeysVersionDraft01, + keys: []keyShare{serverKS}, + cipherSuites: []uint16{tls.TLS_AES_128_GCM_SHA256}, + // draft-ietf-tls-esni-01: "If the server supports wildcard names, it SHOULD set this value to 260." + paddedLength: 260, + notBefore: uint64(notBefore.Unix()), + notAfter: uint64(notAfter.Unix()), + } + data := k.serialize() + hash := sha256.New() + hash.Write(data[:2]) // version + hash.Write([]byte{0, 0, 0, 0}) + hash.Write(data[6:]) // fields after checksum + copy(k.checksum[:], hash.Sum(nil)[:4]) + return serverPrivate, k +} + +func main() { + var esniKeysFile, esniPrivateFile string + var validity time.Duration + flag.StringVar(&esniKeysFile, "esni-keys-file", "", "Write base64-encoded ESNI keys to file instead of stdout") + flag.StringVar(&esniPrivateFile, "esni-private-file", "", "Write ESNI private key to file instead of stdout") + flag.DurationVar(&validity, "validity", 24*time.Hour, "Validity period of the keys") + flag.Parse() + + serverPrivate, k := NewESNIKeys(validity) + esniBase64 := base64.StdEncoding.EncodeToString(k.serialize()) + if esniKeysFile == "" { + // draft -01 uses a TXT record instead of a dedicated RR. + fmt.Printf("_esni TXT record: %s\n", esniBase64) + } else { + err := ioutil.WriteFile(esniKeysFile, []byte(esniBase64+"\n"), 0644) + if err != nil { + log.Fatalf("Failed to write %s: %s", esniKeysFile, err) + } + } + if esniPrivateFile == "" { + fmt.Printf("ESNI private key: %x\n", serverPrivate) + } else { + err := ioutil.WriteFile(esniPrivateFile, serverPrivate, 0600) + if err != nil { + log.Fatalf("Failed to write %s: %s", esniPrivateFile, err) + } + } +} diff --git a/_dev/interop_test_runner.py b/_dev/interop_test_runner.py index 56c0f4a70d..bad6630846 100755 --- a/_dev/interop_test_runner.py +++ b/_dev/interop_test_runner.py @@ -1,9 +1,13 @@ #!/usr/bin/env python3 import docker -import unittest +import os import re +import subprocess +import sys +import tempfile import time +import unittest # Regex patterns used for testing @@ -34,17 +38,17 @@ def get_ip(self, server): tris_localserver_container = self.d.containers.get(server) return tris_localserver_container.attrs['NetworkSettings']['IPAddress'] - def run_client(self, image_name, cmd): + def run_client(self, image_name, cmd, volumes=None): ''' Runs client and returns tuple (status_code, logs) ''' - c = self.d.containers.run(image=image_name, detach=True, command=cmd) + c = self.d.containers.run(image=image_name, detach=True, command=cmd, volumes=volumes) res = c.wait() ret = c.logs().decode('utf8') c.remove() return (res['StatusCode'], ret) - def run_server(self, image_name, cmd=None, ports=None, entrypoint=None): + def run_server(self, image_name, cmd=None, ports=None, entrypoint=None, volumes=None): ''' Starts server and returns docker container ''' - c = self.d.containers.run(image=image_name, auto_remove=True, detach=True, command=cmd, ports=ports, entrypoint=entrypoint) + c = self.d.containers.run(image=image_name, auto_remove=True, detach=True, command=cmd, ports=ports, entrypoint=entrypoint, volumes=volumes) # TODO: maybe can be done better? time.sleep(3) return c @@ -97,8 +101,12 @@ class InteropServer(object): def setUpClass(self): self.d = Docker() try: - self.server = self.d.run_server(self.TRIS_SERVER_NAME) + self.tmpdir = self._create_tmpdir() + self._create_esni_keys() + self.server = self.d.run_server(self.TRIS_SERVER_NAME, volumes=self.data_volumes()) except: + if hasattr(self, 'tmpdir'): + self.tmpdir.cleanup() self.d.close() raise @@ -106,6 +114,28 @@ def setUpClass(self): def tearDownClass(self): self.server.kill() self.d.close() + self.tmpdir.cleanup() + + @classmethod + def _create_tmpdir(cls): + basedir = None + if sys.platform == 'darwin': + # Workaround for TMPDIR=/var/folders/.../T/ which is not permitted + # by the default Docker configuration. + basedir = '/tmp' + return tempfile.TemporaryDirectory(dir=basedir) + + @classmethod + def _create_esni_keys(cls): + # Create fresh ESNIKeys that has not expired yet. + esni_priv = os.path.join(cls.tmpdir.name, 'esni.key') + esni_pub = os.path.join(cls.tmpdir.name, 'esni.pub') + esnitool = os.path.join(os.path.dirname(__file__), 'esnitool', 'esnitool') + subprocess.check_call([esnitool, '-esni-keys-file', esni_pub, '-esni-private-file', esni_priv]) + + @classmethod + def data_volumes(cls): + return {cls.tmpdir.name: {'bind': '/testdata', 'mode': 'ro'}} @property def server_ip(self): @@ -313,5 +343,10 @@ def test_server_doesnt_support_SIDH(self): res = self.d.run_client(self.CLIENT_NAME, '-rsa=false -ecdsa=true '+self.server_ip+":7443") self.assertEqual(res[0], 0) + def test_esni(self): + res = self.d.run_client(self.CLIENT_NAME, '-rsa=false -ecdsa=false -esni-keys=/testdata/esni.pub '+self.server_ip+':1443', + volumes=self.data_volumes()) + self.assertEqual(res[0], 0) + if __name__ == '__main__': unittest.main() diff --git a/_dev/tris-localserver/runner.sh b/_dev/tris-localserver/runner.sh index 9b43013da5..6f879a77de 100755 --- a/_dev/tris-localserver/runner.sh +++ b/_dev/tris-localserver/runner.sh @@ -1,6 +1,11 @@ #!/bin/sh -./tris-localserver -b 0.0.0.0:1443 -cert=rsa -rtt0=n 2>&1 & # first port: ECDSA (and no 0-RTT) +esni_args= +if [ -e /testdata/esni.key ]; then + esni_args='-esni-keys=/testdata/esni.pub -esni-private=/testdata/esni.key' +fi + +./tris-localserver -b 0.0.0.0:1443 -cert=rsa -rtt0=n $esni_args 2>&1 & # first port: ECDSA (and no 0-RTT) ./tris-localserver -b 0.0.0.0:2443 -cert=ecdsa -rtt0=a 2>&1 & # second port: RSA (and accept 0-RTT but not offer it) ./tris-localserver -b 0.0.0.0:3443 -cert=ecdsa -rtt0=o 2>&1 & # third port: offer and reject 0-RTT ./tris-localserver -b 0.0.0.0:4443 -cert=ecdsa -rtt0=oa 2>&1 & # fourth port: offer and accept 0-RTT diff --git a/_dev/tris-localserver/server.go b/_dev/tris-localserver/server.go index 4c7733f1e6..8b52249d36 100644 --- a/_dev/tris-localserver/server.go +++ b/_dev/tris-localserver/server.go @@ -3,6 +3,7 @@ package main import ( "crypto/tls" "crypto/x509" + "encoding/base64" "encoding/hex" "errors" "flag" @@ -154,6 +155,8 @@ func main() { arg_confirm := flag.Bool("rtt0ack", false, "0-RTT confirm") arg_clientauth := flag.Bool("cliauth", false, "Performs client authentication (RequireAndVerifyClientCert used)") arg_pq := flag.String("pq", "", "Enable quantum-resistant algorithms [c: Support classical and Quantum-Resistant, q: Enable Quantum-Resistant only]") + arg_esniKeys := flag.String("esni-keys", "", "File with base64-encoded ESNIKeys") + arg_esniPrivate := flag.String("esni-private", "", "Private key file for ESNI") flag.Parse() s.Address = *arg_addr @@ -178,6 +181,35 @@ func main() { enablePQ(s, false) } + var err error + var esniKeys *tls.ESNIKeys + var esniPrivateKey []byte + if *arg_esniPrivate == "" && *arg_esniKeys != "" || + *arg_esniPrivate != "" && *arg_esniKeys == "" { + log.Fatal("Both -esni-keys and -esni-private must be provided.") + } + if *arg_esniPrivate != "" { + esniPrivateKey, err = ioutil.ReadFile(*arg_esniPrivate) + if err != nil { + log.Fatalf("Failed to read ESNI private key: %s", err) + } + } + if *arg_esniKeys != "" { + contents, err := ioutil.ReadFile(*arg_esniKeys) + if err != nil { + log.Fatalf("Failed to read ESNIKeys: %s", err) + } + esniKeysBytes, err := base64.StdEncoding.DecodeString(string(contents)) + if err != nil { + log.Fatal("Bad -esni-keys: %s", err) + } + esniKeys, err = tls.ParseESNIKeys(esniKeysBytes) + if esniKeys == nil { + log.Fatalf("Cannot parse ESNIKeys: %s", err) + } + s.TLS.GetServerESNIKeys = func([]byte) (*tls.ESNIKeys, []byte, error) { return esniKeys, esniPrivateKey, nil } + } + http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { tlsConn := r.Context().Value(http.TLSConnContextKey).(*tls.Conn) diff --git a/_dev/tris-testclient/client.go b/_dev/tris-testclient/client.go index 71609b3edd..29277bacb8 100644 --- a/_dev/tris-testclient/client.go +++ b/_dev/tris-testclient/client.go @@ -3,11 +3,14 @@ package main import ( "crypto/tls" "crypto/x509" + "encoding/base64" "errors" "flag" "fmt" "io" + "io/ioutil" "log" + "net" "os" "strings" ) @@ -55,6 +58,8 @@ type Client struct { func NewClient() *Client { var c Client c.TLS.InsecureSkipVerify = true + // ESNI support requires a server name to be set. + c.TLS.ServerName = "localhost" return &c } @@ -112,7 +117,8 @@ func result() { // Usage client args host:port func main() { var keylog_file, tls_version, named_groups, named_ciphers string - var enable_rsa, enable_ecdsa, client_auth bool + var enable_rsa, enable_ecdsa, client_auth, enable_esni bool + var esniKeys string flag.StringVar(&keylog_file, "keylogfile", "", "Secrets will be logged here") flag.BoolVar(&enable_rsa, "rsa", true, "Whether to enable RSA cipher suites") @@ -121,6 +127,8 @@ func main() { flag.StringVar(&tls_version, "tls_version", "1.3", "TLS version to use") flag.StringVar(&named_groups, "groups", "X25519:P-256:P-384:P-521", "NamedGroups IDs to use") flag.StringVar(&named_ciphers, "ciphers", "TLS_CHACHA20_POLY1305_SHA256:TLS_AES_128_GCM_SHA256:TLS_AES_256_GCM_SHA384", "Named cipher IDs to use") + flag.BoolVar(&enable_esni, "esni", false, "Whether to enable ESNI (using DNS if -esni-keys is not provided)") + flag.StringVar(&esniKeys, "esni-keys", "", "Enable ESNI, using the base64-encoded ESNIKeys from this file instead of DNS") flag.Parse() if flag.NArg() != 1 { flag.Usage() @@ -132,6 +140,10 @@ func main() { if !strings.Contains(client.addr, ":") { client.addr += ":443" } + host, _, err := net.SplitHostPort(client.addr) + if err != nil { + log.Fatalf("Cannot parse address: %s", err) + } if keylog_file == "" { keylog_file = os.Getenv("SSLKEYLOGFILE") @@ -159,6 +171,32 @@ func main() { } } + var esniKeysBytes []byte + if len(esniKeys) != 0 { + contents, err := ioutil.ReadFile(esniKeys) + if err != nil { + log.Fatalf("Failed to read ESNIKeys: %s", err) + } + esniKeysBytes, err = base64.StdEncoding.DecodeString(string(contents)) + if err != nil { + log.Fatalf("Failed to parse -esni-keys: %s", err) + } + enable_esni = true + } else if enable_esni { + esniKeysBytes, err = QueryESNIKeysForHost(host) + // Note: on error the spec suggests to continue with cleartext + // ESNI, but for testing purposes we will treat it as fatal. + if err != nil { + log.Fatalf("Failed to retrieve ESNI for host: %s", err) + } + } + if enable_esni { + client.TLS.ClientESNIKeys, err = tls.ParseESNIKeys(esniKeysBytes) + if client.TLS.ClientESNIKeys == nil { + log.Fatalf("Failed to process ESNI response for host: %s", err) + } + } + if enable_rsa { // Sanity check: TLS 1.2 with the mandatory cipher suite from RFC 5246 c := client.clone() diff --git a/_dev/tris-testclient/esni_query.go b/_dev/tris-testclient/esni_query.go new file mode 100644 index 0000000000..aa794ac1cd --- /dev/null +++ b/_dev/tris-testclient/esni_query.go @@ -0,0 +1,105 @@ +package main + +import ( + "crypto/tls" + "encoding/base64" + "errors" + "fmt" + "strings" + + "golang.org/x/net/dns/dnsmessage" +) + +func makeDoTQuery(dnsName string) ([]byte, error) { + query := dnsmessage.Message{ + Header: dnsmessage.Header{ + RecursionDesired: true, + }, + Questions: []dnsmessage.Question{ + { + Name: dnsmessage.MustNewName(dnsName), + Type: dnsmessage.TypeTXT, + Class: dnsmessage.ClassINET, + }, + }, + } + req, err := query.Pack() + if err != nil { + return nil, err + } + l := len(req) + req = append([]byte{ + uint8(l >> 8), + uint8(l), + }, req...) + return req, nil +} + +func parseTXTResponse(buf []byte, wantName string) (string, error) { + var p dnsmessage.Parser + hdr, err := p.Start(buf) + if err != nil { + return "", err + } + if hdr.RCode != dnsmessage.RCodeSuccess { + return "", fmt.Errorf("DNS query failed, rcode=%s", hdr.RCode) + } + if err := p.SkipAllQuestions(); err != nil { + return "", err + } + for { + h, err := p.AnswerHeader() + if err == dnsmessage.ErrSectionDone { + break + } + if err != nil { + return "", err + } + if h.Type != dnsmessage.TypeTXT || h.Class != dnsmessage.ClassINET { + continue + } + if !strings.EqualFold(h.Name.String(), wantName) { + if err := p.SkipAnswer(); err != nil { + return "", err + } + } + r, err := p.TXTResource() + if err != nil { + return "", err + } + return r.TXT[0], nil + } + return "", errors.New("No TXT record found") +} + +func QueryESNIKeysForHost(hostname string) ([]byte, error) { + esniDnsName := "_esni." + hostname + "." + query, err := makeDoTQuery(esniDnsName) + if err != nil { + return nil, fmt.Errorf("Building DNS query failed: %s", err) + } + + c, err := tls.Dial("tcp", "1.1.1.1:853", &tls.Config{}) + if err != nil { + return nil, err + } + defer c.Close() + + // Send DNS query + n, err := c.Write(query) + if err != nil || n != len(query) { + return nil, fmt.Errorf("Failed to write query: %s", err) + } + + // Read DNS response + buf := make([]byte, 4096) + n, err = c.Read(buf) + if n < 2 && err != nil { + return nil, fmt.Errorf("Cannot read response: %s", err) + } + txt, err := parseTXTResponse(buf[2:n], esniDnsName) + if err != nil { + return nil, fmt.Errorf("Cannot process TXT record: %s", err) + } + return base64.StdEncoding.DecodeString(txt) +} diff --git a/common.go b/common.go index 46741ad10a..c72a373917 100644 --- a/common.go +++ b/common.go @@ -641,6 +641,18 @@ type Config struct { // This value has no meaning for the client. GetDelegatedCredential func(*ClientHelloInfo, uint16) ([]byte, crypto.PrivateKey, error) + // ClientESNIKeys enables SNI encryption by the client using the + // provided server keys, it has no effect on a server. If the value is + // nil, then SNI encryption is not enabled. + // + // See https://tools.ietf.org/html/draft-ietf-tls-esni-01 + ClientESNIKeys *ESNIKeys + + // GetServerESNIKeys should return the ESNIKeys structure identified by + // the given recordDigest and the corresponding private key share or + // return an error if unknown. + GetServerESNIKeys func(recordDigest []byte) (*ESNIKeys, []byte, error) + serverInitOnce sync.Once // guards calling (*Config).serverInit // mutex protects sessionTicketKeys. @@ -723,6 +735,8 @@ func (c *Config) Clone() *Config { SessionTicketSealer: c.SessionTicketSealer, AcceptDelegatedCredential: c.AcceptDelegatedCredential, GetDelegatedCredential: c.GetDelegatedCredential, + ClientESNIKeys: c.ClientESNIKeys, + GetServerESNIKeys: c.GetServerESNIKeys, sessionTicketKeys: sessionTicketKeys, UseExtendedMasterSecret: c.UseExtendedMasterSecret, } diff --git a/esni.go b/esni.go new file mode 100644 index 0000000000..851a135629 --- /dev/null +++ b/esni.go @@ -0,0 +1,448 @@ +package tls + +import ( + "bytes" + "crypto" + "crypto/cipher" + "crypto/sha256" + "crypto/subtle" + "errors" + "fmt" + "io" + "strings" + "time" + + "golang.org/x/crypto/cryptobyte" +) + +// https://tools.ietf.org/html/draft-ietf-tls-esni-01 +const esniKeysVersionDraft01 uint16 = 0xff01 + +const extensionEncryptedServerName uint16 = 0xffce + +const esniNonceLength = 16 + +// ESNIKeys structure that is exposed through DNS. +type ESNIKeys struct { + version uint16 + checksum [4]uint8 + // (Draft -03 introduces "public_name" here) + keys []keyShare // 16-bit vector length + cipherSuites []uint16 // 16-bit vector length + paddedLength uint16 + notBefore uint64 + notAfter uint64 + extensions []byte // 16-bit vector length. No extensions are defined in draft -01 +} + +// Like cryptobyte.ReadUint16LengthPrefixed, but accepts a []byte output. +func readUint16LengthPrefixed(s *cryptobyte.String, out *[]byte) bool { + return s.ReadUint16LengthPrefixed((*cryptobyte.String)(out)) +} + +func readUint64(s *cryptobyte.String, out *uint64) bool { + var high, low uint32 + if !s.ReadUint32(&high) || !s.ReadUint32(&low) { + return false + } + *out = uint64(high)<<32 | uint64(low) + return true +} + +func addUint64(b *cryptobyte.Builder, v uint64) { + b.AddUint32(uint32(v >> 32)) + b.AddUint32(uint32(v)) +} + +// Parses the raw ESNIKeys structure (not base64-encoded). If obtained from DNS, +// then it must have used a secure transport (DoH, DoT). +// Returns a ESNIKeys structure if parsing was successful and nil otherwise +// (unknown version, bad encoding, invalid checksum, etc.) +func ParseESNIKeys(data []byte) (*ESNIKeys, error) { + k := &ESNIKeys{} + input := cryptobyte.String(data) + var keyShares, cipherSuites, extensions cryptobyte.String + if !input.ReadUint16(&k.version) || + k.version != esniKeysVersionDraft01 { + return nil, errors.New("Invalid version") + } + if !input.CopyBytes(k.checksum[:]) { + return nil, errors.New("Invalid format") + } + + // Verify checksum: SHA256(ESNIKeys)[:4] with checksum = 0 + hash := sha256.New() + hash.Write(data[:2]) // version + hash.Write([]byte{0, 0, 0, 0}) + hash.Write(data[6:]) // fields after checksum + actualChecksum := hash.Sum(nil)[:4] + if subtle.ConstantTimeCompare(k.checksum[:], actualChecksum) != 1 { + return nil, errors.New("Bad checksum") + } + + if !input.ReadUint16LengthPrefixed(&keyShares) || + len(keyShares) == 0 || + !input.ReadUint16LengthPrefixed(&cipherSuites) || + len(cipherSuites) == 0 || + !input.ReadUint16(&k.paddedLength) || + !readUint64(&input, &k.notBefore) || + !readUint64(&input, &k.notAfter) || + !input.ReadUint16LengthPrefixed(&extensions) || + !input.Empty() { + return nil, errors.New("Invalid format") + } + + for !keyShares.Empty() { + var ks keyShare + if !keyShares.ReadUint16((*uint16)(&ks.group)) || + !readUint16LengthPrefixed(&keyShares, &ks.data) || + len(ks.data) == 0 { + return nil, errors.New("Invalid format") + } + k.keys = append(k.keys, ks) + } + for !cipherSuites.Empty() { + var cipherSuite uint16 + if !cipherSuites.ReadUint16(&cipherSuite) { + return nil, errors.New("Invalid format") + } + k.cipherSuites = append(k.cipherSuites, cipherSuite) + } + // Draft -01 does not have any extensions, fail if there are any. + if !extensions.Empty() { + return nil, errors.New("Extensions are not supported") + } + return k, nil +} + +func (k *ESNIKeys) serialize() []byte { + var b cryptobyte.Builder + b.AddUint16(k.version) + b.AddBytes(k.checksum[:]) + b.AddUint16LengthPrefixed(func(b *cryptobyte.Builder) { + for _, ks := range k.keys { + b.AddUint16(uint16(ks.group)) + b.AddUint16LengthPrefixed(func(b *cryptobyte.Builder) { + b.AddBytes(ks.data) + }) + } + }) + b.AddUint16LengthPrefixed(func(b *cryptobyte.Builder) { + for _, cs := range k.cipherSuites { + b.AddUint16(cs) + } + }) + b.AddUint16(k.paddedLength) + addUint64(&b, k.notBefore) + addUint64(&b, k.notAfter) + // No extensions are defined in the initial draft. + b.AddUint16(0) + // Should always succeed as we use simple types only. + return b.BytesOrPanic() +} + +// Returns true if the client can still use this key. +func (k *ESNIKeys) isValid(now time.Time) bool { + nowUnix := uint64(now.Unix()) + return k.notBefore <= nowUnix && nowUnix <= k.notAfter +} + +// Computes a record digest for the given hash algorithm. +func (k *ESNIKeys) recordDigest(hash crypto.Hash) []byte { + h := hash.New() + h.Write(k.serialize()) + return h.Sum(nil) +} + +func (k *ESNIKeys) createPaddedServerNameList(serverName string) ([]byte, error) { + if len(serverName) == 0 { + return nil, errors.New("ServerName must be set") + } + // https://tools.ietf.org/html/rfc6066#section-3 + // + // struct { + // NameType name_type; + // select (name_type) { + // case host_name: HostName; + // } name; + // } ServerName; + // + // enum { + // host_name(0), (255) + // } NameType; + // + // opaque HostName<1..2^16-1>; + // + // struct { + // ServerName server_name_list<1..2^16-1> + // } ServerNameList; + // TODO use NewFixedBuilder when CL 148882 is imported. + b := cryptobyte.NewBuilder(make([]byte, 0, k.paddedLength)) + b.AddUint16LengthPrefixed(func(b *cryptobyte.Builder) { + b.AddUint8(0) // NameType: host_name + b.AddUint16LengthPrefixed(func(b *cryptobyte.Builder) { + b.AddBytes([]byte(serverName)) + }) + }) + serverNameList, err := b.Bytes() + if err != nil || len(serverNameList) > int(k.paddedLength) { + // The client MUST NOT use ESNI when the name length is too long + return nil, errors.New("ServerName is too long") + } + // Append zeroes as padding (if any). + serverNameList = serverNameList[:k.paddedLength] + return serverNameList, nil +} + +type clientEncryptedSNI struct { + suite uint16 // CipherSuite + keyShare keyShare // KeyShareEntry + recordDigest []byte + encryptedSni []byte +} + +// pickCipherSuite selects a supported cipher suite or returns nil if no mutual +// cipher suite is supported. +func (k *ESNIKeys) pickCipherSuite() *cipherSuite { + for _, availCipher := range k.cipherSuites { + for _, supportedCipher := range cipherSuites { + if supportedCipher.flags&suiteTLS13 == 0 { + continue + } + if supportedCipher.id == availCipher { + return supportedCipher + } + } + } + return nil +} + +func (k *ESNIKeys) pickKeyShare() keyShare { + for _, availKeyShare := range k.keys { + switch availKeyShare.group { + case CurveP256, CurveP384, CurveP521, X25519: + return availKeyShare + } + } + return keyShare{} +} + +func pickEsniKex(curveId CurveID) kex { + switch curveId { + case CurveP256: + return &kexNIST{} + case CurveP384: + return &kexNIST{} + case CurveP521: + return &kexNIST{} + case X25519: + return &kexX25519{} + default: + return nil + } +} + +func serializeKeyShares(keyShares []keyShare) []byte { + var b cryptobyte.Builder + b.AddUint16LengthPrefixed(func(b *cryptobyte.Builder) { + for _, ks := range keyShares { + b.AddUint16(uint16(ks.group)) + b.AddUint16LengthPrefixed(func(b *cryptobyte.Builder) { + b.AddBytes(ks.data) + }) + } + }) + return b.BytesOrPanic() +} + +func (k *ESNIKeys) makeClientHelloExtension(rand io.Reader, serverName string, clientHelloRandom []byte, clientHelloKeyShares []keyShare) ([]byte, *clientEncryptedSNI, error) { + suite := k.pickCipherSuite() + if suite == nil { + return nil, nil, errors.New("Unsupported cipher suite") + } + serverKS := k.pickKeyShare() + if serverKS.group == 0 { + return nil, nil, errors.New("Unsupported key shares") + } + + // Prepare plaintext ESNI contents. + paddedSNI, err := k.createPaddedServerNameList(serverName) + if paddedSNI == nil { + // Name is empty or too long. + return nil, nil, err + } + innerESNI := make([]byte, esniNonceLength+len(paddedSNI)) + esniNonce := innerESNI[:esniNonceLength] + if _, err := io.ReadFull(rand, esniNonce); err != nil { + return nil, nil, err + } + copy(innerESNI[esniNonceLength:], paddedSNI) + + // Derive key using a new ephemeral key and the semi-static key provided + // by the server. + kex := pickEsniKex(serverKS.group) + if kex == nil { + return nil, nil, errors.New("Unsupported curve") + } + dhSharedSecret, clientKS, err := kex.keyAgreementServer(rand, serverKS) + if err != nil { + return nil, nil, err + } + recordDigest := k.recordDigest(hashForSuite(suite)) + aead := k.aeadForESNI(suite, recordDigest, clientHelloRandom, clientKS, dhSharedSecret) + aad := serializeKeyShares(clientHelloKeyShares) + // A fixed nonce was provided before, do not provide XOR mask since it + // is used only once. + esni := aead.Seal(nil, nil, innerESNI, aad) + + clientESNI := &clientEncryptedSNI{ + suite: suite.id, + keyShare: clientKS, + recordDigest: recordDigest, + encryptedSni: esni, + } + return esniNonce, clientESNI, nil +} + +func (clientESNI *clientEncryptedSNI) marshal() []byte { + var b cryptobyte.Builder + b.AddUint16(clientESNI.suite) + b.AddUint16(uint16(clientESNI.keyShare.group)) + b.AddUint16LengthPrefixed(func(b *cryptobyte.Builder) { + b.AddBytes(clientESNI.keyShare.data) + }) + b.AddUint16LengthPrefixed(func(b *cryptobyte.Builder) { + b.AddBytes(clientESNI.recordDigest) + }) + b.AddUint16LengthPrefixed(func(b *cryptobyte.Builder) { + b.AddBytes(clientESNI.encryptedSni) + }) + return b.BytesOrPanic() +} + +func (clientESNI *clientEncryptedSNI) unmarshal(data []byte) bool { + input := cryptobyte.String(data) + if !input.ReadUint16((*uint16)(&clientESNI.suite)) || + !input.ReadUint16((*uint16)(&clientESNI.keyShare.group)) || + !readUint16LengthPrefixed(&input, &clientESNI.keyShare.data) || + !readUint16LengthPrefixed(&input, &clientESNI.recordDigest) || + !readUint16LengthPrefixed(&input, &clientESNI.encryptedSni) || + !input.Empty() { + return false + } + return true +} + +// processClientESNIForServer processes the ESNI extension sent by the client. +// On success, it returns the nonce, decrypted server name, alertSuccess and no +// error. On failure, it will return the alert code and error description. +func (clientESNI *clientEncryptedSNI) processClientESNIForServer(config *Config, clientHelloRandom []byte, clientHelloKeyShares []keyShare) ([]byte, string, alert, error) { + if config.GetServerESNIKeys == nil { + return nil, "", alertIllegalParameter, fmt.Errorf("ESNI support is not enabled") + } + // TODO check validity of esniKeys or document that GetServerESNIKeys should check this + esniKeys, serverPrivateKey, err := config.GetServerESNIKeys(clientESNI.recordDigest) + if err != nil { + return nil, "", alertIllegalParameter, fmt.Errorf("tls: unable to find ESNIKeys for server: %s", err) + } + if esniKeys == nil || len(serverPrivateKey) == 0 { + return nil, "", alertInternalError, errors.New("tls: missing ESNIKeys and private key") + } + suite := mutualCipherSuite(esniKeys.cipherSuites, clientESNI.suite) + if suite == nil { + return nil, "", alertIllegalParameter, fmt.Errorf("tls: forbidden cipher suite for ESNI: %#x", clientESNI.suite) + } + clientKS := clientESNI.keyShare + kex := pickEsniKex(clientKS.group) + if kex == nil { + return nil, "", alertIllegalParameter, fmt.Errorf("tls: forbidden key share for ESNI: %#x", clientESNI.keyShare.group) + } + // Sanity check the public ESNIKeys value from GetServerESNIKeys against + // those included in the Client Hello. + recordDigest := esniKeys.recordDigest(hashForSuite(suite)) + if !bytes.Equal(recordDigest, clientESNI.recordDigest) { + return nil, "", alertInternalError, fmt.Errorf("tls: GetServerESNIKeys keys do not match the expected record_digest") + } + + // Compute secrets and decrypt ESNI. keyAgreementClient is used since + // we (the server) were the first to provide a semi-static ESNI key to + // the peer. + dhSharedSecret, err := kex.keyAgreementClient(clientKS, serverPrivateKey) + if err != nil { + return nil, "", alertInternalError, err + } + aead := esniKeys.aeadForESNI(suite, recordDigest, clientHelloRandom, clientKS, dhSharedSecret) + aad := serializeKeyShares(clientHelloKeyShares) + // A fixed nonce was provided before, do not provide XOR mask since it + // is used only once. + innerESNI, err := aead.Open(nil, nil, clientESNI.encryptedSni, aad) + if err != nil { + return nil, "", alertDecryptError, fmt.Errorf("tls: decryption error in ESNI: %s", err) + } + if len(innerESNI) != esniNonceLength+int(esniKeys.paddedLength) { + return nil, "", alertIllegalParameter, errors.New("tls: bad ESNI padded length") + } + + esniNonce := innerESNI[:esniNonceLength] + serverName, ok := parseRealSNI(innerESNI[esniNonceLength:]) + if !ok { + return nil, "", alertIllegalParameter, errors.New("tls: bad ESNI name") + } + return esniNonce, serverName, alertSuccess, nil +} + +func parseRealSNI(data []byte) (string, bool) { + input := cryptobyte.String(data) + var serverNameList cryptobyte.String + if !input.ReadUint16LengthPrefixed(&serverNameList) { + return "", false + } + // TODO padding check + + var serverName string + for !serverNameList.Empty() { + var nameType uint8 + var name []byte + if !serverNameList.ReadUint8(&nameType) || + !readUint16LengthPrefixed(&serverNameList, &name) { + return "", false + } + if nameType == 0 { + serverName = string(name) + } + } + if serverName == "" { + return "", false + } + // An SNI value may not include a trailing dot. See + // https://tools.ietf.org/html/rfc6066#section-3 + if strings.HasSuffix(serverName, ".") { + return "", false + } + return serverName, true +} + +// Returns an AEAD for one encryption/decryption operation. +func (k *ESNIKeys) aeadForESNI(suite *cipherSuite, recordDigest, clientHelloRandom []byte, clientKS keyShare, dhSharedSecret []byte) cipher.AEAD { + hash := hashForSuite(suite) + hashOfESNIContents := hash.New() + hashOfESNIContents.Write([]byte{ + byte(len(recordDigest) >> 8), + byte(len(recordDigest)), + }) + hashOfESNIContents.Write(recordDigest) + hashOfESNIContents.Write([]byte{ + byte(clientKS.group >> 8), + byte(clientKS.group), + byte(len(clientKS.data) >> 8), + byte(len(clientKS.data)), + }) + hashOfESNIContents.Write(clientKS.data) + hashOfESNIContents.Write(clientHelloRandom[:]) + contextHash := hashOfESNIContents.Sum(nil) + + zx := hkdfExtract(hash, dhSharedSecret, nil) + key := hkdfExpandLabel(hash, zx, contextHash, "esni key", suite.keyLen) + iv := hkdfExpandLabel(hash, zx, contextHash, "esni iv", suite.ivLen) + return suite.aead(key, iv) +} diff --git a/esni_test.go b/esni_test.go new file mode 100644 index 0000000000..51f2bb1966 --- /dev/null +++ b/esni_test.go @@ -0,0 +1,61 @@ +package tls + +import ( + "encoding/base64" + "fmt" + "testing" +) + +// dig _esni.cloudflare.com TXT +short +const esniTxtRecord = "/wE7+NS+ACQAHQAgxTKiPqR+6KrU1WMBf21UbzibTZ3kOW2wV8aSohULfzQAAhMBAQQAAAAAXKm8EAAAAABcsaUQAAA=" + +var esniKeysData []byte + +func init() { + d, err := base64.StdEncoding.DecodeString(esniTxtRecord) + if err != nil { + panic(fmt.Sprintf("Bad base64-encoded ESNI record: %s", err)) + } + esniKeysData = d +} + +func TestParseESNIKeysCorruptChecksum(t *testing.T) { + d := make([]byte, len(esniKeysData)) + copy(d, esniKeysData) + d[2] ^= 0xff // corrupt checksum + k, err := ParseESNIKeys(d) + if k != nil { + t.Error("Bad checksum, expected failure!") + } + if err.Error() != "Bad checksum" { + t.Errorf("Expected checksum error, got: %s", err) + } +} + +func TestParseESNIKeys(t *testing.T) { + k, err := ParseESNIKeys(esniKeysData) + if k == nil || err != nil { + t.Errorf("Unable to parse ESNI record: %s", err) + } + if k.version != 0xff01 { + t.Errorf("Unexpected version: %#04x", k.version) + } + if len(k.keys) != 1 || k.keys[0].group != X25519 || len(k.keys[0].data) != 32 { + t.Errorf("Unexpected keyShare: %v", k.keys) + } + if len(k.cipherSuites) != 1 || k.cipherSuites[0] != TLS_AES_128_GCM_SHA256 { + t.Errorf("Unexpected cipher suites: %v", k.cipherSuites) + } + if k.paddedLength != 260 { + t.Errorf("Unexpected paddedLength: %d", k.paddedLength) + } + if k.notBefore != 1554627600 { + t.Errorf("Unexpected notBefore: %d", k.notBefore) + } + if k.notAfter != 1555146000 { + t.Errorf("Unexpected notAfter: %d", k.notAfter) + } + if len(k.extensions) != 0 { + t.Errorf("Unexpected extensions: %v", k.extensions) + } +} diff --git a/handshake_client.go b/handshake_client.go index e8f377dc27..2732863307 100644 --- a/handshake_client.go +++ b/handshake_client.go @@ -34,6 +34,7 @@ type clientHandshakeState struct { // TLS 1.3 fields keySchedule *keySchedule13 privateKey []byte + esniNonce []byte } func makeClientHello(config *Config) (*clientHelloMsg, error) { @@ -205,6 +206,22 @@ func (c *Conn) clientHandshake() error { if _, err := io.ReadFull(c.config.rand(), hello.sessionId); err != nil { return errors.New("tls: short read from Rand: " + err.Error()) } + + if c.config.ClientESNIKeys != nil { + esniKeys := c.config.ClientESNIKeys + if !esniKeys.isValid(c.config.time()) { + return errors.New("tls: ClientESNIKeys has expired") + } + esniNonce, esniExt, err := esniKeys.makeClientHelloExtension(c.config.rand(), hello.serverName, hello.random, hello.keyShares) + if esniExt == nil { + // Bad serverName, etc. + return fmt.Errorf("tls: invalid ESNI configuration: %s", err) + } + // Replace SNI with ESNI, omitting "server_name". + hello.encryptedServerName = esniExt.marshal() + hello.serverName = "" + hs.esniNonce = esniNonce + } } if err = hs.handshake(); err != nil { diff --git a/handshake_messages.go b/handshake_messages.go index 91f0ca8cda..1582e8192f 100644 --- a/handshake_messages.go +++ b/handshake_messages.go @@ -33,6 +33,7 @@ type clientHelloMsg struct { compressionMethods []uint8 nextProtoNeg bool serverName string + encryptedServerName []byte ocspStapling bool scts bool supportedCurves []CurveID @@ -116,6 +117,7 @@ func (m *clientHelloMsg) equal(i interface{}) bool { bytes.Equal(m.compressionMethods, m1.compressionMethods) && m.nextProtoNeg == m1.nextProtoNeg && m.serverName == m1.serverName && + bytes.Equal(m.encryptedServerName, m1.encryptedServerName) && m.ocspStapling == m1.ocspStapling && m.scts == m1.scts && eqCurveIDs(m.supportedCurves, m1.supportedCurves) && @@ -154,6 +156,10 @@ func (m *clientHelloMsg) marshal() []byte { extensionsLength += 5 + len(m.serverName) numExtensions++ } + if len(m.encryptedServerName) > 0 { + extensionsLength += len(m.encryptedServerName) + numExtensions++ + } if len(m.supportedCurves) > 0 { extensionsLength += 2 + 2*len(m.supportedCurves) numExtensions++ @@ -284,6 +290,13 @@ func (m *clientHelloMsg) marshal() []byte { copy(z[5:], []byte(m.serverName)) z = z[l:] } + if len(m.encryptedServerName) > 0 { + l := len(m.encryptedServerName) + binary.BigEndian.PutUint16(z, extensionEncryptedServerName) + binary.BigEndian.PutUint16(z[2:], uint16(l)) + copy(z[4:], m.encryptedServerName) + z = z[4+l:] + } if m.ocspStapling { // RFC 4366, section 3.6 z[0] = byte(extensionStatusRequest >> 8) @@ -490,6 +503,7 @@ func (m *clientHelloMsg) unmarshal(data []byte) alert { m.nextProtoNeg = false m.serverName = "" + m.encryptedServerName = nil m.ocspStapling = false m.ticketSupported = false m.sessionTicket = nil @@ -776,6 +790,9 @@ func (m *clientHelloMsg) unmarshal(data []byte) alert { if length != 0 { return alertDecodeError } + case extensionEncryptedServerName: + // https://tools.ietf.org/html/draft-ietf-tls-esni-01 + m.encryptedServerName = data[:length] } data = data[length:] bindersOffset += length @@ -1234,6 +1251,7 @@ type encryptedExtensionsMsg struct { raw []byte alpnProtocol string earlyData bool + esniNonce []byte } func (m *encryptedExtensionsMsg) equal(i interface{}) bool { @@ -1244,7 +1262,8 @@ func (m *encryptedExtensionsMsg) equal(i interface{}) bool { return bytes.Equal(m.raw, m1.raw) && m.alpnProtocol == m1.alpnProtocol && - m.earlyData == m1.earlyData + m.earlyData == m1.earlyData && + bytes.Equal(m.esniNonce, m1.esniNonce) } func (m *encryptedExtensionsMsg) marshal() []byte { @@ -1264,6 +1283,9 @@ func (m *encryptedExtensionsMsg) marshal() []byte { } length += 2 + 2 + 2 + 1 + alpnLen } + if len(m.esniNonce) > 0 { + length += 2 + 2 + 16 + } x := make([]byte, 4+length) x[0] = typeEncryptedExtensions @@ -1296,6 +1318,13 @@ func (m *encryptedExtensionsMsg) marshal() []byte { z = z[4:] } + if len(m.esniNonce) > 0 { + binary.BigEndian.PutUint16(z, extensionEncryptedServerName) + binary.BigEndian.PutUint16(z[2:], 16) + copy(z[4:], m.esniNonce) + z = z[4+len(m.esniNonce):] + } + m.raw = x return x } @@ -1308,6 +1337,7 @@ func (m *encryptedExtensionsMsg) unmarshal(data []byte) alert { m.alpnProtocol = "" m.earlyData = false + m.esniNonce = nil extensionsLength := int(data[4])<<8 | int(data[5]) data = data[6:] @@ -1350,6 +1380,11 @@ func (m *encryptedExtensionsMsg) unmarshal(data []byte) alert { case extensionEarlyData: // https://tools.ietf.org/html/draft-ietf-tls-tls13-18#section-4.2.8 m.earlyData = true + case extensionEncryptedServerName: + if length != 16 { + return alertDecodeError + } + m.esniNonce = data[:16] } data = data[length:] diff --git a/handshake_server.go b/handshake_server.go index 4d4bff7e17..da335b0bff 100644 --- a/handshake_server.go +++ b/handshake_server.go @@ -246,6 +246,28 @@ Curves: return false, errors.New("tls: initial handshake had non-empty renegotiation extension") } + c.serverName = hs.clientHello.serverName + + var esniNonce []byte + if len(hs.clientHello.encryptedServerName) != 0 { + if c.vers < VersionTLS13 { + c.sendAlert(alertHandshakeFailure) + return false, errors.New("tls: ESNI requires TLS 1.3") + } + var clientESNI clientEncryptedSNI + if !clientESNI.unmarshal(hs.clientHello.encryptedServerName) { + c.sendAlert(alertDecodeError) + return false, errors.New("tls: malformed clientESNI extension") + } + var alert alert + // Override and ignore the old server_name value. + esniNonce, c.serverName, alert, err = clientESNI.processClientESNIForServer(c.config, hs.clientHello.random, hs.clientHello.keyShares) + if alert != alertSuccess { + c.sendAlert(alert) + return false, err + } + } + if c.vers < VersionTLS13 { hs.hello = new(serverHelloMsg) hs.hello.vers = c.vers @@ -268,10 +290,7 @@ Curves: c.sendAlert(alertInternalError) return false, err } - } - - if len(hs.clientHello.serverName) > 0 { - c.serverName = hs.clientHello.serverName + hs.hello13Enc.esniNonce = esniNonce } if len(hs.clientHello.alpnProtocols) > 0 { @@ -914,9 +933,15 @@ func (hs *serverHandshakeState) clientHelloInfo() *ClientHelloInfo { pskBinder = hs.clientHello.psks[0].binder } + // Use ESNI if available. + serverName := hs.c.serverName + if len(serverName) == 0 { + serverName = hs.clientHello.serverName + } + hs.cachedClientHelloInfo = &ClientHelloInfo{ CipherSuites: hs.clientHello.cipherSuites, - ServerName: hs.clientHello.serverName, + ServerName: serverName, SupportedCurves: hs.clientHello.supportedCurves, SupportedPoints: hs.clientHello.supportedPoints, SignatureSchemes: hs.clientHello.supportedSignatureAlgorithms, diff --git a/tls_test.go b/tls_test.go index 1b6a445810..4352d779c5 100644 --- a/tls_test.go +++ b/tls_test.go @@ -674,7 +674,7 @@ func TestCloneNonFuncFields(t *testing.T) { switch fn := typ.Field(i).Name; fn { case "Rand": f.Set(reflect.ValueOf(io.Reader(os.Stdin))) - case "Time", "GetCertificate", "GetConfigForClient", "VerifyPeerCertificate", "GetClientCertificate", "GetDelegatedCredential": + case "Time", "GetCertificate", "GetConfigForClient", "VerifyPeerCertificate", "GetClientCertificate", "GetDelegatedCredential", "ClientESNIKeys", "GetServerESNIKeys": // DeepEqual can't compare functions. If you add a // function field to this list, you must also change // TestCloneFuncFields to ensure that the func field is