diff --git a/docs/content/https/tls.md b/docs/content/https/tls.md index abda2dd93..fc0ecdff5 100644 --- a/docs/content/https/tls.md +++ b/docs/content/https/tls.md @@ -587,4 +587,55 @@ 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" %} 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 5c0935e89..328e36b80 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.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 @@ -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 // 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 @@ -323,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 diff --git a/go.sum b/go.sum index 00b764cce..ea31ca885 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.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/provider/file/file.go b/pkg/provider/file/file.go index a93d4b551..5fd3fc045 100644 --- a/pkg/provider/file/file.go +++ b/pkg/provider/file/file.go @@ -267,6 +267,18 @@ func (p *Provider) loadFileConfig(ctx context.Context, filename string, parseTem } options.ClientAuth.CAFiles = caCerts + var echKeyContents []types.FileOrContent + for _, echKey := range options.ECHKeys { + content, err := echKey.Read() + if err != nil { + log.Ctx(ctx).Error().Err(err).Send() + continue + } + + 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 new file mode 100644 index 000000000..95be41c03 --- /dev/null +++ b/pkg/tls/ech.go @@ -0,0 +1,165 @@ +package tls + +import ( + "crypto/tls" + "encoding/binary" + "encoding/pem" + "errors" + "fmt" + "math/rand/v2" + + "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 { + 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, errors.New("missing ECH configuration or private key in PEM file") + } + + // go ecdh now only supports SHA-256 (32-byte private key) + 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 + + return &k, nil +} + +func MarshalECHKey(k *tls.EncryptedClientHelloKey) ([]byte, error) { + if len(k.Config) == 0 || len(k.PrivateKey) == 0 { + return nil, errors.New("missing ECH configuration or private key") + } + 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})...) + + return pemData, nil +} + +type echCipher struct { + KDFID uint16 + AEADID uint16 +} + +type echExtension struct { + Type uint16 + Data []byte +} + +type echConfig struct { + 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 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 new file mode 100644 index 000000000..34420e9a3 --- /dev/null +++ b/pkg/tls/ech_test.go @@ -0,0 +1,441 @@ +package tls + +import ( + "crypto/rand" + "crypto/rsa" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/base64" + "encoding/pem" + "fmt" + "io" + "math/big" + "net/http" + "net/http/httptest" + "net/url" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// 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 +} + +// 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, + }, + }, + } + + 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 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) + 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, ECH-enabled TLS server!") + })) + + server.TLS = &tls.Config{ + Certificates: []tls.Certificate{testCert}, + MinVersion: tls.VersionTLS13, + EncryptedClientHelloKeys: []tls.EncryptedClientHelloKey{*echKey}, + } + + server.StartTLS() + t.Cleanup(server.Close) + + 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!", + }, + } + + 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 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, + }) + + notBefore := time.Now() + notAfter := notBefore.Add(24 * time.Hour) + + template := x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: commonName}, + DNSNames: []string{commonName, "localhost"}, + SignatureAlgorithm: x509.SHA256WithRSA, + NotBefore: notBefore, + NotAfter: notAfter, + BasicConstraintsValid: true, + 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, + }) + + 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 } 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)