From cf87e4e4b9f564d02fdc4151a5fb7596941dd1f6 Mon Sep 17 00:00:00 2001
From: Shi Yue <34417826+hasbai@users.noreply.github.com>
Date: Fri, 27 Jun 2025 14:56:47 +0800
Subject: [PATCH 1/6] Add support for server-side Encrypted Client Hello(ECH).
---
go.mod | 2 +
go.sum | 2 +
pkg/provider/file/file.go | 10 ++
pkg/tls/ech.go | 228 ++++++++++++++++++++++++++++++++++++++
pkg/tls/ech_test.go | 92 +++++++++++++++
pkg/tls/tls.go | 17 +--
pkg/tls/tlsmanager.go | 18 +++
7 files changed, 361 insertions(+), 8 deletions(-)
create mode 100644 pkg/tls/ech.go
create mode 100644 pkg/tls/ech_test.go
diff --git a/go.mod b/go.mod
index 5c0935e89..3e1fdb156 100644
--- a/go.mod
+++ b/go.mod
@@ -122,6 +122,8 @@ require (
sigs.k8s.io/yaml v1.6.0
)
+require github.com/cloudflare/circl v1.3.7
+
require (
cloud.google.com/go/auth v0.18.0 // indirect
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
diff --git a/go.sum b/go.sum
index 00b764cce..cc4379747 100644
--- a/go.sum
+++ b/go.sum
@@ -294,6 +294,8 @@ github.com/clbanning/mxj v1.8.4/go.mod h1:BVjHeAH+rl9rs6f+QIpeRl0tfu10SXn1pUSa5P
github.com/clbanning/mxj/v2 v2.7.0 h1:WA/La7UGCanFe5NpHF0Q3DNtnCsVoxbPKuyBNHWRyME=
github.com/clbanning/mxj/v2 v2.7.0/go.mod h1:hNiWqW14h+kc+MdF9C6/YoRfjEJoR3ou6tn/Qo+ve2s=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
+github.com/cloudflare/circl v1.3.7 h1:qlCDlTPz2n9fu58M0Nh1J/JzcFpfgkFHHX3O35r5vcU=
+github.com/cloudflare/circl v1.3.7/go.mod h1:sRTcRWXGLrKw6yIGJ+l7amYJFfAXbZG0kBSc8r4zxgA=
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
github.com/cncf/xds/go v0.0.0-20210312221358-fbca930ec8ed/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
diff --git a/pkg/provider/file/file.go b/pkg/provider/file/file.go
index a93d4b551..626a12bf5 100644
--- a/pkg/provider/file/file.go
+++ b/pkg/provider/file/file.go
@@ -267,6 +267,16 @@ func (p *Provider) loadFileConfig(ctx context.Context, filename string, parseTem
}
options.ClientAuth.CAFiles = caCerts
+ for i, echKey := range options.ECHKeys {
+ content, err := echKey.Read()
+ if err != nil {
+ log.Ctx(ctx).Error().Err(err).Send()
+ continue
+ }
+
+ options.ECHKeys[i] = types.FileOrContent(content)
+ }
+
configuration.TLS.Options[name] = options
}
}
diff --git a/pkg/tls/ech.go b/pkg/tls/ech.go
new file mode 100644
index 000000000..19191abd1
--- /dev/null
+++ b/pkg/tls/ech.go
@@ -0,0 +1,228 @@
+package tls
+
+import (
+ "crypto/tls"
+ "encoding/binary"
+ "encoding/pem"
+ "fmt"
+ "github.com/cloudflare/circl/hpke"
+ "golang.org/x/crypto/cryptobyte"
+ "log"
+ "math/rand/v2"
+ "net/http"
+ "net/url"
+)
+
+func UnmarshalECHKey(data []byte) (*tls.EncryptedClientHelloKey, error) {
+ var k tls.EncryptedClientHelloKey
+ for {
+ block, rest := pem.Decode(data)
+ if block == nil {
+ break
+ }
+
+ switch block.Type {
+ case "PRIVATE KEY":
+ k.PrivateKey = block.Bytes
+ case "ECHCONFIG":
+ k.Config = block.Bytes[2:] // Skip the first two bytes (length prefix)
+ default:
+ return nil, fmt.Errorf("unknown PEM block %s", block.Type)
+ }
+
+ data = rest
+ }
+
+ if len(k.Config) == 0 || len(k.PrivateKey) == 0 {
+ return nil, fmt.Errorf("lack of ECH configuration or private key in PEM file")
+ }
+
+ // go ecdh now only supports SHA-256 (32-byte private key)
+ if len(k.PrivateKey) < 32 {
+ return nil, fmt.Errorf("invalid private key length: expected at least 32 bytes, got %d bytes", len(k.PrivateKey))
+ } else if len(k.PrivateKey) > 32 {
+ k.PrivateKey = k.PrivateKey[len(k.PrivateKey)-32:]
+ }
+
+ k.SendAsRetry = true
+
+ return &k, nil
+}
+
+func MarshalECHKey(k *tls.EncryptedClientHelloKey) ([]byte, error) {
+ if len(k.Config) == 0 || len(k.PrivateKey) == 0 {
+ return nil, fmt.Errorf("lack of ECH configuration or private key")
+ }
+ lengthPrefix := make([]byte, 2)
+ binary.BigEndian.PutUint16(lengthPrefix, uint16(len(k.Config)))
+ configBytes := append(lengthPrefix, k.Config...)
+ var pemData []byte
+ pemData = append(pemData, pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: k.PrivateKey})...)
+ pemData = append(pemData, pem.EncodeToMemory(&pem.Block{Type: "ECHCONFIG", Bytes: configBytes})...)
+
+ return pemData, nil
+}
+
+type echCipher struct {
+ KDFID uint16
+ AEADID uint16
+}
+
+type echExtension struct {
+ Type uint16
+ Data []byte
+}
+
+type echConfig struct {
+ raw []byte
+
+ Version uint16
+ Length uint16
+
+ ConfigID uint8
+ KemID uint16
+ PublicKey []byte
+ SymmetricCipherSuite []echCipher
+
+ MaxNameLength uint8
+ PublicName []byte
+ Extensions []echExtension
+}
+
+func NewECHKey(publicName string) (*tls.EncryptedClientHelloKey, error) {
+ publicKey, privateKey, err := hpke.KEM_X25519_HKDF_SHA256.Scheme().GenerateKeyPair()
+ if err != nil {
+ return nil, err
+ }
+ publicKeyBytes, err := publicKey.MarshalBinary()
+ if err != nil {
+ return nil, err
+ }
+ privateKeyBytes, err := privateKey.MarshalBinary()
+ if err != nil {
+ return nil, err
+ }
+
+ config := echConfig{
+ Version: 0xfe0d, // ECH version 0xfe0d
+ Length: 0x0000,
+ ConfigID: uint8(rand.Uint()),
+ KemID: uint16(hpke.KEM_X25519_HKDF_SHA256),
+ PublicKey: publicKeyBytes,
+ SymmetricCipherSuite: []echCipher{
+ {KDFID: uint16(hpke.KDF_HKDF_SHA256), AEADID: uint16(hpke.AEAD_AES256GCM)},
+ },
+ MaxNameLength: 32,
+ PublicName: []byte(publicName),
+ Extensions: nil,
+ }
+ if len(config.PublicName) > int(config.MaxNameLength) {
+ return nil, fmt.Errorf("public name exceeds maximum length of %d bytes", config.MaxNameLength)
+ }
+
+ var b cryptobyte.Builder
+ b.AddUint16(config.Version) // Version
+ b.AddUint16LengthPrefixed(func(b *cryptobyte.Builder) {
+ b.AddUint8(config.ConfigID)
+ b.AddUint16(config.KemID)
+ b.AddUint16(uint16(len(config.PublicKey)))
+ b.AddBytes(config.PublicKey)
+ b.AddUint16LengthPrefixed(func(c *cryptobyte.Builder) {
+ for _, cipher := range config.SymmetricCipherSuite {
+ c.AddUint16(cipher.KDFID)
+ c.AddUint16(cipher.AEADID)
+ }
+ })
+ b.AddUint8(config.MaxNameLength)
+ b.AddUint8(uint8(len(config.PublicName)))
+ b.AddBytes(config.PublicName)
+ b.AddUint16LengthPrefixed(func(c *cryptobyte.Builder) {
+ for _, ext := range config.Extensions {
+ c.AddUint16(ext.Type)
+ c.AddUint16(uint16(len(ext.Data)))
+ c.AddBytes(ext.Data)
+ }
+ })
+ })
+ configBytes, err := b.Bytes()
+ if err != nil {
+ return nil, err
+ }
+
+ return &tls.EncryptedClientHelloKey{
+ Config: configBytes,
+ PrivateKey: privateKeyBytes,
+ SendAsRetry: true,
+ }, nil
+}
+
+func MarshalEncryptedClientHelloConfigList(configs []tls.EncryptedClientHelloKey) ([]byte, error) {
+ builder := cryptobyte.NewBuilder(nil)
+ builder.AddUint16LengthPrefixed(func(builder *cryptobyte.Builder) {
+ for _, c := range configs {
+ builder.AddBytes(c.Config)
+ }
+ })
+ return builder.Bytes()
+}
+
+// startECHServer starts a TLS server that supports Encrypted Client Hello (ECH).
+func startECHServer(bind string, cert tls.Certificate, echKey tls.EncryptedClientHelloKey) {
+ mux := http.NewServeMux()
+ mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
+ fmt.Fprintf(w, "Hello, ECH-enabled TLS server!")
+ })
+
+ server := &http.Server{
+ Addr: bind,
+ Handler: mux,
+ TLSConfig: &tls.Config{
+ Certificates: []tls.Certificate{cert},
+ MinVersion: tls.VersionTLS13,
+ EncryptedClientHelloKeys: []tls.EncryptedClientHelloKey{echKey},
+ },
+ }
+
+ if err := server.ListenAndServeTLS("", ""); err != nil {
+ log.Fatalf("server error: %v", err)
+ }
+}
+
+// RequestWithECH sends request to a server using the provided ECH configuration.
+func RequestWithECH(rawURL string, host string, echKey tls.EncryptedClientHelloKey) []byte {
+ configList, err := MarshalEncryptedClientHelloConfigList([]tls.EncryptedClientHelloKey{echKey})
+ if err != nil {
+ log.Fatalf("marshal ECH config list error: %v", err)
+ }
+
+ client := &http.Client{
+ Transport: &http.Transport{
+ TLSClientConfig: &tls.Config{
+ ServerName: host,
+ EncryptedClientHelloConfigList: configList,
+ MinVersion: tls.VersionTLS13,
+ InsecureSkipVerify: true, // For testing purposes, skip TLS verification
+ },
+ },
+ }
+
+ requestURL, _ := url.Parse(rawURL)
+ req := &http.Request{
+ Method: "GET",
+ URL: requestURL,
+ Host: host,
+ }
+ resp, err := client.Do(req)
+ if err != nil {
+ log.Fatalf("Request error: %v", err)
+ }
+ defer resp.Body.Close()
+
+ body := make([]byte, 1024)
+ n, _ := resp.Body.Read(body)
+ fmt.Printf("server response: %s\n", body[:n])
+ fmt.Printf("Status code: %d\n", resp.StatusCode)
+ fmt.Printf("Response header: %v\n", resp.Header)
+
+ return body[:n]
+}
diff --git a/pkg/tls/ech_test.go b/pkg/tls/ech_test.go
new file mode 100644
index 000000000..5788f0833
--- /dev/null
+++ b/pkg/tls/ech_test.go
@@ -0,0 +1,92 @@
+package tls
+
+import (
+ "crypto/rand"
+ "crypto/rsa"
+ "crypto/tls"
+ "crypto/x509"
+ "crypto/x509/pkix"
+ "encoding/pem"
+ "fmt"
+ "math/big"
+ "reflect"
+ "testing"
+ "time"
+)
+
+func TestECH(t *testing.T) {
+ const commonName = "server.local"
+
+ echKey, err := NewECHKey(commonName)
+ if err != nil {
+ t.Fatalf("Failed to generate ECH key: %v", err)
+ }
+
+ echKeyBytes, err := MarshalECHKey(echKey)
+ if err != nil {
+ t.Fatalf("Failed to marshal ECH key: %v", err)
+ }
+
+ newKey, err := UnmarshalECHKey(echKeyBytes)
+ if err != nil {
+ t.Fatalf("Failed to unmarshal ECH key: %v", err)
+ }
+
+ if !reflect.DeepEqual(*echKey, *newKey) {
+ t.Fatal("Parsed ECH key does not match original")
+ }
+
+ testCert, err := generateCert(commonName)
+ if err != nil {
+ t.Fatalf("Failed to load certs: %v", err)
+ }
+
+ go startECHServer("localhost:8443", testCert, *echKey)
+ time.Sleep(1 * time.Second) // Wait for the server to start
+ response := RequestWithECH("https://localhost:8443/", commonName, *echKey)
+ if string(response) != "Hello, ECH-enabled TLS server!" {
+ t.Fatalf("Unexpected response from ECH server: %s", response)
+ }
+}
+
+func generateCert(commonName string) (tls.Certificate, error) {
+ rsaKey, err := rsa.GenerateKey(rand.Reader, 4096)
+ if err != nil {
+ return tls.Certificate{}, fmt.Errorf("failed to generate RSA key: %w", err)
+ }
+ keyBytes := x509.MarshalPKCS1PrivateKey(rsaKey)
+ keyPEM := pem.EncodeToMemory(
+ &pem.Block{
+ Type: "RSA PRIVATE KEY",
+ Bytes: keyBytes,
+ },
+ )
+
+ notBefore := time.Now()
+ notAfter := notBefore.Add(365 * 24 * 10 * time.Hour)
+
+ template := x509.Certificate{
+ SerialNumber: big.NewInt(0),
+ Subject: pkix.Name{CommonName: commonName},
+ DNSNames: []string{commonName},
+ SignatureAlgorithm: x509.SHA256WithRSA,
+ NotBefore: notBefore,
+ NotAfter: notAfter,
+ BasicConstraintsValid: true,
+ KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyAgreement | x509.KeyUsageKeyEncipherment | x509.KeyUsageDataEncipherment,
+ ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth},
+ }
+
+ derBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, &rsaKey.PublicKey, rsaKey)
+ if err != nil {
+ return tls.Certificate{}, fmt.Errorf("failed to create certificate: %w", err)
+ }
+ certPEM := pem.EncodeToMemory(
+ &pem.Block{
+ Type: "CERTIFICATE",
+ Bytes: derBytes,
+ },
+ )
+
+ return tls.X509KeyPair(certPEM, keyPEM)
+}
diff --git a/pkg/tls/tls.go b/pkg/tls/tls.go
index 1a60a8427..9cff8d6d0 100644
--- a/pkg/tls/tls.go
+++ b/pkg/tls/tls.go
@@ -42,14 +42,15 @@ type ClientAuth struct {
// Options configures TLS for an entry point.
type Options struct {
- MinVersion string `json:"minVersion,omitempty" toml:"minVersion,omitempty" yaml:"minVersion,omitempty" export:"true"`
- MaxVersion string `json:"maxVersion,omitempty" toml:"maxVersion,omitempty" yaml:"maxVersion,omitempty" export:"true"`
- CipherSuites []string `json:"cipherSuites,omitempty" toml:"cipherSuites,omitempty" yaml:"cipherSuites,omitempty" export:"true"`
- CurvePreferences []string `json:"curvePreferences,omitempty" toml:"curvePreferences,omitempty" yaml:"curvePreferences,omitempty" export:"true"`
- ClientAuth ClientAuth `json:"clientAuth,omitempty" toml:"clientAuth,omitempty" yaml:"clientAuth,omitempty"`
- SniStrict bool `json:"sniStrict,omitempty" toml:"sniStrict,omitempty" yaml:"sniStrict,omitempty" export:"true"`
- ALPNProtocols []string `json:"alpnProtocols,omitempty" toml:"alpnProtocols,omitempty" yaml:"alpnProtocols,omitempty" export:"true"`
- DisableSessionTickets bool `json:"disableSessionTickets,omitempty" toml:"disableSessionTickets,omitempty" yaml:"disableSessionTickets,omitempty" export:"true"`
+ MinVersion string `json:"minVersion,omitempty" toml:"minVersion,omitempty" yaml:"minVersion,omitempty" export:"true"`
+ MaxVersion string `json:"maxVersion,omitempty" toml:"maxVersion,omitempty" yaml:"maxVersion,omitempty" export:"true"`
+ CipherSuites []string `json:"cipherSuites,omitempty" toml:"cipherSuites,omitempty" yaml:"cipherSuites,omitempty" export:"true"`
+ CurvePreferences []string `json:"curvePreferences,omitempty" toml:"curvePreferences,omitempty" yaml:"curvePreferences,omitempty" export:"true"`
+ ClientAuth ClientAuth `json:"clientAuth,omitempty" toml:"clientAuth,omitempty" yaml:"clientAuth,omitempty"`
+ SniStrict bool `json:"sniStrict,omitempty" toml:"sniStrict,omitempty" yaml:"sniStrict,omitempty" export:"true"`
+ ALPNProtocols []string `json:"alpnProtocols,omitempty" toml:"alpnProtocols,omitempty" yaml:"alpnProtocols,omitempty" export:"true"`
+ DisableSessionTickets bool `json:"disableSessionTickets,omitempty" toml:"disableSessionTickets,omitempty" yaml:"disableSessionTickets,omitempty" export:"true"`
+ ECHKeys []types.FileOrContent `json:"echKeys,omitempty" toml:"echKeys,omitempty" yaml:"echKeys,omitempty" export:"true"`
// Deprecated: https://github.com/golang/go/issues/45430
PreferServerCipherSuites *bool `json:"preferServerCipherSuites,omitempty" toml:"preferServerCipherSuites,omitempty" yaml:"preferServerCipherSuites,omitempty" export:"true"`
diff --git a/pkg/tls/tlsmanager.go b/pkg/tls/tlsmanager.go
index 9003c21f3..260741ada 100644
--- a/pkg/tls/tlsmanager.go
+++ b/pkg/tls/tlsmanager.go
@@ -513,6 +513,24 @@ func buildTLSConfig(tlsOption Options) (*tls.Config, error) {
}
}
+ // Set the EncryptedClientHelloKeys if set in the config
+ if tlsOption.ECHKeys != nil {
+ conf.EncryptedClientHelloKeys = make([]tls.EncryptedClientHelloKey, 0, len(tlsOption.ECHKeys))
+ for _, content := range tlsOption.ECHKeys {
+ data, err := content.Read()
+ if err != nil {
+ return nil, fmt.Errorf("reading ECH key file failed: %w", err)
+ }
+
+ echKey, err := UnmarshalECHKey(data)
+ if err != nil {
+ return nil, fmt.Errorf("unmarshalling ECH key failed: %w", err)
+ }
+
+ conf.EncryptedClientHelloKeys = append(conf.EncryptedClientHelloKeys, *echKey)
+ }
+ }
+
return conf, nil
}
From e69de320c5145d28765513be39698ec942773bdb Mon Sep 17 00:00:00 2001
From: Shi Yue <34417826+hasbai@users.noreply.github.com>
Date: Fri, 27 Jun 2025 16:16:22 +0800
Subject: [PATCH 2/6] Add documentation for ECH keys.
---
docs/content/https/tls.md | 50 +++++++++++++++++++++++++++++++++++++++
1 file changed, 50 insertions(+)
diff --git a/docs/content/https/tls.md b/docs/content/https/tls.md
index abda2dd93..da311ba92 100644
--- a/docs/content/https/tls.md
+++ b/docs/content/https/tls.md
@@ -587,4 +587,54 @@ spec:
disableSessionTickets: true
```
+### Encrypted Client Hello Keys
+
+_Optional_
+
+The `ECH Keys` option enables the server-side ECH feature. This option does not impact clients that do not support ECH.
+
+The configuration file should be in PEM format, and requires both a private key and an ECH configuration block.
+[Reference](https://www.ietf.org/archive/id/draft-farrell-tls-pemesni-09.html)
+
+Below is an example of the configuration file:
+```text
+-----BEGIN PRIVATE KEY-----
+MC4CAQAwBQYDK2VuBCIEICjd4yGRdsoP9gU7YT7My8DHx1Tjme8GYDXrOMCi8v1V
+-----END PRIVATE KEY-----
+-----BEGIN ECHCONFIG-----
+AD7+DQA65wAgACA8wVN2BtscOl3vQheUzHeIkVmKIiydUhDCliA4iyQRCwAEAAEA
+AQALZXhhbXBsZS5jb20AAA==
+-----END ECHCONFIG-----
+```
+
+```yaml tab="File (YAML)"
+# Dynamic configuration
+
+tls:
+ options:
+ default:
+ echKeys:
+ - example.pem
+```
+
+```toml tab="File (TOML)"
+# Dynamic configuration
+
+[tls.options]
+ [tls.options.default]
+ echKeys = ["example.pem"]
+```
+
+```yaml tab="Kubernetes"
+apiVersion: traefik.io/v1alpha1
+kind: TLSOption
+metadata:
+ name: default
+ namespace: default
+
+spec:
+ echKeys:
+ - example.pem
+```
+
{% include-markdown "includes/traefik-for-business-applications.md" %}
From e72aa991f0f189eea0509e6eadbff53ebfcd0f5e Mon Sep 17 00:00:00 2001
From: Shi Yue <34417826+hasbai@users.noreply.github.com>
Date: Sat, 28 Jun 2025 03:17:23 +0800
Subject: [PATCH 3/6] cmd tool to generate ech key
---
cmd/ech/ech.go | 111 ++++++++++++++++++++++++++++++++++++++
cmd/traefik/traefik.go | 7 +++
docs/content/https/tls.md | 8 ++-
pkg/tls/ech.go | 62 +++++++++++++--------
pkg/tls/ech_test.go | 16 +++++-
5 files changed, 179 insertions(+), 25 deletions(-)
create mode 100644 cmd/ech/ech.go
diff --git a/cmd/ech/ech.go b/cmd/ech/ech.go
new file mode 100644
index 000000000..63e35ebe3
--- /dev/null
+++ b/cmd/ech/ech.go
@@ -0,0 +1,111 @@
+package ech
+
+import (
+ "bytes"
+ "flag"
+ "github.com/pkg/errors"
+ "github.com/traefik/paerser/cli"
+ "github.com/traefik/traefik/v3/pkg/tls"
+ "io"
+ stdlog "log"
+ "os"
+)
+
+// NewCmd builds a new Version command.
+func NewCmd() *cli.Command {
+ cmd := cli.Command{
+ Name: "ech",
+ Description: `Encrypted Client Hello (ECH) utils.`,
+ Run: nil,
+ }
+
+ var err error
+ err = cmd.AddCommand(generate())
+ if err != nil {
+ stdlog.Println(err)
+ os.Exit(1)
+ }
+
+ err = cmd.AddCommand(request())
+ if err != nil {
+ stdlog.Println(err)
+ os.Exit(1)
+ }
+
+ buf := bytes.NewBuffer(nil)
+ err = cmd.PrintHelp(buf)
+ if err != nil {
+ stdlog.Println(err)
+ os.Exit(1)
+ }
+ cmd.Run = func(_ []string) error {
+ if _, err = os.Stdout.Write(buf.Bytes()); err != nil {
+ return err
+ }
+ return nil
+ }
+
+ return &cmd
+}
+
+func generate() *cli.Command {
+ help := []byte(`Usage: ech generate SNI [SNI ...]`)
+ cmd := cli.Command{
+ Name: "generate",
+ Description: "Generate a new ECH key with given outer sni.",
+ AllowArg: true,
+ CustomHelpFunc: func(writer io.Writer, command *cli.Command) error {
+ _, err := writer.Write(help)
+ return err
+ },
+ Run: func(names []string) error {
+ if len(names) == 0 {
+ if _, err := os.Stdout.Write(help); err != nil {
+ return err
+ }
+ }
+ for _, name := range names {
+ key, err := tls.NewECHKey(name)
+ if err != nil {
+ return errors.Wrapf(err, "failed to generate ECH key for %s", name)
+ }
+ data, err := tls.MarshalECHKey(key)
+ if err != nil {
+ return errors.Wrapf(err, "failed to marshal ECH key for %s", name)
+ }
+ if _, err = os.Stdout.Write(data); err != nil {
+ return errors.Wrapf(err, "failed to write ECH key for %s", name)
+ }
+ }
+ return nil
+ },
+ }
+ return &cmd
+}
+
+func request() *cli.Command {
+ return &cli.Command{
+ Name: "request",
+ Description: "Make an ECH request.",
+ AllowArg: true,
+ CustomHelpFunc: func(writer io.Writer, command *cli.Command) error {
+ _, err := writer.Write([]byte(`Usage: ech request URL -e ECH [-h HOST] [-k])`))
+ return err
+ },
+ Run: func(args []string) error {
+ c := tls.ECHRequestConf[string]{}
+ fs := flag.NewFlagSet("ech request", flag.ContinueOnError)
+ c.URL = fs.Arg(0)
+ c.ECH = *fs.String("ech", "", "A base64-encoded ECH public config list.")
+ c.Host = *fs.String("h", "", "The host to use in the request. If not set, it will be derived from the URL.")
+ c.Insecure = *fs.Bool("k", false, "Allow insecure server connections when using SSL.")
+ fs.SetOutput(os.Stderr)
+ if err := fs.Parse(args); err != nil {
+ return err
+ }
+
+ _, err := tls.RequestWithECH(c)
+ return err
+ },
+ }
+}
diff --git a/cmd/traefik/traefik.go b/cmd/traefik/traefik.go
index adc8c358b..c581d09c2 100644
--- a/cmd/traefik/traefik.go
+++ b/cmd/traefik/traefik.go
@@ -23,6 +23,7 @@ import (
"github.com/spiffe/go-spiffe/v2/workloadapi"
"github.com/traefik/paerser/cli"
"github.com/traefik/traefik/v3/cmd"
+ cmdECH "github.com/traefik/traefik/v3/cmd/ech"
"github.com/traefik/traefik/v3/cmd/healthcheck"
cmdVersion "github.com/traefik/traefik/v3/cmd/version"
tcli "github.com/traefik/traefik/v3/pkg/cli"
@@ -80,6 +81,12 @@ Complete documentation is available at https://traefik.io`,
os.Exit(1)
}
+ err = cmdTraefik.AddCommand(cmdECH.NewCmd())
+ if err != nil {
+ stdlog.Println(err)
+ os.Exit(1)
+ }
+
err = cli.Execute(cmdTraefik)
if err != nil {
log.Error().Err(err).Msg("Command error")
diff --git a/docs/content/https/tls.md b/docs/content/https/tls.md
index da311ba92..4fa1511ed 100644
--- a/docs/content/https/tls.md
+++ b/docs/content/https/tls.md
@@ -593,7 +593,7 @@ _Optional_
The `ECH Keys` option enables the server-side ECH feature. This option does not impact clients that do not support ECH.
-The configuration file should be in PEM format, and requires both a private key and an ECH configuration block.
+The configuration file should be in PEM format and requires both a private key and an ECH configuration block.
[Reference](https://www.ietf.org/archive/id/draft-farrell-tls-pemesni-09.html)
Below is an example of the configuration file:
@@ -607,6 +607,12 @@ AQALZXhhbXBsZS5jb20AAA==
-----END ECHCONFIG-----
```
+To generate the ECH configuration, you can run:
+```bash
+traefik ech generate example.com
+```
+
+
```yaml tab="File (YAML)"
# Dynamic configuration
diff --git a/pkg/tls/ech.go b/pkg/tls/ech.go
index 19191abd1..4bf510c4d 100644
--- a/pkg/tls/ech.go
+++ b/pkg/tls/ech.go
@@ -2,6 +2,7 @@ package tls
import (
"crypto/tls"
+ "encoding/base64"
"encoding/binary"
"encoding/pem"
"fmt"
@@ -156,16 +157,6 @@ func NewECHKey(publicName string) (*tls.EncryptedClientHelloKey, error) {
}, nil
}
-func MarshalEncryptedClientHelloConfigList(configs []tls.EncryptedClientHelloKey) ([]byte, error) {
- builder := cryptobyte.NewBuilder(nil)
- builder.AddUint16LengthPrefixed(func(builder *cryptobyte.Builder) {
- for _, c := range configs {
- builder.AddBytes(c.Config)
- }
- })
- return builder.Bytes()
-}
-
// startECHServer starts a TLS server that supports Encrypted Client Hello (ECH).
func startECHServer(bind string, cert tls.Certificate, echKey tls.EncryptedClientHelloKey) {
mux := http.NewServeMux()
@@ -188,41 +179,66 @@ func startECHServer(bind string, cert tls.Certificate, echKey tls.EncryptedClien
}
}
-// RequestWithECH sends request to a server using the provided ECH configuration.
-func RequestWithECH(rawURL string, host string, echKey tls.EncryptedClientHelloKey) []byte {
- configList, err := MarshalEncryptedClientHelloConfigList([]tls.EncryptedClientHelloKey{echKey})
- if err != nil {
- log.Fatalf("marshal ECH config list error: %v", err)
+type ECHRequestConf[T []byte | string] struct {
+ URL string `description:"The URL to request." json:"u" export:"true"`
+ Host string `description:"The host/sni to request with." json:"h" export:"true"`
+ ECH T `description:"A base64-encoded ECH public config list." json:"ech" export:"true"`
+ Insecure bool `description:"If true, skip TLS verification (for testing purposes)." json:"k" export:"true"`
+}
+
+// RequestWithECH sends a GET request to a server using the provided ECH configuration.
+func RequestWithECH[T []byte | string](c ECHRequestConf[T]) (body []byte, err error) {
+ // Decode the ECH configuration from base64 if it's a string, otherwise use it directly.
+ var ech []byte
+ if s, ok := any(c.ECH).(string); ok {
+ ech, err = base64.StdEncoding.DecodeString(s)
+ if err != nil {
+ return nil, err
+ }
+ } else {
+ ech = []byte(c.ECH)
+ }
+
+ requestURL, _ := url.Parse(c.URL)
+ if c.Host == "" {
+ c.Host = requestURL.Hostname()
}
client := &http.Client{
Transport: &http.Transport{
TLSClientConfig: &tls.Config{
- ServerName: host,
- EncryptedClientHelloConfigList: configList,
+ ServerName: c.Host,
+ EncryptedClientHelloConfigList: ech,
MinVersion: tls.VersionTLS13,
- InsecureSkipVerify: true, // For testing purposes, skip TLS verification
+ InsecureSkipVerify: c.Insecure,
},
},
}
- requestURL, _ := url.Parse(rawURL)
req := &http.Request{
Method: "GET",
URL: requestURL,
- Host: host,
+ Host: c.Host,
}
resp, err := client.Do(req)
if err != nil {
- log.Fatalf("Request error: %v", err)
+ return nil, err
}
defer resp.Body.Close()
- body := make([]byte, 1024)
+ body = make([]byte, 1024)
n, _ := resp.Body.Read(body)
fmt.Printf("server response: %s\n", body[:n])
fmt.Printf("Status code: %d\n", resp.StatusCode)
fmt.Printf("Response header: %v\n", resp.Header)
- return body[:n]
+ return body[:n], nil
+}
+
+func ECHConfigToConfigList(echConfig []byte) ([]byte, error) {
+ var b cryptobyte.Builder
+ b.AddUint16LengthPrefixed(func(child *cryptobyte.Builder) {
+ child.AddBytes(echConfig)
+ })
+ return b.Bytes()
}
diff --git a/pkg/tls/ech_test.go b/pkg/tls/ech_test.go
index 5788f0833..1d676e5f5 100644
--- a/pkg/tls/ech_test.go
+++ b/pkg/tls/ech_test.go
@@ -41,9 +41,23 @@ func TestECH(t *testing.T) {
t.Fatalf("Failed to load certs: %v", err)
}
+ echConfigList, err := ECHConfigToConfigList(echKey.Config)
+ if err != nil {
+ t.Fatalf("Failed to convert ECH config to config list: %v", err)
+ }
+
go startECHServer("localhost:8443", testCert, *echKey)
time.Sleep(1 * time.Second) // Wait for the server to start
- response := RequestWithECH("https://localhost:8443/", commonName, *echKey)
+ response, err := RequestWithECH(ECHRequestConf[[]byte]{
+ URL: "https://localhost:8443/",
+ Host: commonName,
+ ECH: echConfigList,
+ Insecure: true,
+ })
+
+ if err != nil {
+ t.Fatalf("Failed to make ECH request: %v", err)
+ }
if string(response) != "Hello, ECH-enabled TLS server!" {
t.Fatalf("Unexpected response from ECH server: %s", response)
}
From fdbe3c6acaf088628d53349f8771a386a6105743 Mon Sep 17 00:00:00 2001
From: Shi Yue <34417826+hasbai@users.noreply.github.com>
Date: Tue, 1 Jul 2025 09:54:09 +0800
Subject: [PATCH 4/6] fix
---
docs/content/https/tls.md | 3 +-
.../reference/dynamic-configuration/file.toml | 2 +
.../reference/dynamic-configuration/file.yaml | 6 +
.../reference/dynamic-configuration/kv-ref.md | 113 +++++++++---------
.../other-providers/file.toml | 2 +
.../other-providers/file.yaml | 6 +
go.mod | 5 +-
7 files changed, 74 insertions(+), 63 deletions(-)
diff --git a/docs/content/https/tls.md b/docs/content/https/tls.md
index 4fa1511ed..90d54d7a4 100644
--- a/docs/content/https/tls.md
+++ b/docs/content/https/tls.md
@@ -597,6 +597,7 @@ The configuration file should be in PEM format and requires both a private key a
[Reference](https://www.ietf.org/archive/id/draft-farrell-tls-pemesni-09.html)
Below is an example of the configuration file:
+
```text
-----BEGIN PRIVATE KEY-----
MC4CAQAwBQYDK2VuBCIEICjd4yGRdsoP9gU7YT7My8DHx1Tjme8GYDXrOMCi8v1V
@@ -608,11 +609,11 @@ AQALZXhhbXBsZS5jb20AAA==
```
To generate the ECH configuration, you can run:
+
```bash
traefik ech generate example.com
```
-
```yaml tab="File (YAML)"
# Dynamic configuration
diff --git a/docs/content/reference/dynamic-configuration/file.toml b/docs/content/reference/dynamic-configuration/file.toml
index b1c8cb4f5..ff5955209 100644
--- a/docs/content/reference/dynamic-configuration/file.toml
+++ b/docs/content/reference/dynamic-configuration/file.toml
@@ -598,6 +598,7 @@
sniStrict = true
alpnProtocols = ["foobar", "foobar"]
disableSessionTickets = true
+ echKeys = ["foobar", "foobar"]
preferServerCipherSuites = true
[tls.options.Options0.clientAuth]
caFiles = ["foobar", "foobar"]
@@ -610,6 +611,7 @@
sniStrict = true
alpnProtocols = ["foobar", "foobar"]
disableSessionTickets = true
+ echKeys = ["foobar", "foobar"]
preferServerCipherSuites = true
[tls.options.Options1.clientAuth]
caFiles = ["foobar", "foobar"]
diff --git a/docs/content/reference/dynamic-configuration/file.yaml b/docs/content/reference/dynamic-configuration/file.yaml
index 392a927a4..a06b7022d 100644
--- a/docs/content/reference/dynamic-configuration/file.yaml
+++ b/docs/content/reference/dynamic-configuration/file.yaml
@@ -673,6 +673,9 @@ tls:
- foobar
- foobar
disableSessionTickets: true
+ echKeys:
+ - foobar
+ - foobar
preferServerCipherSuites: true
Options1:
minVersion: foobar
@@ -693,6 +696,9 @@ tls:
- foobar
- foobar
disableSessionTickets: true
+ echKeys:
+ - foobar
+ - foobar
preferServerCipherSuites: true
stores:
Store0:
diff --git a/docs/content/reference/dynamic-configuration/kv-ref.md b/docs/content/reference/dynamic-configuration/kv-ref.md
index 7a2b9229e..807f912aa 100644
--- a/docs/content/reference/dynamic-configuration/kv-ref.md
+++ b/docs/content/reference/dynamic-configuration/kv-ref.md
@@ -199,7 +199,6 @@ THIS FILE MUST NOT BE EDITED BY HAND
| `traefik/http/routers/Router0/middlewares/1` | `foobar` |
| `traefik/http/routers/Router0/observability/accessLogs` | `true` |
| `traefik/http/routers/Router0/observability/metrics` | `true` |
-| `traefik/http/routers/Router0/observability/traceVerbosity` | `foobar` |
| `traefik/http/routers/Router0/observability/tracing` | `true` |
| `traefik/http/routers/Router0/priority` | `42` |
| `traefik/http/routers/Router0/rule` | `foobar` |
@@ -219,7 +218,6 @@ THIS FILE MUST NOT BE EDITED BY HAND
| `traefik/http/routers/Router1/middlewares/1` | `foobar` |
| `traefik/http/routers/Router1/observability/accessLogs` | `true` |
| `traefik/http/routers/Router1/observability/metrics` | `true` |
-| `traefik/http/routers/Router1/observability/traceVerbosity` | `foobar` |
| `traefik/http/routers/Router1/observability/tracing` | `true` |
| `traefik/http/routers/Router1/priority` | `42` |
| `traefik/http/routers/Router1/rule` | `foobar` |
@@ -282,63 +280,56 @@ THIS FILE MUST NOT BE EDITED BY HAND
| `traefik/http/services/Service01/failover/fallback` | `foobar` |
| `traefik/http/services/Service01/failover/healthCheck` | `` |
| `traefik/http/services/Service01/failover/service` | `foobar` |
-| `traefik/http/services/Service02/highestRandomWeight/healthCheck` | `` |
-| `traefik/http/services/Service02/highestRandomWeight/services/0/name` | `foobar` |
-| `traefik/http/services/Service02/highestRandomWeight/services/0/weight` | `42` |
-| `traefik/http/services/Service02/highestRandomWeight/services/1/name` | `foobar` |
-| `traefik/http/services/Service02/highestRandomWeight/services/1/weight` | `42` |
-| `traefik/http/services/Service03/loadBalancer/healthCheck/followRedirects` | `true` |
-| | `foobar` |
-| | `foobar` |
-| `traefik/http/services/Service03/loadBalancer/healthCheck/hostname` | `foobar` |
-| `traefik/http/services/Service03/loadBalancer/healthCheck/interval` | `42s` |
-| `traefik/http/services/Service03/loadBalancer/healthCheck/method` | `foobar` |
-| `traefik/http/services/Service03/loadBalancer/healthCheck/mode` | `foobar` |
-| `traefik/http/services/Service03/loadBalancer/healthCheck/path` | `foobar` |
-| `traefik/http/services/Service03/loadBalancer/healthCheck/port` | `42` |
-| `traefik/http/services/Service03/loadBalancer/healthCheck/scheme` | `foobar` |
-| `traefik/http/services/Service03/loadBalancer/healthCheck/status` | `42` |
-| `traefik/http/services/Service03/loadBalancer/healthCheck/timeout` | `42s` |
-| `traefik/http/services/Service03/loadBalancer/healthCheck/unhealthyInterval` | `42s` |
-| | `true` |
-| `traefik/http/services/Service03/loadBalancer/passiveHealthCheck/failureWindow` | `42s` |
-| `traefik/http/services/Service03/loadBalancer/passiveHealthCheck/maxFailedAttempts` | `42` |
-| `traefik/http/services/Service03/loadBalancer/responseForwarding/flushInterval` | `42s` |
-| `traefik/http/services/Service03/loadBalancer/servers/0/preservePath` | `true` |
-| `traefik/http/services/Service03/loadBalancer/servers/0/url` | `foobar` |
-| `traefik/http/services/Service03/loadBalancer/servers/0/weight` | `42` |
-| `traefik/http/services/Service03/loadBalancer/servers/1/preservePath` | `true` |
-| `traefik/http/services/Service03/loadBalancer/servers/1/url` | `foobar` |
-| `traefik/http/services/Service03/loadBalancer/servers/1/weight` | `42` |
-| `traefik/http/services/Service03/loadBalancer/serversTransport` | `foobar` |
-| `traefik/http/services/Service03/loadBalancer/sticky/cookie/domain` | `foobar` |
-| `traefik/http/services/Service03/loadBalancer/sticky/cookie/httpOnly` | `true` |
-| `traefik/http/services/Service03/loadBalancer/sticky/cookie/maxAge` | `42` |
-| `traefik/http/services/Service03/loadBalancer/sticky/cookie/name` | `foobar` |
-| `traefik/http/services/Service03/loadBalancer/sticky/cookie/path` | `foobar` |
-| `traefik/http/services/Service03/loadBalancer/sticky/cookie/sameSite` | `foobar` |
-| `traefik/http/services/Service03/loadBalancer/sticky/cookie/secure` | `true` |
-| `traefik/http/services/Service03/loadBalancer/strategy` | `foobar` |
-| `traefik/http/services/Service04/mirroring/healthCheck` | `` |
-| `traefik/http/services/Service04/mirroring/maxBodySize` | `42` |
-| `traefik/http/services/Service04/mirroring/mirrorBody` | `true` |
-| `traefik/http/services/Service04/mirroring/mirrors/0/name` | `foobar` |
-| `traefik/http/services/Service04/mirroring/mirrors/0/percent` | `42` |
-| `traefik/http/services/Service04/mirroring/mirrors/1/name` | `foobar` |
-| `traefik/http/services/Service04/mirroring/mirrors/1/percent` | `42` |
-| `traefik/http/services/Service04/mirroring/service` | `foobar` |
-| `traefik/http/services/Service05/weighted/healthCheck` | `` |
-| `traefik/http/services/Service05/weighted/services/0/name` | `foobar` |
-| `traefik/http/services/Service05/weighted/services/0/weight` | `42` |
-| `traefik/http/services/Service05/weighted/services/1/name` | `foobar` |
-| `traefik/http/services/Service05/weighted/services/1/weight` | `42` |
-| `traefik/http/services/Service05/weighted/sticky/cookie/domain` | `foobar` |
-| `traefik/http/services/Service05/weighted/sticky/cookie/httpOnly` | `true` |
-| `traefik/http/services/Service05/weighted/sticky/cookie/maxAge` | `42` |
-| `traefik/http/services/Service05/weighted/sticky/cookie/name` | `foobar` |
-| `traefik/http/services/Service05/weighted/sticky/cookie/path` | `foobar` |
-| `traefik/http/services/Service05/weighted/sticky/cookie/sameSite` | `foobar` |
-| `traefik/http/services/Service05/weighted/sticky/cookie/secure` | `true` |
+| `traefik/http/services/Service02/loadBalancer/healthCheck/followRedirects` | `true` |
+| | `foobar` |
+| | `foobar` |
+| `traefik/http/services/Service02/loadBalancer/healthCheck/hostname` | `foobar` |
+| `traefik/http/services/Service02/loadBalancer/healthCheck/interval` | `42s` |
+| `traefik/http/services/Service02/loadBalancer/healthCheck/method` | `foobar` |
+| `traefik/http/services/Service02/loadBalancer/healthCheck/mode` | `foobar` |
+| `traefik/http/services/Service02/loadBalancer/healthCheck/path` | `foobar` |
+| `traefik/http/services/Service02/loadBalancer/healthCheck/port` | `42` |
+| `traefik/http/services/Service02/loadBalancer/healthCheck/scheme` | `foobar` |
+| `traefik/http/services/Service02/loadBalancer/healthCheck/status` | `42` |
+| `traefik/http/services/Service02/loadBalancer/healthCheck/timeout` | `42s` |
+| `traefik/http/services/Service02/loadBalancer/healthCheck/unhealthyInterval` | `42s` |
+| | `true` |
+| `traefik/http/services/Service02/loadBalancer/responseForwarding/flushInterval` | `42s` |
+| `traefik/http/services/Service02/loadBalancer/servers/0/preservePath` | `true` |
+| `traefik/http/services/Service02/loadBalancer/servers/0/url` | `foobar` |
+| `traefik/http/services/Service02/loadBalancer/servers/0/weight` | `42` |
+| `traefik/http/services/Service02/loadBalancer/servers/1/preservePath` | `true` |
+| `traefik/http/services/Service02/loadBalancer/servers/1/url` | `foobar` |
+| `traefik/http/services/Service02/loadBalancer/servers/1/weight` | `42` |
+| `traefik/http/services/Service02/loadBalancer/serversTransport` | `foobar` |
+| `traefik/http/services/Service02/loadBalancer/sticky/cookie/domain` | `foobar` |
+| `traefik/http/services/Service02/loadBalancer/sticky/cookie/httpOnly` | `true` |
+| `traefik/http/services/Service02/loadBalancer/sticky/cookie/maxAge` | `42` |
+| `traefik/http/services/Service02/loadBalancer/sticky/cookie/name` | `foobar` |
+| `traefik/http/services/Service02/loadBalancer/sticky/cookie/path` | `foobar` |
+| `traefik/http/services/Service02/loadBalancer/sticky/cookie/sameSite` | `foobar` |
+| `traefik/http/services/Service02/loadBalancer/sticky/cookie/secure` | `true` |
+| `traefik/http/services/Service02/loadBalancer/strategy` | `foobar` |
+| `traefik/http/services/Service03/mirroring/healthCheck` | `` |
+| `traefik/http/services/Service03/mirroring/maxBodySize` | `42` |
+| `traefik/http/services/Service03/mirroring/mirrorBody` | `true` |
+| `traefik/http/services/Service03/mirroring/mirrors/0/name` | `foobar` |
+| `traefik/http/services/Service03/mirroring/mirrors/0/percent` | `42` |
+| `traefik/http/services/Service03/mirroring/mirrors/1/name` | `foobar` |
+| `traefik/http/services/Service03/mirroring/mirrors/1/percent` | `42` |
+| `traefik/http/services/Service03/mirroring/service` | `foobar` |
+| `traefik/http/services/Service04/weighted/healthCheck` | `` |
+| `traefik/http/services/Service04/weighted/services/0/name` | `foobar` |
+| `traefik/http/services/Service04/weighted/services/0/weight` | `42` |
+| `traefik/http/services/Service04/weighted/services/1/name` | `foobar` |
+| `traefik/http/services/Service04/weighted/services/1/weight` | `42` |
+| `traefik/http/services/Service04/weighted/sticky/cookie/domain` | `foobar` |
+| `traefik/http/services/Service04/weighted/sticky/cookie/httpOnly` | `true` |
+| `traefik/http/services/Service04/weighted/sticky/cookie/maxAge` | `42` |
+| `traefik/http/services/Service04/weighted/sticky/cookie/name` | `foobar` |
+| `traefik/http/services/Service04/weighted/sticky/cookie/path` | `foobar` |
+| `traefik/http/services/Service04/weighted/sticky/cookie/sameSite` | `foobar` |
+| `traefik/http/services/Service04/weighted/sticky/cookie/secure` | `true` |
| `traefik/tcp/middlewares/TCPMiddleware01/ipAllowList/sourceRange/0` | `foobar` |
| `traefik/tcp/middlewares/TCPMiddleware01/ipAllowList/sourceRange/1` | `foobar` |
| `traefik/tcp/middlewares/TCPMiddleware02/ipWhiteList/sourceRange/0` | `foobar` |
@@ -437,6 +428,8 @@ THIS FILE MUST NOT BE EDITED BY HAND
| `traefik/tls/options/Options0/curvePreferences/0` | `foobar` |
| `traefik/tls/options/Options0/curvePreferences/1` | `foobar` |
| `traefik/tls/options/Options0/disableSessionTickets` | `true` |
+| `traefik/tls/options/Options0/echKeys/0` | `foobar` |
+| `traefik/tls/options/Options0/echKeys/1` | `foobar` |
| `traefik/tls/options/Options0/maxVersion` | `foobar` |
| `traefik/tls/options/Options0/minVersion` | `foobar` |
| `traefik/tls/options/Options0/preferServerCipherSuites` | `true` |
@@ -451,6 +444,8 @@ THIS FILE MUST NOT BE EDITED BY HAND
| `traefik/tls/options/Options1/curvePreferences/0` | `foobar` |
| `traefik/tls/options/Options1/curvePreferences/1` | `foobar` |
| `traefik/tls/options/Options1/disableSessionTickets` | `true` |
+| `traefik/tls/options/Options1/echKeys/0` | `foobar` |
+| `traefik/tls/options/Options1/echKeys/1` | `foobar` |
| `traefik/tls/options/Options1/maxVersion` | `foobar` |
| `traefik/tls/options/Options1/minVersion` | `foobar` |
| `traefik/tls/options/Options1/preferServerCipherSuites` | `true` |
diff --git a/docs/content/reference/routing-configuration/other-providers/file.toml b/docs/content/reference/routing-configuration/other-providers/file.toml
index 2f94a07dd..cdbaf65d5 100644
--- a/docs/content/reference/routing-configuration/other-providers/file.toml
+++ b/docs/content/reference/routing-configuration/other-providers/file.toml
@@ -620,6 +620,7 @@
sniStrict = true
alpnProtocols = ["foobar", "foobar"]
disableSessionTickets = true
+ echKeys = ["foobar", "foobar"]
preferServerCipherSuites = true
[tls.options.Options0.clientAuth]
caFiles = ["foobar", "foobar"]
@@ -632,6 +633,7 @@
sniStrict = true
alpnProtocols = ["foobar", "foobar"]
disableSessionTickets = true
+ echKeys = ["foobar", "foobar"]
preferServerCipherSuites = true
[tls.options.Options1.clientAuth]
caFiles = ["foobar", "foobar"]
diff --git a/docs/content/reference/routing-configuration/other-providers/file.yaml b/docs/content/reference/routing-configuration/other-providers/file.yaml
index 50200a4fb..7133691d4 100644
--- a/docs/content/reference/routing-configuration/other-providers/file.yaml
+++ b/docs/content/reference/routing-configuration/other-providers/file.yaml
@@ -701,6 +701,9 @@ tls:
- foobar
- foobar
disableSessionTickets: true
+ echKeys:
+ - foobar
+ - foobar
preferServerCipherSuites: true
Options1:
minVersion: foobar
@@ -721,6 +724,9 @@ tls:
- foobar
- foobar
disableSessionTickets: true
+ echKeys:
+ - foobar
+ - foobar
preferServerCipherSuites: true
stores:
Store0:
diff --git a/go.mod b/go.mod
index 3e1fdb156..6a5218e2e 100644
--- a/go.mod
+++ b/go.mod
@@ -16,6 +16,7 @@ require (
github.com/aws/aws-sdk-go-v2/service/ssm v1.56.13
github.com/aws/smithy-go v1.24.0
github.com/cenkalti/backoff/v4 v4.3.0
+ github.com/cloudflare/circl v1.3.7
github.com/containous/alice v0.0.0-20181107144136-d83ebdd94cbd // No tag on the repo.
github.com/coreos/go-systemd/v22 v22.5.0
github.com/docker/cli v28.3.3+incompatible
@@ -53,6 +54,7 @@ require (
github.com/mitchellh/mapstructure v1.5.1-0.20231216201459-8508981c8b6c // No tag on the repo.
github.com/patrickmn/go-cache v2.1.0+incompatible
github.com/pires/go-proxyproto v0.8.1
+ github.com/pkg/errors v0.9.1
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // No tag on the repo.
github.com/prometheus/client_golang v1.23.0
github.com/prometheus/client_model v0.6.2
@@ -122,8 +124,6 @@ require (
sigs.k8s.io/yaml v1.6.0
)
-require github.com/cloudflare/circl v1.3.7
-
require (
cloud.google.com/go/auth v0.18.0 // indirect
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
@@ -325,7 +325,6 @@ require (
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/peterhellberg/link v1.2.0 // indirect
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
- github.com/pkg/errors v0.9.1 // indirect
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
github.com/pquerna/otp v1.5.0 // indirect
github.com/prometheus/common v0.65.0 // indirect
From 48ea384586d530ce52f07409f71be7ebcc85f3ed Mon Sep 17 00:00:00 2001
From: mmatur
Date: Fri, 2 Jan 2026 16:05:51 +0100
Subject: [PATCH 5/6] fix for review
---
cmd/ech/ech.go | 111 --------------------------------------
cmd/traefik/traefik.go | 7 ---
docs/content/https/tls.md | 6 ---
go.mod | 2 +-
go.sum | 4 ++
internal/ech/cmd/main.go | 46 ++++++++++++++++
internal/ech/ech.go | 41 ++++++++++++++
pkg/tls/ech.go | 69 ++++++++++--------------
pkg/tls/ech_test.go | 26 ++++++++-
9 files changed, 144 insertions(+), 168 deletions(-)
delete mode 100644 cmd/ech/ech.go
create mode 100644 internal/ech/cmd/main.go
create mode 100644 internal/ech/ech.go
diff --git a/cmd/ech/ech.go b/cmd/ech/ech.go
deleted file mode 100644
index 63e35ebe3..000000000
--- a/cmd/ech/ech.go
+++ /dev/null
@@ -1,111 +0,0 @@
-package ech
-
-import (
- "bytes"
- "flag"
- "github.com/pkg/errors"
- "github.com/traefik/paerser/cli"
- "github.com/traefik/traefik/v3/pkg/tls"
- "io"
- stdlog "log"
- "os"
-)
-
-// NewCmd builds a new Version command.
-func NewCmd() *cli.Command {
- cmd := cli.Command{
- Name: "ech",
- Description: `Encrypted Client Hello (ECH) utils.`,
- Run: nil,
- }
-
- var err error
- err = cmd.AddCommand(generate())
- if err != nil {
- stdlog.Println(err)
- os.Exit(1)
- }
-
- err = cmd.AddCommand(request())
- if err != nil {
- stdlog.Println(err)
- os.Exit(1)
- }
-
- buf := bytes.NewBuffer(nil)
- err = cmd.PrintHelp(buf)
- if err != nil {
- stdlog.Println(err)
- os.Exit(1)
- }
- cmd.Run = func(_ []string) error {
- if _, err = os.Stdout.Write(buf.Bytes()); err != nil {
- return err
- }
- return nil
- }
-
- return &cmd
-}
-
-func generate() *cli.Command {
- help := []byte(`Usage: ech generate SNI [SNI ...]`)
- cmd := cli.Command{
- Name: "generate",
- Description: "Generate a new ECH key with given outer sni.",
- AllowArg: true,
- CustomHelpFunc: func(writer io.Writer, command *cli.Command) error {
- _, err := writer.Write(help)
- return err
- },
- Run: func(names []string) error {
- if len(names) == 0 {
- if _, err := os.Stdout.Write(help); err != nil {
- return err
- }
- }
- for _, name := range names {
- key, err := tls.NewECHKey(name)
- if err != nil {
- return errors.Wrapf(err, "failed to generate ECH key for %s", name)
- }
- data, err := tls.MarshalECHKey(key)
- if err != nil {
- return errors.Wrapf(err, "failed to marshal ECH key for %s", name)
- }
- if _, err = os.Stdout.Write(data); err != nil {
- return errors.Wrapf(err, "failed to write ECH key for %s", name)
- }
- }
- return nil
- },
- }
- return &cmd
-}
-
-func request() *cli.Command {
- return &cli.Command{
- Name: "request",
- Description: "Make an ECH request.",
- AllowArg: true,
- CustomHelpFunc: func(writer io.Writer, command *cli.Command) error {
- _, err := writer.Write([]byte(`Usage: ech request URL -e ECH [-h HOST] [-k])`))
- return err
- },
- Run: func(args []string) error {
- c := tls.ECHRequestConf[string]{}
- fs := flag.NewFlagSet("ech request", flag.ContinueOnError)
- c.URL = fs.Arg(0)
- c.ECH = *fs.String("ech", "", "A base64-encoded ECH public config list.")
- c.Host = *fs.String("h", "", "The host to use in the request. If not set, it will be derived from the URL.")
- c.Insecure = *fs.Bool("k", false, "Allow insecure server connections when using SSL.")
- fs.SetOutput(os.Stderr)
- if err := fs.Parse(args); err != nil {
- return err
- }
-
- _, err := tls.RequestWithECH(c)
- return err
- },
- }
-}
diff --git a/cmd/traefik/traefik.go b/cmd/traefik/traefik.go
index c581d09c2..adc8c358b 100644
--- a/cmd/traefik/traefik.go
+++ b/cmd/traefik/traefik.go
@@ -23,7 +23,6 @@ import (
"github.com/spiffe/go-spiffe/v2/workloadapi"
"github.com/traefik/paerser/cli"
"github.com/traefik/traefik/v3/cmd"
- cmdECH "github.com/traefik/traefik/v3/cmd/ech"
"github.com/traefik/traefik/v3/cmd/healthcheck"
cmdVersion "github.com/traefik/traefik/v3/cmd/version"
tcli "github.com/traefik/traefik/v3/pkg/cli"
@@ -81,12 +80,6 @@ Complete documentation is available at https://traefik.io`,
os.Exit(1)
}
- err = cmdTraefik.AddCommand(cmdECH.NewCmd())
- if err != nil {
- stdlog.Println(err)
- os.Exit(1)
- }
-
err = cli.Execute(cmdTraefik)
if err != nil {
log.Error().Err(err).Msg("Command error")
diff --git a/docs/content/https/tls.md b/docs/content/https/tls.md
index 90d54d7a4..fc0ecdff5 100644
--- a/docs/content/https/tls.md
+++ b/docs/content/https/tls.md
@@ -608,12 +608,6 @@ AQALZXhhbXBsZS5jb20AAA==
-----END ECHCONFIG-----
```
-To generate the ECH configuration, you can run:
-
-```bash
-traefik ech generate example.com
-```
-
```yaml tab="File (YAML)"
# Dynamic configuration
diff --git a/go.mod b/go.mod
index 6a5218e2e..868e43438 100644
--- a/go.mod
+++ b/go.mod
@@ -16,7 +16,7 @@ require (
github.com/aws/aws-sdk-go-v2/service/ssm v1.56.13
github.com/aws/smithy-go v1.24.0
github.com/cenkalti/backoff/v4 v4.3.0
- github.com/cloudflare/circl v1.3.7
+ github.com/cloudflare/circl v1.6.2
github.com/containous/alice v0.0.0-20181107144136-d83ebdd94cbd // No tag on the repo.
github.com/coreos/go-systemd/v22 v22.5.0
github.com/docker/cli v28.3.3+incompatible
diff --git a/go.sum b/go.sum
index cc4379747..42e449721 100644
--- a/go.sum
+++ b/go.sum
@@ -296,6 +296,10 @@ github.com/clbanning/mxj/v2 v2.7.0/go.mod h1:hNiWqW14h+kc+MdF9C6/YoRfjEJoR3ou6tn
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/cloudflare/circl v1.3.7 h1:qlCDlTPz2n9fu58M0Nh1J/JzcFpfgkFHHX3O35r5vcU=
github.com/cloudflare/circl v1.3.7/go.mod h1:sRTcRWXGLrKw6yIGJ+l7amYJFfAXbZG0kBSc8r4zxgA=
+github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0=
+github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs=
+github.com/cloudflare/circl v1.6.2 h1:hL7VBpHHKzrV5WTfHCaBsgx/HGbBYlgrwvNXEVDYYsQ=
+github.com/cloudflare/circl v1.6.2/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
github.com/cncf/xds/go v0.0.0-20210312221358-fbca930ec8ed/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
diff --git a/internal/ech/cmd/main.go b/internal/ech/cmd/main.go
new file mode 100644
index 000000000..2907e9419
--- /dev/null
+++ b/internal/ech/cmd/main.go
@@ -0,0 +1,46 @@
+// Command ech provides utilities for generating ECH (Encrypted Client Hello) keys.
+//
+// Usage:
+//
+// go run ./internal/ech/cmd generate example.com,example.org
+package main
+
+import (
+ "fmt"
+ "os"
+ "strings"
+
+ "github.com/traefik/traefik/v3/internal/ech"
+)
+
+func main() {
+ if len(os.Args) < 3 {
+ printUsage()
+ os.Exit(1)
+ }
+
+ command := os.Args[1]
+ switch command {
+ case "generate":
+ names := strings.Split(os.Args[2], ",")
+ if err := ech.GenerateMultiple(os.Stdout, names); err != nil {
+ fmt.Fprintf(os.Stderr, "Error: %v\n", err)
+ os.Exit(1)
+ }
+ default:
+ fmt.Fprintf(os.Stderr, "Unknown command: %s\n", command)
+ printUsage()
+ os.Exit(1)
+ }
+}
+
+func printUsage() {
+ fmt.Fprintln(os.Stderr, "Usage: go run ./internal/ech/cmd [arguments]")
+ fmt.Fprintln(os.Stderr, "")
+ fmt.Fprintln(os.Stderr, "Commands:")
+ fmt.Fprintln(os.Stderr, " generate Generate ECH keys for the given SNI names (comma-separated)")
+ fmt.Fprintln(os.Stderr, "")
+ fmt.Fprintln(os.Stderr, "Examples:")
+ fmt.Fprintln(os.Stderr, " go run ./internal/ech/cmd generate example.com")
+ fmt.Fprintln(os.Stderr, " go run ./internal/ech/cmd generate example.com,example.org")
+}
diff --git a/internal/ech/ech.go b/internal/ech/ech.go
new file mode 100644
index 000000000..e7fb87b38
--- /dev/null
+++ b/internal/ech/ech.go
@@ -0,0 +1,41 @@
+// Package ech provides utilities for generating and working with
+// Encrypted Client Hello (ECH) keys.
+package ech
+
+import (
+ "fmt"
+ "io"
+
+ "github.com/traefik/traefik/v3/pkg/tls"
+)
+
+// Generate creates a new ECH key for the given public name (SNI) and writes
+// the PEM-encoded result to the provided writer.
+func Generate(w io.Writer, publicName string) error {
+ key, err := tls.NewECHKey(publicName)
+ if err != nil {
+ return fmt.Errorf("failed to generate ECH key for %s: %w", publicName, err)
+ }
+
+ data, err := tls.MarshalECHKey(key)
+ if err != nil {
+ return fmt.Errorf("failed to marshal ECH key for %s: %w", publicName, err)
+ }
+
+ if _, err = w.Write(data); err != nil {
+ return fmt.Errorf("failed to write ECH key for %s: %w", publicName, err)
+ }
+
+ return nil
+}
+
+// GenerateMultiple creates ECH keys for multiple public names and writes
+// all PEM-encoded results to the provided writer.
+func GenerateMultiple(w io.Writer, publicNames []string) error {
+ for _, name := range publicNames {
+ if err := Generate(w, name); err != nil {
+ return err
+ }
+ }
+ return nil
+}
diff --git a/pkg/tls/ech.go b/pkg/tls/ech.go
index 4bf510c4d..f0458aa85 100644
--- a/pkg/tls/ech.go
+++ b/pkg/tls/ech.go
@@ -6,14 +6,18 @@ import (
"encoding/binary"
"encoding/pem"
"fmt"
- "github.com/cloudflare/circl/hpke"
- "golang.org/x/crypto/cryptobyte"
- "log"
+ "io"
"math/rand/v2"
"net/http"
"net/url"
+
+ "github.com/cloudflare/circl/hpke"
+ "golang.org/x/crypto/cryptobyte"
)
+// sha256PrivateKeyLength is the required private key length for SHA-256 based ECDH.
+const sha256PrivateKeyLength = 32
+
func UnmarshalECHKey(data []byte) (*tls.EncryptedClientHelloKey, error) {
var k tls.EncryptedClientHelloKey
for {
@@ -35,14 +39,14 @@ func UnmarshalECHKey(data []byte) (*tls.EncryptedClientHelloKey, error) {
}
if len(k.Config) == 0 || len(k.PrivateKey) == 0 {
- return nil, fmt.Errorf("lack of ECH configuration or private key in PEM file")
+ return nil, fmt.Errorf("missing ECH configuration or private key in PEM file")
}
// go ecdh now only supports SHA-256 (32-byte private key)
- if len(k.PrivateKey) < 32 {
- return nil, fmt.Errorf("invalid private key length: expected at least 32 bytes, got %d bytes", len(k.PrivateKey))
- } else if len(k.PrivateKey) > 32 {
- k.PrivateKey = k.PrivateKey[len(k.PrivateKey)-32:]
+ if len(k.PrivateKey) < sha256PrivateKeyLength {
+ return nil, fmt.Errorf("invalid private key length: expected at least %d bytes, got %d bytes", sha256PrivateKeyLength, len(k.PrivateKey))
+ } else if len(k.PrivateKey) > sha256PrivateKeyLength {
+ k.PrivateKey = k.PrivateKey[len(k.PrivateKey)-sha256PrivateKeyLength:]
}
k.SendAsRetry = true
@@ -52,7 +56,7 @@ func UnmarshalECHKey(data []byte) (*tls.EncryptedClientHelloKey, error) {
func MarshalECHKey(k *tls.EncryptedClientHelloKey) ([]byte, error) {
if len(k.Config) == 0 || len(k.PrivateKey) == 0 {
- return nil, fmt.Errorf("lack of ECH configuration or private key")
+ return nil, fmt.Errorf("missing ECH configuration or private key")
}
lengthPrefix := make([]byte, 2)
binary.BigEndian.PutUint16(lengthPrefix, uint16(len(k.Config)))
@@ -157,37 +161,15 @@ func NewECHKey(publicName string) (*tls.EncryptedClientHelloKey, error) {
}, nil
}
-// startECHServer starts a TLS server that supports Encrypted Client Hello (ECH).
-func startECHServer(bind string, cert tls.Certificate, echKey tls.EncryptedClientHelloKey) {
- mux := http.NewServeMux()
- mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
- fmt.Fprintf(w, "Hello, ECH-enabled TLS server!")
- })
-
- server := &http.Server{
- Addr: bind,
- Handler: mux,
- TLSConfig: &tls.Config{
- Certificates: []tls.Certificate{cert},
- MinVersion: tls.VersionTLS13,
- EncryptedClientHelloKeys: []tls.EncryptedClientHelloKey{echKey},
- },
- }
-
- if err := server.ListenAndServeTLS("", ""); err != nil {
- log.Fatalf("server error: %v", err)
- }
-}
-
-type ECHRequestConf[T []byte | string] struct {
+type ECHRequestConfig[T []byte | string] struct {
URL string `description:"The URL to request." json:"u" export:"true"`
- Host string `description:"The host/sni to request with." json:"h" export:"true"`
- ECH T `description:"A base64-encoded ECH public config list." json:"ech" export:"true"`
+ Host string `description:"The host/sni to request." json:"h" export:"true"`
+ ECH T `description:"Base64-encoded ECH public configuration list for client use." json:"ech" export:"true"`
Insecure bool `description:"If true, skip TLS verification (for testing purposes)." json:"k" export:"true"`
}
// RequestWithECH sends a GET request to a server using the provided ECH configuration.
-func RequestWithECH[T []byte | string](c ECHRequestConf[T]) (body []byte, err error) {
+func RequestWithECH[T []byte | string](c ECHRequestConfig[T]) (body []byte, err error) {
// Decode the ECH configuration from base64 if it's a string, otherwise use it directly.
var ech []byte
if s, ok := any(c.ECH).(string); ok {
@@ -199,7 +181,11 @@ func RequestWithECH[T []byte | string](c ECHRequestConf[T]) (body []byte, err er
ech = []byte(c.ECH)
}
- requestURL, _ := url.Parse(c.URL)
+ requestURL, err := url.Parse(c.URL)
+ if err != nil {
+ return nil, fmt.Errorf("failed to parse URL: %w", err)
+ }
+
if c.Host == "" {
c.Host = requestURL.Hostname()
}
@@ -226,13 +212,12 @@ func RequestWithECH[T []byte | string](c ECHRequestConf[T]) (body []byte, err er
}
defer resp.Body.Close()
- body = make([]byte, 1024)
- n, _ := resp.Body.Read(body)
- fmt.Printf("server response: %s\n", body[:n])
- fmt.Printf("Status code: %d\n", resp.StatusCode)
- fmt.Printf("Response header: %v\n", resp.Header)
+ body, err = io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, fmt.Errorf("failed to read response body: %w", err)
+ }
- return body[:n], nil
+ return body, nil
}
func ECHConfigToConfigList(echConfig []byte) ([]byte, error) {
diff --git a/pkg/tls/ech_test.go b/pkg/tls/ech_test.go
index 1d676e5f5..8d3e89b9e 100644
--- a/pkg/tls/ech_test.go
+++ b/pkg/tls/ech_test.go
@@ -8,12 +8,36 @@ import (
"crypto/x509/pkix"
"encoding/pem"
"fmt"
+ "log"
"math/big"
+ "net/http"
"reflect"
"testing"
"time"
)
+// startECHServer starts a TLS server that supports Encrypted Client Hello (ECH).
+func startECHServer(bind string, cert tls.Certificate, echKey tls.EncryptedClientHelloKey) {
+ mux := http.NewServeMux()
+ mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
+ fmt.Fprintf(w, "Hello, ECH-enabled TLS server!")
+ })
+
+ server := &http.Server{
+ Addr: bind,
+ Handler: mux,
+ TLSConfig: &tls.Config{
+ Certificates: []tls.Certificate{cert},
+ MinVersion: tls.VersionTLS13,
+ EncryptedClientHelloKeys: []tls.EncryptedClientHelloKey{echKey},
+ },
+ }
+
+ if err := server.ListenAndServeTLS("", ""); err != nil {
+ log.Fatalf("server error: %v", err)
+ }
+}
+
func TestECH(t *testing.T) {
const commonName = "server.local"
@@ -48,7 +72,7 @@ func TestECH(t *testing.T) {
go startECHServer("localhost:8443", testCert, *echKey)
time.Sleep(1 * time.Second) // Wait for the server to start
- response, err := RequestWithECH(ECHRequestConf[[]byte]{
+ response, err := RequestWithECH(ECHRequestConfig[[]byte]{
URL: "https://localhost:8443/",
Host: commonName,
ECH: echConfigList,
From 6be505af7f1db5222cb5d48cf9c03043533dcca3 Mon Sep 17 00:00:00 2001
From: mmatur
Date: Thu, 8 Jan 2026 16:22:21 +0100
Subject: [PATCH 6/6] fix linter + rewrite tests
---
go.mod | 2 +-
go.sum | 4 -
pkg/provider/file/file.go | 6 +-
pkg/tls/ech.go | 76 +-----
pkg/tls/ech_test.go | 455 ++++++++++++++++++++++++++-----
pkg/tls/zz_generated.deepcopy.go | 5 +
6 files changed, 399 insertions(+), 149 deletions(-)
diff --git a/go.mod b/go.mod
index 868e43438..328e36b80 100644
--- a/go.mod
+++ b/go.mod
@@ -54,7 +54,7 @@ require (
github.com/mitchellh/mapstructure v1.5.1-0.20231216201459-8508981c8b6c // No tag on the repo.
github.com/patrickmn/go-cache v2.1.0+incompatible
github.com/pires/go-proxyproto v0.8.1
- github.com/pkg/errors v0.9.1
+ github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // No tag on the repo.
github.com/prometheus/client_golang v1.23.0
github.com/prometheus/client_model v0.6.2
diff --git a/go.sum b/go.sum
index 42e449721..ea31ca885 100644
--- a/go.sum
+++ b/go.sum
@@ -294,10 +294,6 @@ github.com/clbanning/mxj v1.8.4/go.mod h1:BVjHeAH+rl9rs6f+QIpeRl0tfu10SXn1pUSa5P
github.com/clbanning/mxj/v2 v2.7.0 h1:WA/La7UGCanFe5NpHF0Q3DNtnCsVoxbPKuyBNHWRyME=
github.com/clbanning/mxj/v2 v2.7.0/go.mod h1:hNiWqW14h+kc+MdF9C6/YoRfjEJoR3ou6tn/Qo+ve2s=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
-github.com/cloudflare/circl v1.3.7 h1:qlCDlTPz2n9fu58M0Nh1J/JzcFpfgkFHHX3O35r5vcU=
-github.com/cloudflare/circl v1.3.7/go.mod h1:sRTcRWXGLrKw6yIGJ+l7amYJFfAXbZG0kBSc8r4zxgA=
-github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0=
-github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs=
github.com/cloudflare/circl v1.6.2 h1:hL7VBpHHKzrV5WTfHCaBsgx/HGbBYlgrwvNXEVDYYsQ=
github.com/cloudflare/circl v1.6.2/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
diff --git a/pkg/provider/file/file.go b/pkg/provider/file/file.go
index 626a12bf5..5fd3fc045 100644
--- a/pkg/provider/file/file.go
+++ b/pkg/provider/file/file.go
@@ -267,15 +267,17 @@ func (p *Provider) loadFileConfig(ctx context.Context, filename string, parseTem
}
options.ClientAuth.CAFiles = caCerts
- for i, echKey := range options.ECHKeys {
+ var echKeyContents []types.FileOrContent
+ for _, echKey := range options.ECHKeys {
content, err := echKey.Read()
if err != nil {
log.Ctx(ctx).Error().Err(err).Send()
continue
}
- options.ECHKeys[i] = types.FileOrContent(content)
+ echKeyContents = append(echKeyContents, types.FileOrContent(content))
}
+ options.ECHKeys = echKeyContents
configuration.TLS.Options[name] = options
}
diff --git a/pkg/tls/ech.go b/pkg/tls/ech.go
index f0458aa85..95be41c03 100644
--- a/pkg/tls/ech.go
+++ b/pkg/tls/ech.go
@@ -2,14 +2,11 @@ package tls
import (
"crypto/tls"
- "encoding/base64"
"encoding/binary"
"encoding/pem"
+ "errors"
"fmt"
- "io"
"math/rand/v2"
- "net/http"
- "net/url"
"github.com/cloudflare/circl/hpke"
"golang.org/x/crypto/cryptobyte"
@@ -39,7 +36,7 @@ func UnmarshalECHKey(data []byte) (*tls.EncryptedClientHelloKey, error) {
}
if len(k.Config) == 0 || len(k.PrivateKey) == 0 {
- return nil, fmt.Errorf("missing ECH configuration or private key in PEM file")
+ return nil, errors.New("missing ECH configuration or private key in PEM file")
}
// go ecdh now only supports SHA-256 (32-byte private key)
@@ -56,11 +53,11 @@ func UnmarshalECHKey(data []byte) (*tls.EncryptedClientHelloKey, error) {
func MarshalECHKey(k *tls.EncryptedClientHelloKey) ([]byte, error) {
if len(k.Config) == 0 || len(k.PrivateKey) == 0 {
- return nil, fmt.Errorf("missing ECH configuration or private key")
+ return nil, errors.New("missing ECH configuration or private key")
}
- lengthPrefix := make([]byte, 2)
- binary.BigEndian.PutUint16(lengthPrefix, uint16(len(k.Config)))
- configBytes := append(lengthPrefix, k.Config...)
+ configBytes := make([]byte, 2+len(k.Config))
+ binary.BigEndian.PutUint16(configBytes, uint16(len(k.Config)))
+ copy(configBytes[2:], k.Config)
var pemData []byte
pemData = append(pemData, pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: k.PrivateKey})...)
pemData = append(pemData, pem.EncodeToMemory(&pem.Block{Type: "ECHCONFIG", Bytes: configBytes})...)
@@ -79,8 +76,6 @@ type echExtension struct {
}
type echConfig struct {
- raw []byte
-
Version uint16
Length uint16
@@ -161,65 +156,6 @@ func NewECHKey(publicName string) (*tls.EncryptedClientHelloKey, error) {
}, nil
}
-type ECHRequestConfig[T []byte | string] struct {
- URL string `description:"The URL to request." json:"u" export:"true"`
- Host string `description:"The host/sni to request." json:"h" export:"true"`
- ECH T `description:"Base64-encoded ECH public configuration list for client use." json:"ech" export:"true"`
- Insecure bool `description:"If true, skip TLS verification (for testing purposes)." json:"k" export:"true"`
-}
-
-// RequestWithECH sends a GET request to a server using the provided ECH configuration.
-func RequestWithECH[T []byte | string](c ECHRequestConfig[T]) (body []byte, err error) {
- // Decode the ECH configuration from base64 if it's a string, otherwise use it directly.
- var ech []byte
- if s, ok := any(c.ECH).(string); ok {
- ech, err = base64.StdEncoding.DecodeString(s)
- if err != nil {
- return nil, err
- }
- } else {
- ech = []byte(c.ECH)
- }
-
- requestURL, err := url.Parse(c.URL)
- if err != nil {
- return nil, fmt.Errorf("failed to parse URL: %w", err)
- }
-
- if c.Host == "" {
- c.Host = requestURL.Hostname()
- }
-
- client := &http.Client{
- Transport: &http.Transport{
- TLSClientConfig: &tls.Config{
- ServerName: c.Host,
- EncryptedClientHelloConfigList: ech,
- MinVersion: tls.VersionTLS13,
- InsecureSkipVerify: c.Insecure,
- },
- },
- }
-
- req := &http.Request{
- Method: "GET",
- URL: requestURL,
- Host: c.Host,
- }
- resp, err := client.Do(req)
- if err != nil {
- return nil, err
- }
- defer resp.Body.Close()
-
- body, err = io.ReadAll(resp.Body)
- if err != nil {
- return nil, fmt.Errorf("failed to read response body: %w", err)
- }
-
- return body, nil
-}
-
func ECHConfigToConfigList(echConfig []byte) ([]byte, error) {
var b cryptobyte.Builder
b.AddUint16LengthPrefixed(func(child *cryptobyte.Builder) {
diff --git a/pkg/tls/ech_test.go b/pkg/tls/ech_test.go
index 8d3e89b9e..34420e9a3 100644
--- a/pkg/tls/ech_test.go
+++ b/pkg/tls/ech_test.go
@@ -6,125 +6,436 @@ import (
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
+ "encoding/base64"
"encoding/pem"
"fmt"
- "log"
+ "io"
"math/big"
"net/http"
- "reflect"
+ "net/http/httptest"
+ "net/url"
"testing"
"time"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
)
-// startECHServer starts a TLS server that supports Encrypted Client Hello (ECH).
-func startECHServer(bind string, cert tls.Certificate, echKey tls.EncryptedClientHelloKey) {
- mux := http.NewServeMux()
- mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
- fmt.Fprintf(w, "Hello, ECH-enabled TLS server!")
- })
+// ECHRequestConfig is a configuration struct for making ECH-enabled requests.
+// This is only used for testing purposes.
+type ECHRequestConfig[T []byte | string] struct {
+ URL string
+ Host string
+ ECH T
+ Insecure bool
+}
- server := &http.Server{
- Addr: bind,
- Handler: mux,
- TLSConfig: &tls.Config{
- Certificates: []tls.Certificate{cert},
- MinVersion: tls.VersionTLS13,
- EncryptedClientHelloKeys: []tls.EncryptedClientHelloKey{echKey},
+// RequestWithECH sends a GET request to a server using the provided ECH configuration.
+// This is only used for testing purposes.
+func RequestWithECH[T []byte | string](c ECHRequestConfig[T]) (body []byte, err error) {
+ // Decode the ECH configuration from base64 if it's a string, otherwise use it directly.
+ var ech []byte
+ if s, ok := any(c.ECH).(string); ok {
+ ech, err = base64.StdEncoding.DecodeString(s)
+ if err != nil {
+ return nil, err
+ }
+ } else {
+ ech = []byte(c.ECH)
+ }
+
+ requestURL, err := url.Parse(c.URL)
+ if err != nil {
+ return nil, fmt.Errorf("failed to parse URL: %w", err)
+ }
+
+ if c.Host == "" {
+ c.Host = requestURL.Hostname()
+ }
+
+ client := &http.Client{
+ Transport: &http.Transport{
+ TLSClientConfig: &tls.Config{
+ ServerName: c.Host,
+ EncryptedClientHelloConfigList: ech,
+ MinVersion: tls.VersionTLS13,
+ InsecureSkipVerify: c.Insecure,
+ },
},
}
- if err := server.ListenAndServeTLS("", ""); err != nil {
- log.Fatalf("server error: %v", err)
+ req := &http.Request{
+ Method: http.MethodGet,
+ URL: requestURL,
+ Host: c.Host,
+ }
+ resp, err := client.Do(req)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+
+ body, err = io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, fmt.Errorf("failed to read response body: %w", err)
+ }
+
+ return body, nil
+}
+
+func TestNewECHKey(t *testing.T) {
+ testCases := []struct {
+ desc string
+ publicName string
+ expectError bool
+ }{
+ {
+ desc: "valid short public name",
+ publicName: "server.local",
+ },
+ {
+ desc: "valid public name at max length",
+ publicName: "abcdefghijklmnopqrstuvwxyz012345", // 32 chars
+ },
+ {
+ desc: "public name exceeds max length",
+ publicName: "abcdefghijklmnopqrstuvwxyz0123456", // 33 chars
+ expectError: true,
+ },
+ }
+
+ for _, test := range testCases {
+ t.Run(test.desc, func(t *testing.T) {
+ t.Parallel()
+
+ echKey, err := NewECHKey(test.publicName)
+
+ if test.expectError {
+ require.Error(t, err)
+ return
+ }
+
+ require.NoError(t, err)
+ assert.NotEmpty(t, echKey.Config)
+ assert.NotEmpty(t, echKey.PrivateKey)
+ assert.True(t, echKey.SendAsRetry)
+ })
}
}
-func TestECH(t *testing.T) {
+func TestMarshalUnmarshalECHKey(t *testing.T) {
+ testCases := []struct {
+ desc string
+ publicName string
+ }{
+ {
+ desc: "standard domain",
+ publicName: "server.local",
+ },
+ {
+ desc: "subdomain",
+ publicName: "api.example.com",
+ },
+ }
+
+ for _, test := range testCases {
+ t.Run(test.desc, func(t *testing.T) {
+ t.Parallel()
+
+ echKey, err := NewECHKey(test.publicName)
+ require.NoError(t, err)
+
+ echKeyBytes, err := MarshalECHKey(echKey)
+ require.NoError(t, err)
+ assert.NotEmpty(t, echKeyBytes)
+
+ newKey, err := UnmarshalECHKey(echKeyBytes)
+ require.NoError(t, err)
+
+ assert.Equal(t, echKey.Config, newKey.Config)
+ assert.Equal(t, echKey.PrivateKey, newKey.PrivateKey)
+ assert.True(t, newKey.SendAsRetry)
+ })
+ }
+}
+
+func TestMarshalECHKey_Errors(t *testing.T) {
+ testCases := []struct {
+ desc string
+ key *tls.EncryptedClientHelloKey
+ }{
+ {
+ desc: "missing config",
+ key: &tls.EncryptedClientHelloKey{
+ PrivateKey: []byte("some-key"),
+ },
+ },
+ {
+ desc: "missing private key",
+ key: &tls.EncryptedClientHelloKey{
+ Config: []byte("some-config"),
+ },
+ },
+ {
+ desc: "both missing",
+ key: &tls.EncryptedClientHelloKey{},
+ },
+ }
+
+ for _, test := range testCases {
+ t.Run(test.desc, func(t *testing.T) {
+ t.Parallel()
+
+ _, err := MarshalECHKey(test.key)
+ require.Error(t, err)
+ })
+ }
+}
+
+func TestUnmarshalECHKey_Errors(t *testing.T) {
+ testCases := []struct {
+ desc string
+ data []byte
+ }{
+ {
+ desc: "empty data",
+ data: []byte{},
+ },
+ {
+ desc: "invalid PEM",
+ data: []byte("not a valid PEM"),
+ },
+ {
+ desc: "unknown PEM block type",
+ data: pem.EncodeToMemory(&pem.Block{Type: "UNKNOWN", Bytes: []byte("data")}),
+ },
+ {
+ desc: "missing private key",
+ data: func() []byte {
+ // Create ECHCONFIG block with length prefix
+ configBytes := append([]byte{0, 4}, []byte("test")...)
+ return pem.EncodeToMemory(&pem.Block{Type: "ECHCONFIG", Bytes: configBytes})
+ }(),
+ },
+ {
+ desc: "missing config",
+ data: pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: make([]byte, 32)}),
+ },
+ {
+ desc: "private key too short",
+ data: func() []byte {
+ var pemData []byte
+ pemData = append(pemData, pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: make([]byte, 16)})...)
+ configBytes := append([]byte{0, 4}, []byte("test")...)
+ pemData = append(pemData, pem.EncodeToMemory(&pem.Block{Type: "ECHCONFIG", Bytes: configBytes})...)
+ return pemData
+ }(),
+ },
+ }
+
+ for _, test := range testCases {
+ t.Run(test.desc, func(t *testing.T) {
+ t.Parallel()
+
+ _, err := UnmarshalECHKey(test.data)
+ require.Error(t, err)
+ })
+ }
+}
+
+func TestECHConfigToConfigList(t *testing.T) {
+ testCases := []struct {
+ desc string
+ config []byte
+ }{
+ {
+ desc: "empty config",
+ config: []byte{},
+ },
+ {
+ desc: "simple config",
+ config: []byte{0x01, 0x02, 0x03, 0x04},
+ },
+ }
+
+ for _, test := range testCases {
+ t.Run(test.desc, func(t *testing.T) {
+ t.Parallel()
+
+ configList, err := ECHConfigToConfigList(test.config)
+ require.NoError(t, err)
+
+ // Config list should have 2-byte length prefix followed by the config
+ expectedLen := 2 + len(test.config)
+ assert.Len(t, configList, expectedLen)
+ })
+ }
+}
+
+func TestRequestWithECH(t *testing.T) {
const commonName = "server.local"
echKey, err := NewECHKey(commonName)
- if err != nil {
- t.Fatalf("Failed to generate ECH key: %v", err)
- }
+ require.NoError(t, err)
- echKeyBytes, err := MarshalECHKey(echKey)
- if err != nil {
- t.Fatalf("Failed to marshal ECH key: %v", err)
- }
-
- newKey, err := UnmarshalECHKey(echKeyBytes)
- if err != nil {
- t.Fatalf("Failed to unmarshal ECH key: %v", err)
- }
-
- if !reflect.DeepEqual(*echKey, *newKey) {
- t.Fatal("Parsed ECH key does not match original")
- }
-
- testCert, err := generateCert(commonName)
- if err != nil {
- t.Fatalf("Failed to load certs: %v", err)
- }
+ testCert, err := generateTestCert(commonName)
+ require.NoError(t, err)
echConfigList, err := ECHConfigToConfigList(echKey.Config)
- if err != nil {
- t.Fatalf("Failed to convert ECH config to config list: %v", err)
+ require.NoError(t, err)
+
+ server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ fmt.Fprint(w, "Hello, ECH-enabled TLS server!")
+ }))
+
+ server.TLS = &tls.Config{
+ Certificates: []tls.Certificate{testCert},
+ MinVersion: tls.VersionTLS13,
+ EncryptedClientHelloKeys: []tls.EncryptedClientHelloKey{*echKey},
}
- go startECHServer("localhost:8443", testCert, *echKey)
- time.Sleep(1 * time.Second) // Wait for the server to start
- response, err := RequestWithECH(ECHRequestConfig[[]byte]{
- URL: "https://localhost:8443/",
- Host: commonName,
- ECH: echConfigList,
- Insecure: true,
- })
+ server.StartTLS()
+ t.Cleanup(server.Close)
- if err != nil {
- t.Fatalf("Failed to make ECH request: %v", err)
+ testCases := []struct {
+ desc string
+ config ECHRequestConfig[[]byte]
+ expectedBody string
+ expectError bool
+ }{
+ {
+ desc: "successful ECH request with bytes",
+ config: ECHRequestConfig[[]byte]{
+ URL: server.URL + "/",
+ Host: commonName,
+ ECH: echConfigList,
+ Insecure: true,
+ },
+ expectedBody: "Hello, ECH-enabled TLS server!",
+ },
}
- if string(response) != "Hello, ECH-enabled TLS server!" {
- t.Fatalf("Unexpected response from ECH server: %s", response)
+
+ for _, test := range testCases {
+ t.Run(test.desc, func(t *testing.T) {
+ response, err := RequestWithECH(test.config)
+
+ if test.expectError {
+ require.Error(t, err)
+ return
+ }
+
+ require.NoError(t, err)
+ assert.Equal(t, test.expectedBody, string(response))
+ })
}
}
-func generateCert(commonName string) (tls.Certificate, error) {
- rsaKey, err := rsa.GenerateKey(rand.Reader, 4096)
+func TestRequestWithECH_StringConfig(t *testing.T) {
+ const commonName = "server.local"
+
+ echKey, err := NewECHKey(commonName)
+ require.NoError(t, err)
+
+ testCert, err := generateTestCert(commonName)
+ require.NoError(t, err)
+
+ echConfigList, err := ECHConfigToConfigList(echKey.Config)
+ require.NoError(t, err)
+
+ server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ fmt.Fprint(w, "Hello from string config!")
+ }))
+
+ server.TLS = &tls.Config{
+ Certificates: []tls.Certificate{testCert},
+ MinVersion: tls.VersionTLS13,
+ EncryptedClientHelloKeys: []tls.EncryptedClientHelloKey{*echKey},
+ }
+
+ server.StartTLS()
+ t.Cleanup(server.Close)
+
+ // Test with base64-encoded string config
+ echConfigBase64 := base64.StdEncoding.EncodeToString(echConfigList)
+
+ response, err := RequestWithECH(ECHRequestConfig[string]{
+ URL: server.URL + "/",
+ Host: commonName,
+ ECH: echConfigBase64,
+ Insecure: true,
+ })
+
+ require.NoError(t, err)
+ assert.Equal(t, "Hello from string config!", string(response))
+}
+
+func TestRequestWithECH_Errors(t *testing.T) {
+ testCases := []struct {
+ desc string
+ config ECHRequestConfig[string]
+ }{
+ {
+ desc: "invalid base64 ECH config",
+ config: ECHRequestConfig[string]{
+ URL: "https://localhost:12345/",
+ ECH: "not-valid-base64!!!",
+ },
+ },
+ {
+ desc: "invalid URL",
+ config: ECHRequestConfig[string]{
+ URL: "://invalid-url",
+ ECH: "dGVzdA==",
+ },
+ },
+ }
+
+ for _, test := range testCases {
+ t.Run(test.desc, func(t *testing.T) {
+ t.Parallel()
+
+ _, err := RequestWithECH(test.config)
+ require.Error(t, err)
+ })
+ }
+}
+
+func generateTestCert(commonName string) (tls.Certificate, error) {
+ rsaKey, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
return tls.Certificate{}, fmt.Errorf("failed to generate RSA key: %w", err)
}
+
keyBytes := x509.MarshalPKCS1PrivateKey(rsaKey)
- keyPEM := pem.EncodeToMemory(
- &pem.Block{
- Type: "RSA PRIVATE KEY",
- Bytes: keyBytes,
- },
- )
+ keyPEM := pem.EncodeToMemory(&pem.Block{
+ Type: "RSA PRIVATE KEY",
+ Bytes: keyBytes,
+ })
notBefore := time.Now()
- notAfter := notBefore.Add(365 * 24 * 10 * time.Hour)
+ notAfter := notBefore.Add(24 * time.Hour)
template := x509.Certificate{
- SerialNumber: big.NewInt(0),
+ SerialNumber: big.NewInt(1),
Subject: pkix.Name{CommonName: commonName},
- DNSNames: []string{commonName},
+ DNSNames: []string{commonName, "localhost"},
SignatureAlgorithm: x509.SHA256WithRSA,
NotBefore: notBefore,
NotAfter: notAfter,
BasicConstraintsValid: true,
- KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyAgreement | x509.KeyUsageKeyEncipherment | x509.KeyUsageDataEncipherment,
- ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth},
+ KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
+ ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
}
derBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, &rsaKey.PublicKey, rsaKey)
if err != nil {
return tls.Certificate{}, fmt.Errorf("failed to create certificate: %w", err)
}
- certPEM := pem.EncodeToMemory(
- &pem.Block{
- Type: "CERTIFICATE",
- Bytes: derBytes,
- },
- )
+
+ certPEM := pem.EncodeToMemory(&pem.Block{
+ Type: "CERTIFICATE",
+ Bytes: derBytes,
+ })
return tls.X509KeyPair(certPEM, keyPEM)
}
diff --git a/pkg/tls/zz_generated.deepcopy.go b/pkg/tls/zz_generated.deepcopy.go
index 10b4c4ff5..d86b28c51 100644
--- a/pkg/tls/zz_generated.deepcopy.go
+++ b/pkg/tls/zz_generated.deepcopy.go
@@ -116,6 +116,11 @@ func (in *Options) DeepCopyInto(out *Options) {
*out = make([]string, len(*in))
copy(*out, *in)
}
+ if in.ECHKeys != nil {
+ in, out := &in.ECHKeys, &out.ECHKeys
+ *out = make([]types.FileOrContent, len(*in))
+ copy(*out, *in)
+ }
if in.PreferServerCipherSuites != nil {
in, out := &in.PreferServerCipherSuites, &out.PreferServerCipherSuites
*out = new(bool)