runner: Implement ECH server for testing.

This implements draft-ietf-tls-esni-10.

This will be used to test the client implementation. While I'm here,
I've switched the setup logic in the server tests to use the new
ServerECHConfig type. I'll probably need to patch in various features
later for testing, but this should be a usable starting point.

Based on an initial implementation by Dan McArdle in
https://boringssl-review.googlesource.com/c/boringssl/+/46786

Bug: 275
Change-Id: I69523cda70c3da2ae505bcab837fd358195fb9e9
Reviewed-on: https://boringssl-review.googlesource.com/c/boringssl/+/47967
Commit-Queue: David Benjamin <davidben@google.com>
Reviewed-by: Adam Langley <agl@google.com>
diff --git a/ssl/test/runner/common.go b/ssl/test/runner/common.go
index ae07dc0..b753dc8 100644
--- a/ssl/test/runner/common.go
+++ b/ssl/test/runner/common.go
@@ -431,6 +431,10 @@
 	// decreasing order of preference. If empty, the default will be used.
 	ECHCipherSuites []HPKECipherSuite
 
+	// ServerECHConfigs is the server's list of ECHConfig values with
+	// corresponding secret keys.
+	ServerECHConfigs []ServerECHConfig
+
 	// ECHOuterExtensions is the list of extensions that the client will
 	// compress with the ech_outer_extensions extension. If empty, no extensions
 	// will be compressed.
diff --git a/ssl/test/runner/handshake_client.go b/ssl/test/runner/handshake_client.go
index fba7355..158e52c 100644
--- a/ssl/test/runner/handshake_client.go
+++ b/ssl/test/runner/handshake_client.go
@@ -894,8 +894,8 @@
 
 	// Place the ECH extension in the outer CH.
 	hello.clientECH = &clientECH{
-		hpkeKDF:  hs.echHPKEContext.KDF(),
-		hpkeAEAD: hs.echHPKEContext.AEAD(),
+		kdfID:    hs.echHPKEContext.KDF(),
+		aeadID:   hs.echHPKEContext.AEAD(),
 		configID: configID,
 		enc:      enc,
 		payload:  payload,
diff --git a/ssl/test/runner/handshake_messages.go b/ssl/test/runner/handshake_messages.go
index 41a4532..469cf30 100644
--- a/ssl/test/runner/handshake_messages.go
+++ b/ssl/test/runner/handshake_messages.go
@@ -6,6 +6,7 @@
 
 import (
 	"encoding/binary"
+	"errors"
 	"fmt"
 )
 
@@ -300,11 +301,16 @@
 	return bb.finish()
 }
 
+type ServerECHConfig struct {
+	ECHConfig *ECHConfig
+	Key       []byte
+}
+
 // The contents of a CH "encrypted_client_hello" extension.
 // https://tools.ietf.org/html/draft-ietf-tls-esni-10
 type clientECH struct {
-	hpkeKDF  uint16
-	hpkeAEAD uint16
+	kdfID    uint16
+	aeadID   uint16
 	configID uint8
 	enc      []byte
 	payload  []byte
@@ -363,6 +369,12 @@
 	alpsProtocols             []string
 	outerExtensions           []uint16
 	prefixExtensions          []uint16
+	// The following fields are only filled in by |unmarshal| and ignored when
+	// marshaling a new ClientHello.
+	extensionStart    int
+	echExtensionStart int
+	echExtensionEnd   int
+	rawExtensions     map[uint16][]byte
 }
 
 func (m *clientHelloMsg) marshalKeyShares(bb *byteBuilder) {
@@ -449,8 +461,8 @@
 	}
 	if m.clientECH != nil && typ != clientHelloOuterAAD {
 		body := newByteBuilder()
-		body.addU16(m.clientECH.hpkeKDF)
-		body.addU16(m.clientECH.hpkeAEAD)
+		body.addU16(m.clientECH.kdfID)
+		body.addU16(m.clientECH.aeadID)
 		body.addU8(m.clientECH.configID)
 		body.addU16LengthPrefixed().addBytes(m.clientECH.enc)
 		body.addU16LengthPrefixed().addBytes(m.clientECH.payload)
@@ -867,6 +879,8 @@
 		}
 	}
 
+	m.extensionStart = len(data) - len(reader)
+
 	m.nextProtoNeg = false
 	m.serverName = ""
 	m.ocspStapling = false
@@ -883,6 +897,7 @@
 	m.customExtension = ""
 	m.delegatedCredentials = false
 	m.alpsProtocols = nil
+	m.rawExtensions = make(map[uint16][]byte)
 
 	if len(reader) == 0 {
 		// ClientHello is optionally followed by extension data
@@ -900,6 +915,7 @@
 			!extensions.readU16LengthPrefixed(&body) {
 			return false
 		}
+		m.rawExtensions[extension] = body
 		switch extension {
 		case extensionServerName:
 			var names byteReader
@@ -918,18 +934,24 @@
 				}
 			}
 		case extensionEncryptedClientHello:
+			m.echExtensionEnd = len(data) - len(extensions)
+			m.echExtensionStart = m.echExtensionEnd - len(body) - 4
 			var ech clientECH
-			if !body.readU16(&ech.hpkeKDF) ||
-				!body.readU16(&ech.hpkeAEAD) ||
+			if !body.readU16(&ech.kdfID) ||
+				!body.readU16(&ech.aeadID) ||
 				!body.readU8(&ech.configID) ||
 				!body.readU16LengthPrefixedBytes(&ech.enc) ||
-				len(ech.enc) == 0 ||
 				!body.readU16LengthPrefixedBytes(&ech.payload) ||
 				len(ech.payload) == 0 ||
 				len(body) > 0 {
 				return false
 			}
 			m.clientECH = &ech
+		case extensionECHIsInner:
+			if len(body) != 0 {
+				return false
+			}
+			m.echIsInner = true
 		case extensionNextProtoNeg:
 			if len(body) != 0 {
 				return false
@@ -1161,9 +1183,89 @@
 		}
 	}
 
+	// Clients may not send both extensions.
+	// TODO(davidben): A later draft will likely merge the code points, at which
+	// point this check will be redundant.
+	if m.echIsInner && m.clientECH != nil {
+		return false
+	}
+
 	return true
 }
 
+func decodeClientHelloInner(encoded []byte, helloOuter *clientHelloMsg) (*clientHelloMsg, error) {
+	reader := byteReader(encoded)
+	var versAndRandom, sessionID, cipherSuites, compressionMethods []byte
+	var extensions byteReader
+	if !reader.readBytes(&versAndRandom, 2+32) ||
+		!reader.readU8LengthPrefixedBytes(&sessionID) ||
+		len(sessionID) != 0 || // Copied from |helloOuter|
+		!reader.readU16LengthPrefixedBytes(&cipherSuites) ||
+		!reader.readU8LengthPrefixedBytes(&compressionMethods) ||
+		!reader.readU16LengthPrefixed(&extensions) ||
+		len(reader) != 0 {
+		return nil, errors.New("tls: error parsing EncodedClientHelloInner")
+	}
+
+	builder := newByteBuilder()
+	builder.addU8(typeClientHello)
+	body := builder.addU24LengthPrefixed()
+	body.addBytes(versAndRandom)
+	body.addU8LengthPrefixed().addBytes(helloOuter.sessionID)
+	body.addU16LengthPrefixed().addBytes(cipherSuites)
+	body.addU8LengthPrefixed().addBytes(compressionMethods)
+	newExtensions := body.addU16LengthPrefixed()
+
+	var seenOuterExtensions bool
+	copied := make(map[uint16]struct{})
+	for len(extensions) > 0 {
+		var extType uint16
+		var extBody byteReader
+		if !extensions.readU16(&extType) ||
+			!extensions.readU16LengthPrefixed(&extBody) {
+			return nil, errors.New("tls: error parsing EncodedClientHelloInner")
+		}
+		if extType != extensionECHOuterExtensions {
+			newExtensions.addU16(extType)
+			newExtensions.addU16LengthPrefixed().addBytes(extBody)
+			continue
+		}
+		if seenOuterExtensions {
+			return nil, errors.New("tls: duplicate ech_outer_extensions extension")
+		}
+		seenOuterExtensions = true
+		var outerExtensions byteReader
+		if !extBody.readU8LengthPrefixed(&outerExtensions) || len(outerExtensions) == 0 || len(extBody) != 0 {
+			return nil, errors.New("tls: error parsing ech_outer_extensions")
+		}
+		for len(outerExtensions) != 0 {
+			var newExtType uint16
+			if !outerExtensions.readU16(&newExtType) {
+				return nil, errors.New("tls: error parsing ech_outer_extensions")
+			}
+			if newExtType == extensionEncryptedClientHello {
+				return nil, errors.New("tls: error parsing ech_outer_extensions")
+			}
+			if _, ok := copied[newExtType]; ok {
+				return nil, errors.New("tls: duplicate extension in ech_outer_extensions")
+			}
+			newExtBody, ok := helloOuter.rawExtensions[newExtType]
+			if !ok {
+				return nil, fmt.Errorf("tls: extension %04x not found in ClientHelloOuter", newExtType)
+			}
+			newExtensions.addU16(newExtType)
+			newExtensions.addU16LengthPrefixed().addBytes(newExtBody)
+			copied[newExtType] = struct{}{}
+		}
+	}
+
+	ret := new(clientHelloMsg)
+	if !ret.unmarshal(builder.finish()) {
+		return nil, errors.New("tls: error parsing reconstructed ClientHello")
+	}
+	return ret, nil
+}
+
 type serverHelloMsg struct {
 	raw                   []byte
 	isDTLS                bool
diff --git a/ssl/test/runner/handshake_server.go b/ssl/test/runner/handshake_server.go
index 8622bfa..7319f92 100644
--- a/ssl/test/runner/handshake_server.go
+++ b/ssl/test/runner/handshake_server.go
@@ -18,6 +18,8 @@
 	"io"
 	"math/big"
 	"time"
+
+	"boringssl.googlesource.com/boringssl/ssl/test/runner/hpke"
 )
 
 // serverHandshakeState contains details of a server handshake in progress.
@@ -35,6 +37,8 @@
 	certsFromClient [][]byte
 	cert            *Certificate
 	finishedBytes   []byte
+	echHPKEContext  *hpke.Context
+	echConfigID     uint8
 }
 
 // serverHandshake performs a TLS handshake as a server.
@@ -173,6 +177,41 @@
 		return errors.New("tls: ClientHello random was all zero")
 	}
 
+	if clientECH := hs.clientHello.clientECH; clientECH != nil {
+		for _, candidate := range config.ServerECHConfigs {
+			if candidate.ECHConfig.ConfigID != clientECH.configID {
+				continue
+			}
+			var found bool
+			for _, suite := range candidate.ECHConfig.CipherSuites {
+				if clientECH.kdfID == suite.KDF && clientECH.aeadID == suite.AEAD {
+					found = true
+					break
+				}
+			}
+			if !found {
+				continue
+			}
+			info := []byte("tls ech\x00")
+			info = append(info, candidate.ECHConfig.Raw...)
+			hs.echHPKEContext, err = hpke.SetupBaseReceiverX25519(clientECH.kdfID, clientECH.aeadID, clientECH.enc, candidate.Key, info)
+			if err != nil {
+				continue
+			}
+			clientHelloInner, err := hs.decryptClientHello(hs.clientHello)
+			if err != nil {
+				if _, ok := err.(*echDecryptError); ok {
+					continue
+				}
+				c.sendAlert(alertDecryptError)
+				return fmt.Errorf("tls: error decrypting ClientHello: %s", err)
+			}
+			c.echAccepted = true
+			hs.clientHello = clientHelloInner
+			hs.echConfigID = clientECH.configID
+		}
+	}
+
 	if c.isDTLS && !config.Bugs.SkipHelloVerifyRequest {
 		// Per RFC 6347, the version field in HelloVerifyRequest SHOULD
 		// be always DTLS 1.0
@@ -387,6 +426,64 @@
 	}
 }
 
+type echDecryptError struct {
+	error
+}
+
+func (hs *serverHandshakeState) decryptClientHello(helloOuter *clientHelloMsg) (helloInner *clientHelloMsg, err error) {
+	// See draft-ietf-tls-esni-10, section 5.2.
+	aad := newByteBuilder()
+	aad.addU16(helloOuter.clientECH.kdfID)
+	aad.addU16(helloOuter.clientECH.aeadID)
+	aad.addU8(helloOuter.clientECH.configID)
+	aad.addU16LengthPrefixed().addBytes(helloOuter.clientECH.enc)
+	// ClientHelloOuterAAD.outer_hello is ClientHelloOuter without the
+	// encrypted_client_hello extension. Construct this by piecing together
+	// the preserved portions from offsets and updating the length prefix.
+	//
+	// TODO(davidben): If https://github.com/tlswg/draft-ietf-tls-esni/pull/442
+	// is merged, a later iteration will hopefully be simpler.
+	outerHello := aad.addU24LengthPrefixed()
+	outerHello.addBytes(helloOuter.raw[4:helloOuter.extensionStart])
+	extensions := outerHello.addU16LengthPrefixed()
+	extensions.addBytes(helloOuter.raw[helloOuter.extensionStart+2 : helloOuter.echExtensionStart])
+	extensions.addBytes(helloOuter.raw[helloOuter.echExtensionEnd:])
+
+	encoded, err := hs.echHPKEContext.Open(helloOuter.clientECH.payload, aad.finish())
+	if err != nil {
+		// Wrap |err| so the caller can implement trial decryption.
+		return nil, &echDecryptError{err}
+	}
+
+	helloInner, err = decodeClientHelloInner(encoded, helloOuter)
+	if err != nil {
+		return nil, err
+	}
+
+	if isAllZero(helloInner.random) {
+		// If the client forgets to fill in the client random, it will likely be
+		// all zero.
+		return nil, errors.New("tls: ClientHelloInner random was all zero")
+	}
+	if bytes.Equal(helloInner.random, helloOuter.random) {
+		return nil, errors.New("tls: ClientHelloOuter and ClientHelloInner have the same random values")
+	}
+	if len(helloInner.supportedVersions) == 0 {
+		return nil, errors.New("tls: ClientHelloInner did not offer supported_versions")
+	}
+	for _, vers := range helloInner.supportedVersions {
+		switch vers {
+		case VersionSSL30, VersionTLS10, VersionTLS11, VersionTLS12, VersionDTLS10, VersionDTLS12:
+			return nil, fmt.Errorf("tls: ClientHelloInner offered invalid version: %04x", vers)
+		}
+	}
+	if !helloInner.echIsInner {
+		return nil, errors.New("tls: ClientHelloInner missing ech_is_inner extension")
+	}
+
+	return helloInner, nil
+}
+
 func (hs *serverHandshakeState) doTLS13Handshake() error {
 	c := hs.c
 	config := c.config
@@ -650,6 +747,29 @@
 			c.sendAlert(alertUnexpectedMessage)
 			return unexpectedMessageError(newClientHello, newMsg)
 		}
+
+		if c.echAccepted {
+			if newClientHello.clientECH == nil {
+				c.sendAlert(alertMissingExtension)
+				return errors.New("tls: second ClientHelloOuter had no encrypted_client_hello extension")
+			}
+			if newClientHello.clientECH.configID != hs.echConfigID ||
+				newClientHello.clientECH.kdfID != hs.echHPKEContext.KDF() ||
+				newClientHello.clientECH.aeadID != hs.echHPKEContext.AEAD() {
+				c.sendAlert(alertIllegalParameter)
+				return errors.New("tls: ECH parameters changed in second ClientHelloOuter")
+			}
+			if len(newClientHello.clientECH.enc) != 0 {
+				c.sendAlert(alertIllegalParameter)
+				return errors.New("tls: second ClientECH had non-empty enc")
+			}
+			newClientHello, err = hs.decryptClientHello(newClientHello)
+			if err != nil {
+				c.sendAlert(alertDecryptError)
+				return fmt.Errorf("tls: error decrypting ClientHello: %s", err)
+			}
+		}
+
 		hs.writeClientHash(newClientHello.marshal())
 
 		if config.Bugs.ExpectNoTLS13PSKAfterHRR && len(newClientHello.pskIdentities) > 0 {
@@ -843,6 +963,16 @@
 		hs.finishedHash.addEntropy(hs.finishedHash.zeroSecret())
 	}
 
+	// Overwrite part of ServerHello.random to signal ECH acceptance to the client.
+	if hs.clientHello.echIsInner {
+		for i := 24; i < 32; i++ {
+			hs.hello.random[i] = 0
+		}
+		echAcceptConfirmation := hs.finishedHash.deriveSecretPeek([]byte("ech accept confirmation"), hs.hello.marshal())
+		copy(hs.hello.random[24:], echAcceptConfirmation)
+		hs.hello.raw = nil
+	}
+
 	// Send unencrypted ServerHello.
 	helloBytes := hs.hello.marshal()
 	hs.writeServerHash(helloBytes)
@@ -1393,7 +1523,7 @@
 		hs.cert = config.getCertificateForName(hs.clientHello.serverName)
 	}
 	if expected := c.config.Bugs.ExpectServerName; expected != "" && expected != hs.clientHello.serverName {
-		return errors.New("tls: unexpected server name")
+		return fmt.Errorf("tls: unexpected server name: wanted %q, got %q", expected, hs.clientHello.serverName)
 	}
 
 	if cert := config.Bugs.RenegotiationCertificate; c.cipherSuite != nil && cert != nil {
@@ -1524,8 +1654,16 @@
 
 	serverExtensions.serverNameAck = c.config.Bugs.SendServerNameAck
 
-	if (c.vers >= VersionTLS13 || c.config.Bugs.SendECHRetryConfigsInTLS12ServerHello) && hs.clientHello.clientECH != nil && len(config.Bugs.SendECHRetryConfigs) > 0 {
-		serverExtensions.echRetryConfigs = config.Bugs.SendECHRetryConfigs
+	if (c.vers >= VersionTLS13 || c.config.Bugs.SendECHRetryConfigsInTLS12ServerHello) && hs.clientHello.clientECH != nil {
+		if len(config.Bugs.SendECHRetryConfigs) > 0 {
+			serverExtensions.echRetryConfigs = config.Bugs.SendECHRetryConfigs
+		} else if len(config.ServerECHConfigs) > 0 {
+			echConfigs := make([][]byte, len(config.ServerECHConfigs))
+			for i, echConfig := range config.ServerECHConfigs {
+				echConfigs[i] = echConfig.ECHConfig.Raw
+			}
+			serverExtensions.echRetryConfigs = CreateECHConfigList(echConfigs...)
+		}
 	}
 
 	return nil
diff --git a/ssl/test/runner/hpke/kem.go b/ssl/test/runner/hpke/kem.go
index 0138a02..fcf17b1 100644
--- a/ssl/test/runner/hpke/kem.go
+++ b/ssl/test/runner/hpke/kem.go
@@ -69,8 +69,8 @@
 	return key
 }
 
-// GenerateKeyPair generates a random key pair.
-func GenerateKeyPair() (publicKey, secretKeyOut []byte, err error) {
+// GenerateKeyPairX25519 generates a random X25519 key pair.
+func GenerateKeyPairX25519() (publicKey, secretKeyOut []byte, err error) {
 	// Generate a new private key.
 	var secretKey [curve25519.ScalarSize]byte
 	_, err = rand.Read(secretKey[:])
@@ -89,10 +89,10 @@
 // and a fixed-length encapsulation of that key |enc| that can be decapsulated
 // by the receiver with the secret key corresponding to |publicKeyR|.
 // Internally, |keygenOptional| is used to generate an ephemeral keypair. If
-// |keygenOptional| is nil, |GenerateKeyPair| will be substituted.
+// |keygenOptional| is nil, |GenerateKeyPairX25519| will be substituted.
 func x25519Encap(publicKeyR []byte, keygen GenerateKeyPairFunc) ([]byte, []byte, error) {
 	if keygen == nil {
-		keygen = GenerateKeyPair
+		keygen = GenerateKeyPairX25519
 	}
 	publicKeyEphem, secretKeyEphem, err := keygen()
 	if err != nil {
diff --git a/ssl/test/runner/runner.go b/ssl/test/runner/runner.go
index 7fe500a..657394c 100644
--- a/ssl/test/runner/runner.go
+++ b/ssl/test/runner/runner.go
@@ -16202,55 +16202,34 @@
 	},
 }
 
-// generateECHConfigWithKey constructs a valid ECHConfig and corresponding
-// private key for the server. If the cipher list is empty, all ciphers are
-// included.
-func generateECHConfigWithKey(publicName string, ciphers []HPKECipherSuite, configID uint8) (*ECHConfig, []byte, error) {
-	publicKey, secretKey, err := hpke.GenerateKeyPair()
+// generateServerECHConfig constructs a ServerECHConfig with a fresh X25519
+// keypair and using |template| as a template for the ECHConfig. If
+// |template.CipherSuites| is empty, all ciphers are included. |template.KEM|
+// and |template.PublicKey| are ignored.
+func generateServerECHConfig(template *ECHConfig) ServerECHConfig {
+	publicKey, secretKey, err := hpke.GenerateKeyPairX25519()
 	if err != nil {
-		return nil, nil, err
+		panic(err)
 	}
-	if len(ciphers) == 0 {
-		ciphers = make([]HPKECipherSuite, 0, len(echCiphers))
-		for _, cipher := range echCiphers {
-			ciphers = append(ciphers, cipher.cipher)
+	templateCopy := *template
+	templateCopy.KEM = hpke.X25519WithHKDFSHA256
+	templateCopy.PublicKey = publicKey
+	if len(templateCopy.CipherSuites) == 0 {
+		templateCopy.CipherSuites = make([]HPKECipherSuite, len(echCiphers))
+		for i, cipher := range echCiphers {
+			templateCopy.CipherSuites[i] = cipher.cipher
 		}
 	}
-	template := ECHConfig{
-		ConfigID:     configID,
-		PublicName:   publicName,
-		PublicKey:    publicKey,
-		KEM:          hpke.X25519WithHKDFSHA256,
-		CipherSuites: ciphers,
-		// For real-life purposes, the maxNameLen should be
-		// based on the set of domain names that the server
-		// represents.
-		MaxNameLen: 16,
-	}
-	return CreateECHConfig(&template), secretKey, nil
+	return ServerECHConfig{ECHConfig: CreateECHConfig(&templateCopy), Key: secretKey}
 }
 
 func addEncryptedClientHelloTests() {
-	echConfig, key, err := generateECHConfigWithKey("public.example", nil, 42)
-	if err != nil {
-		panic(err)
-	}
-	echConfig1, key1, err := generateECHConfigWithKey("public.example", nil, 43)
-	if err != nil {
-		panic(err)
-	}
-	echConfig2, key2, err := generateECHConfigWithKey("public.example", nil, 44)
-	if err != nil {
-		panic(err)
-	}
-	echConfig3, key3, err := generateECHConfigWithKey("public.example", nil, 45)
-	if err != nil {
-		panic(err)
-	}
-	echConfigRepeatID, keyRepeatID, err := generateECHConfigWithKey("public.example", nil, 42)
-	if err != nil {
-		panic(err)
-	}
+	// echConfig's ConfigID should match the one used in ssl/test/fuzzer.h.
+	echConfig := generateServerECHConfig(&ECHConfig{ConfigID: 42, PublicName: "public.example"})
+	echConfig1 := generateServerECHConfig(&ECHConfig{ConfigID: 43, PublicName: "public.example"})
+	echConfig2 := generateServerECHConfig(&ECHConfig{ConfigID: 44, PublicName: "public.example"})
+	echConfig3 := generateServerECHConfig(&ECHConfig{ConfigID: 45, PublicName: "public.example"})
+	echConfigRepeatID := generateServerECHConfig(&ECHConfig{ConfigID: 42, PublicName: "public.example"})
 
 	for _, protocol := range []protocol{tls, quic} {
 		prefix := protocol.String() + "-"
@@ -16273,13 +16252,13 @@
 				name:     prefix + "ECH-Server" + suffix,
 				config: Config{
 					ServerName:      "secret.example",
-					ClientECHConfig: echConfig,
+					ClientECHConfig: echConfig.ECHConfig,
 					DefaultCurves:   defaultCurves,
 				},
 				resumeSession: true,
 				flags: []string{
-					"-ech-server-config", base64.StdEncoding.EncodeToString(echConfig.Raw),
-					"-ech-server-key", base64.StdEncoding.EncodeToString(key),
+					"-ech-server-config", base64.StdEncoding.EncodeToString(echConfig.ECHConfig.Raw),
+					"-ech-server-key", base64.StdEncoding.EncodeToString(echConfig.Key),
 					"-ech-is-retry-config", "1",
 					"-expect-server-name", "secret.example",
 					"-expect-ech-accept",
@@ -16298,7 +16277,7 @@
 				name:     prefix + "ECH-Server-MinimalClientHelloOuter" + suffix,
 				config: Config{
 					ServerName:      "secret.example",
-					ClientECHConfig: echConfig,
+					ClientECHConfig: echConfig.ECHConfig,
 					DefaultCurves:   defaultCurves,
 					Bugs: ProtocolBugs{
 						MinimalClientHelloOuter: true,
@@ -16306,8 +16285,8 @@
 				},
 				resumeSession: true,
 				flags: []string{
-					"-ech-server-config", base64.StdEncoding.EncodeToString(echConfig.Raw),
-					"-ech-server-key", base64.StdEncoding.EncodeToString(key),
+					"-ech-server-config", base64.StdEncoding.EncodeToString(echConfig.ECHConfig.Raw),
+					"-ech-server-key", base64.StdEncoding.EncodeToString(echConfig.Key),
 					"-ech-is-retry-config", "1",
 					"-expect-server-name", "secret.example",
 					"-expect-ech-accept",
@@ -16328,24 +16307,24 @@
 					DefaultCurves: defaultCurves,
 					// The client uses an ECHConfig that the server does not understand
 					// so we can observe which retry configs the server sends back.
-					ClientECHConfig: echConfig,
+					ClientECHConfig: echConfig.ECHConfig,
 					Bugs: ProtocolBugs{
 						OfferSessionInClientHelloOuter: true,
-						ExpectECHRetryConfigs:          CreateECHConfigList(echConfig2.Raw, echConfig3.Raw),
+						ExpectECHRetryConfigs:          CreateECHConfigList(echConfig2.ECHConfig.Raw, echConfig3.ECHConfig.Raw),
 					},
 				},
 				resumeSession: true,
 				flags: []string{
 					// Configure three ECHConfigs on the shim, only two of which
 					// should be sent in retry configs.
-					"-ech-server-config", base64.StdEncoding.EncodeToString(echConfig1.Raw),
-					"-ech-server-key", base64.StdEncoding.EncodeToString(key1),
+					"-ech-server-config", base64.StdEncoding.EncodeToString(echConfig1.ECHConfig.Raw),
+					"-ech-server-key", base64.StdEncoding.EncodeToString(echConfig1.Key),
 					"-ech-is-retry-config", "0",
-					"-ech-server-config", base64.StdEncoding.EncodeToString(echConfig2.Raw),
-					"-ech-server-key", base64.StdEncoding.EncodeToString(key2),
+					"-ech-server-config", base64.StdEncoding.EncodeToString(echConfig2.ECHConfig.Raw),
+					"-ech-server-key", base64.StdEncoding.EncodeToString(echConfig2.Key),
 					"-ech-is-retry-config", "1",
-					"-ech-server-config", base64.StdEncoding.EncodeToString(echConfig3.Raw),
-					"-ech-server-key", base64.StdEncoding.EncodeToString(key3),
+					"-ech-server-config", base64.StdEncoding.EncodeToString(echConfig3.ECHConfig.Raw),
+					"-ech-server-key", base64.StdEncoding.EncodeToString(echConfig3.Key),
 					"-ech-is-retry-config", "1",
 					"-expect-server-name", "public.example",
 				},
@@ -16360,14 +16339,14 @@
 				config: Config{
 					ServerName:      "secret.example",
 					DefaultCurves:   defaultCurves,
-					ClientECHConfig: echConfig,
+					ClientECHConfig: echConfig.ECHConfig,
 					Bugs: ProtocolBugs{
 						AllowTLS12InClientHelloInner: true,
 					},
 				},
 				flags: []string{
-					"-ech-server-config", base64.StdEncoding.EncodeToString(echConfig.Raw),
-					"-ech-server-key", base64.StdEncoding.EncodeToString(key),
+					"-ech-server-config", base64.StdEncoding.EncodeToString(echConfig.ECHConfig.Raw),
+					"-ech-server-key", base64.StdEncoding.EncodeToString(echConfig.Key),
 					"-ech-is-retry-config", "1"},
 				shouldFail:         true,
 				expectedLocalError: "remote error: illegal parameter",
@@ -16383,15 +16362,15 @@
 				config: Config{
 					ServerName:      "secret.example",
 					DefaultCurves:   defaultCurves,
-					ClientECHConfig: echConfig,
+					ClientECHConfig: echConfig.ECHConfig,
 					Bugs: ProtocolBugs{
 						OmitECHIsInner:       !hrr,
 						OmitSecondECHIsInner: hrr,
 					},
 				},
 				flags: []string{
-					"-ech-server-config", base64.StdEncoding.EncodeToString(echConfig.Raw),
-					"-ech-server-key", base64.StdEncoding.EncodeToString(key),
+					"-ech-server-config", base64.StdEncoding.EncodeToString(echConfig.ECHConfig.Raw),
+					"-ech-server-key", base64.StdEncoding.EncodeToString(echConfig.Key),
 					"-ech-is-retry-config", "1",
 				},
 				shouldFail:         true,
@@ -16407,7 +16386,7 @@
 				config: Config{
 					ServerName:      "secret.example",
 					DefaultCurves:   defaultCurves,
-					ClientECHConfig: echConfig,
+					ClientECHConfig: echConfig.ECHConfig,
 					ECHOuterExtensions: []uint16{
 						extensionKeyShare,
 						extensionSupportedCurves,
@@ -16425,8 +16404,8 @@
 					},
 				},
 				flags: []string{
-					"-ech-server-config", base64.StdEncoding.EncodeToString(echConfig.Raw),
-					"-ech-server-key", base64.StdEncoding.EncodeToString(key),
+					"-ech-server-config", base64.StdEncoding.EncodeToString(echConfig.ECHConfig.Raw),
+					"-ech-server-key", base64.StdEncoding.EncodeToString(echConfig.Key),
 					"-ech-is-retry-config", "1",
 					"-expect-server-name", "secret.example",
 					"-expect-ech-accept",
@@ -16447,7 +16426,7 @@
 				config: Config{
 					ServerName:      "secret.example",
 					DefaultCurves:   defaultCurves,
-					ClientECHConfig: echConfig,
+					ClientECHConfig: echConfig.ECHConfig,
 					ECHOuterExtensions: []uint16{
 						extensionSupportedCurves,
 						extensionSupportedCurves,
@@ -16457,8 +16436,8 @@
 					},
 				},
 				flags: []string{
-					"-ech-server-config", base64.StdEncoding.EncodeToString(echConfig.Raw),
-					"-ech-server-key", base64.StdEncoding.EncodeToString(key),
+					"-ech-server-config", base64.StdEncoding.EncodeToString(echConfig.ECHConfig.Raw),
+					"-ech-server-key", base64.StdEncoding.EncodeToString(echConfig.Key),
 					"-ech-is-retry-config", "1",
 				},
 				shouldFail:         true,
@@ -16475,7 +16454,7 @@
 				config: Config{
 					ServerName:      "secret.example",
 					DefaultCurves:   defaultCurves,
-					ClientECHConfig: echConfig,
+					ClientECHConfig: echConfig.ECHConfig,
 					ECHOuterExtensions: []uint16{
 						extensionCustom,
 					},
@@ -16484,8 +16463,8 @@
 					},
 				},
 				flags: []string{
-					"-ech-server-config", base64.StdEncoding.EncodeToString(echConfig.Raw),
-					"-ech-server-key", base64.StdEncoding.EncodeToString(key),
+					"-ech-server-config", base64.StdEncoding.EncodeToString(echConfig.ECHConfig.Raw),
+					"-ech-server-key", base64.StdEncoding.EncodeToString(echConfig.Key),
 					"-ech-is-retry-config", "1",
 					"-expect-server-name", "secret.example",
 					"-expect-ech-accept",
@@ -16505,7 +16484,7 @@
 				config: Config{
 					ServerName:      "secret.example",
 					DefaultCurves:   defaultCurves,
-					ClientECHConfig: echConfig,
+					ClientECHConfig: echConfig.ECHConfig,
 					ECHOuterExtensions: []uint16{
 						extensionEncryptedClientHello,
 					},
@@ -16514,8 +16493,8 @@
 					},
 				},
 				flags: []string{
-					"-ech-server-config", base64.StdEncoding.EncodeToString(echConfig.Raw),
-					"-ech-server-key", base64.StdEncoding.EncodeToString(key),
+					"-ech-server-config", base64.StdEncoding.EncodeToString(echConfig.ECHConfig.Raw),
+					"-ech-server-key", base64.StdEncoding.EncodeToString(echConfig.Key),
 					"-ech-is-retry-config", "1",
 				},
 				shouldFail:         true,
@@ -16532,13 +16511,13 @@
 			name:     prefix + "ECH-Server-AsyncEarlyCallback",
 			config: Config{
 				ServerName:      "secret.example",
-				ClientECHConfig: echConfig,
+				ClientECHConfig: echConfig.ECHConfig,
 			},
 			flags: []string{
 				"-async",
 				"-use-early-callback",
-				"-ech-server-config", base64.StdEncoding.EncodeToString(echConfig.Raw),
-				"-ech-server-key", base64.StdEncoding.EncodeToString(key),
+				"-ech-server-config", base64.StdEncoding.EncodeToString(echConfig.ECHConfig.Raw),
+				"-ech-server-key", base64.StdEncoding.EncodeToString(echConfig.Key),
 				"-ech-is-retry-config", "1",
 				"-expect-server-name", "secret.example",
 				"-expect-ech-accept",
@@ -16556,14 +16535,14 @@
 			name:     prefix + "ECH-Server-SecondECHConfig",
 			config: Config{
 				ServerName:      "secret.example",
-				ClientECHConfig: echConfig1,
+				ClientECHConfig: echConfig1.ECHConfig,
 			},
 			flags: []string{
-				"-ech-server-config", base64.StdEncoding.EncodeToString(echConfig.Raw),
-				"-ech-server-key", base64.StdEncoding.EncodeToString(key),
+				"-ech-server-config", base64.StdEncoding.EncodeToString(echConfig.ECHConfig.Raw),
+				"-ech-server-key", base64.StdEncoding.EncodeToString(echConfig.Key),
 				"-ech-is-retry-config", "1",
-				"-ech-server-config", base64.StdEncoding.EncodeToString(echConfig1.Raw),
-				"-ech-server-key", base64.StdEncoding.EncodeToString(key1),
+				"-ech-server-config", base64.StdEncoding.EncodeToString(echConfig1.ECHConfig.Raw),
+				"-ech-server-key", base64.StdEncoding.EncodeToString(echConfig1.Key),
 				"-ech-is-retry-config", "1",
 				"-expect-server-name", "secret.example",
 				"-expect-ech-accept",
@@ -16581,14 +16560,14 @@
 			name:     prefix + "ECH-Server-RepeatedConfigID",
 			config: Config{
 				ServerName:      "secret.example",
-				ClientECHConfig: echConfigRepeatID,
+				ClientECHConfig: echConfigRepeatID.ECHConfig,
 			},
 			flags: []string{
-				"-ech-server-config", base64.StdEncoding.EncodeToString(echConfig.Raw),
-				"-ech-server-key", base64.StdEncoding.EncodeToString(key),
+				"-ech-server-config", base64.StdEncoding.EncodeToString(echConfig.ECHConfig.Raw),
+				"-ech-server-key", base64.StdEncoding.EncodeToString(echConfig.Key),
 				"-ech-is-retry-config", "1",
-				"-ech-server-config", base64.StdEncoding.EncodeToString(echConfigRepeatID.Raw),
-				"-ech-server-key", base64.StdEncoding.EncodeToString(keyRepeatID),
+				"-ech-server-config", base64.StdEncoding.EncodeToString(echConfigRepeatID.ECHConfig.Raw),
+				"-ech-server-key", base64.StdEncoding.EncodeToString(echConfigRepeatID.Key),
 				"-ech-is-retry-config", "1",
 				"-expect-server-name", "secret.example",
 				"-expect-ech-accept",
@@ -16600,10 +16579,7 @@
 
 		// Test all supported ECH cipher suites.
 		for i, cipher := range echCiphers {
-			otherCipher := echCiphers[0]
-			if i == 0 {
-				otherCipher = echCiphers[1]
-			}
+			otherCipher := echCiphers[(i+1)%len(echCiphers)]
 
 			// Test the ECH server can handle the specified cipher.
 			testCases = append(testCases, testCase{
@@ -16612,12 +16588,12 @@
 				name:     prefix + "ECH-Server-Cipher-" + cipher.name,
 				config: Config{
 					ServerName:      "secret.example",
-					ClientECHConfig: echConfig,
+					ClientECHConfig: echConfig.ECHConfig,
 					ECHCipherSuites: []HPKECipherSuite{cipher.cipher},
 				},
 				flags: []string{
-					"-ech-server-config", base64.StdEncoding.EncodeToString(echConfig.Raw),
-					"-ech-server-key", base64.StdEncoding.EncodeToString(key),
+					"-ech-server-config", base64.StdEncoding.EncodeToString(echConfig.ECHConfig.Raw),
+					"-ech-server-key", base64.StdEncoding.EncodeToString(echConfig.Key),
 					"-ech-is-retry-config", "1",
 					"-expect-server-name", "secret.example",
 					"-expect-ech-accept",
@@ -16629,25 +16605,26 @@
 
 			// Test that the ECH server rejects the specified cipher if not
 			// listed in its ECHConfig.
-			config, key, err := generateECHConfigWithKey("public.example", []HPKECipherSuite{otherCipher.cipher}, 42)
-			if err != nil {
-				panic(err)
-			}
+			config := generateServerECHConfig(&ECHConfig{
+				ConfigID:     42,
+				PublicName:   "public.name",
+				CipherSuites: []HPKECipherSuite{otherCipher.cipher},
+			})
 			testCases = append(testCases, testCase{
 				testType: serverTest,
 				protocol: protocol,
 				name:     prefix + "ECH-Server-DisabledCipher-" + cipher.name,
 				config: Config{
 					ServerName:      "secret.example",
-					ClientECHConfig: echConfig,
+					ClientECHConfig: echConfig.ECHConfig,
 					ECHCipherSuites: []HPKECipherSuite{cipher.cipher},
 					Bugs: ProtocolBugs{
-						ExpectECHRetryConfigs: CreateECHConfigList(config.Raw),
+						ExpectECHRetryConfigs: CreateECHConfigList(config.ECHConfig.Raw),
 					},
 				},
 				flags: []string{
-					"-ech-server-config", base64.StdEncoding.EncodeToString(config.Raw),
-					"-ech-server-key", base64.StdEncoding.EncodeToString(key),
+					"-ech-server-config", base64.StdEncoding.EncodeToString(config.ECHConfig.Raw),
+					"-ech-server-key", base64.StdEncoding.EncodeToString(config.Key),
 					"-ech-is-retry-config", "1",
 					"-expect-server-name", "public.example",
 				},
@@ -16662,15 +16639,15 @@
 			name:     prefix + "ECH-Server-ShortClientECHEnc",
 			config: Config{
 				ServerName:      "secret.example",
-				ClientECHConfig: echConfig,
+				ClientECHConfig: echConfig.ECHConfig,
 				Bugs: ProtocolBugs{
-					ExpectECHRetryConfigs: CreateECHConfigList(echConfig.Raw),
+					ExpectECHRetryConfigs: CreateECHConfigList(echConfig.ECHConfig.Raw),
 					TruncateClientECHEnc:  true,
 				},
 			},
 			flags: []string{
-				"-ech-server-config", base64.StdEncoding.EncodeToString(echConfig.Raw),
-				"-ech-server-key", base64.StdEncoding.EncodeToString(key),
+				"-ech-server-config", base64.StdEncoding.EncodeToString(echConfig.ECHConfig.Raw),
+				"-ech-server-key", base64.StdEncoding.EncodeToString(echConfig.Key),
 				"-ech-is-retry-config", "1",
 				"-expect-server-name", "public.example",
 			},
@@ -16684,15 +16661,15 @@
 			name:     prefix + "ECH-Server-CorruptEncryptedClientHello",
 			config: Config{
 				ServerName:      "secret.example",
-				ClientECHConfig: echConfig,
+				ClientECHConfig: echConfig.ECHConfig,
 				Bugs: ProtocolBugs{
-					ExpectECHRetryConfigs:       CreateECHConfigList(echConfig.Raw),
+					ExpectECHRetryConfigs:       CreateECHConfigList(echConfig.ECHConfig.Raw),
 					CorruptEncryptedClientHello: true,
 				},
 			},
 			flags: []string{
-				"-ech-server-config", base64.StdEncoding.EncodeToString(echConfig.Raw),
-				"-ech-server-key", base64.StdEncoding.EncodeToString(key),
+				"-ech-server-config", base64.StdEncoding.EncodeToString(echConfig.ECHConfig.Raw),
+				"-ech-server-key", base64.StdEncoding.EncodeToString(echConfig.Key),
 				"-ech-is-retry-config", "1",
 			},
 		})
@@ -16705,7 +16682,7 @@
 			name:     prefix + "ECH-Server-CorruptSecondEncryptedClientHello",
 			config: Config{
 				ServerName:      "secret.example",
-				ClientECHConfig: echConfig,
+				ClientECHConfig: echConfig.ECHConfig,
 				// Force a HelloRetryRequest.
 				DefaultCurves: []CurveID{},
 				Bugs: ProtocolBugs{
@@ -16713,8 +16690,8 @@
 				},
 			},
 			flags: []string{
-				"-ech-server-config", base64.StdEncoding.EncodeToString(echConfig.Raw),
-				"-ech-server-key", base64.StdEncoding.EncodeToString(key),
+				"-ech-server-config", base64.StdEncoding.EncodeToString(echConfig.ECHConfig.Raw),
+				"-ech-server-key", base64.StdEncoding.EncodeToString(echConfig.Key),
 				"-ech-is-retry-config", "1",
 			},
 			shouldFail:         true,
@@ -16729,7 +16706,7 @@
 			name:     prefix + "ECH-Server-OmitSecondEncryptedClientHello",
 			config: Config{
 				ServerName:      "secret.example",
-				ClientECHConfig: echConfig,
+				ClientECHConfig: echConfig.ECHConfig,
 				// Force a HelloRetryRequest.
 				DefaultCurves: []CurveID{},
 				Bugs: ProtocolBugs{
@@ -16737,8 +16714,8 @@
 				},
 			},
 			flags: []string{
-				"-ech-server-config", base64.StdEncoding.EncodeToString(echConfig.Raw),
-				"-ech-server-key", base64.StdEncoding.EncodeToString(key),
+				"-ech-server-config", base64.StdEncoding.EncodeToString(echConfig.ECHConfig.Raw),
+				"-ech-server-key", base64.StdEncoding.EncodeToString(echConfig.Key),
 				"-ech-is-retry-config", "1",
 			},
 			shouldFail:         true,
@@ -16753,7 +16730,7 @@
 			name:     prefix + "ECH-Server-DifferentConfigIDSecondClientHello",
 			config: Config{
 				ServerName:      "secret.example",
-				ClientECHConfig: echConfig,
+				ClientECHConfig: echConfig.ECHConfig,
 				// Force a HelloRetryRequest.
 				DefaultCurves: []CurveID{},
 				Bugs: ProtocolBugs{
@@ -16761,8 +16738,8 @@
 				},
 			},
 			flags: []string{
-				"-ech-server-config", base64.StdEncoding.EncodeToString(echConfig.Raw),
-				"-ech-server-key", base64.StdEncoding.EncodeToString(key),
+				"-ech-server-config", base64.StdEncoding.EncodeToString(echConfig.ECHConfig.Raw),
+				"-ech-server-key", base64.StdEncoding.EncodeToString(echConfig.Key),
 				"-ech-is-retry-config", "1",
 			},
 			shouldFail:         true,
@@ -16777,13 +16754,13 @@
 			name:     prefix + "ECH-Server-EarlyData",
 			config: Config{
 				ServerName:      "secret.example",
-				ClientECHConfig: echConfig,
+				ClientECHConfig: echConfig.ECHConfig,
 			},
 			resumeSession: true,
 			earlyData:     true,
 			flags: []string{
-				"-ech-server-config", base64.StdEncoding.EncodeToString(echConfig.Raw),
-				"-ech-server-key", base64.StdEncoding.EncodeToString(key),
+				"-ech-server-config", base64.StdEncoding.EncodeToString(echConfig.ECHConfig.Raw),
+				"-ech-server-key", base64.StdEncoding.EncodeToString(echConfig.Key),
 				"-ech-is-retry-config", "1",
 				"-expect-ech-accept",
 			},
@@ -16797,7 +16774,7 @@
 			name:     prefix + "ECH-Server-EarlyDataRejected",
 			config: Config{
 				ServerName:      "secret.example",
-				ClientECHConfig: echConfig,
+				ClientECHConfig: echConfig.ECHConfig,
 				Bugs: ProtocolBugs{
 					// Cause the server to reject 0-RTT with a bad ticket age.
 					SendTicketAge: 1 * time.Hour,
@@ -16807,8 +16784,8 @@
 			earlyData:               true,
 			expectEarlyDataRejected: true,
 			flags: []string{
-				"-ech-server-config", base64.StdEncoding.EncodeToString(echConfig.Raw),
-				"-ech-server-key", base64.StdEncoding.EncodeToString(key),
+				"-ech-server-config", base64.StdEncoding.EncodeToString(echConfig.ECHConfig.Raw),
+				"-ech-server-key", base64.StdEncoding.EncodeToString(echConfig.Key),
 				"-ech-is-retry-config", "1",
 				"-expect-ech-accept",
 			},
@@ -16825,7 +16802,7 @@
 			name:     prefix + "ECH-Server-Disabled",
 			config: Config{
 				ServerName:      "secret.example",
-				ClientECHConfig: echConfig,
+				ClientECHConfig: echConfig.ECHConfig,
 			},
 			flags: []string{
 				"-expect-server-name", "public.example",
@@ -16892,7 +16869,7 @@
 					// Include an additional well-formed ECHConfig with an
 					// unsupported version. This ensures the client can skip
 					// unsupported configs.
-					SendECHRetryConfigs: CreateECHConfigList(echConfig.Raw, unsupportedVersion),
+					SendECHRetryConfigs: CreateECHConfigList(echConfig.ECHConfig.Raw, unsupportedVersion),
 				},
 			},
 			flags: []string{"-enable-ech-grease"},
@@ -16909,7 +16886,7 @@
 					MaxVersion: VersionTLS12,
 					Bugs: ProtocolBugs{
 						ExpectClientECH:                       true,
-						SendECHRetryConfigs:                   CreateECHConfigList(echConfig.Raw, unsupportedVersion),
+						SendECHRetryConfigs:                   CreateECHConfigList(echConfig.ECHConfig.Raw, unsupportedVersion),
 						SendECHRetryConfigsInTLS12ServerHello: true,
 					},
 				},
@@ -17021,7 +16998,7 @@
 			config: Config{
 				MinVersion:      VersionTLS13,
 				MaxVersion:      VersionTLS13,
-				ClientECHConfig: echConfig,
+				ClientECHConfig: echConfig.ECHConfig,
 				Bugs: ProtocolBugs{
 					AlwaysSendECHIsInner: true,
 				},