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` | -| `traefik/http/services/Service03/loadBalancer/healthCheck/headers/name0` | `foobar` | -| `traefik/http/services/Service03/loadBalancer/healthCheck/headers/name1` | `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` | -| `traefik/http/services/Service03/loadBalancer/passHostHeader` | `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` | +| `traefik/http/services/Service02/loadBalancer/healthCheck/headers/name0` | `foobar` | +| `traefik/http/services/Service02/loadBalancer/healthCheck/headers/name1` | `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` | +| `traefik/http/services/Service02/loadBalancer/passHostHeader` | `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)