Skip to content

[ENHANCEMENTS] Native Support for Encrypted Client Hello (ECH) #1319

Open

Description

@Cyantox

Is your feature request related to a problem? Please describe.
Zoraxy currently does not support Encrypted Client Hello (ECH, RFC 9849). Without ECH, the SNI is sent in plaintext in the TLS ClientHello, so network observers (ISPs, middleboxes) can see which hostnames are requested on a shared IP. Major clients (Firefox, Chrome, Android 17+) already support ECH; they only use it when the server publishes an ECH config via a DNS HTTPS record (RFC 9460, ech parameter defined in RFC 9848).

Describe the solution you'd like
Native server-side ECH support:

  1. Key management & rotation: Generate HPKE key pairs (X25519; Go 1.26's stdlib crypto/hpke means no new dependency), store them in Zoraxy's config/DB with the same protection as TLS private keys (included in backup/export, never written to logs). Import/export in the standard ECH PEM format (RFC 9934) for portability. Rotate on a configurable schedule (real-world deployments range from hours, e.g. Cloudflare, to months): only the newest key is advertised; previous keys stay decrypt-only for at least the DNS TTL so clients with cached configs are not rejected. Current keys should be sent as retry configs (SendAsRetry). Set maximum_name_length in the ECHConfig from the longest configured hostname, so clients pad the inner SNI and name length does not reveal the target host.

  2. TLS termination: Use Go's crypto/tls server-side ECH (EncryptedClientHelloKeys, Go 1.24+; GetEncryptedClientHelloKeys callback for hot rotation, Go 1.25+). After decryption, the inner SNI feeds the existing host routing unchanged. ECH only applies to TLS 1.3; TLS 1.2 clients keep working without it (but still leak the SNI, and in TLS 1.2 also the certificate itself).

    • Inner vs. outer SNI: Certificate selection and host-specific TLS behavior are both resolved inside the GetCertificate callback (tlscert.Manager.GetCert -> hostSpecificTlsBehavior), which crypto/tls calls after ECH decryption, so they already receive the inner SNI. Zoraxy does not use GetConfigForClient or peek the raw ClientHello, so the issue Traefik hit (per-host TLS options picked from the outer SNI) should not apply. When ECH is rejected, GetCert receives the public_name, which needs a valid certificate (see point 3).
  3. Outer SNI / public_name: A configurable public name (e.g. ech.example.com) with its own certificate, shared by all ECH-enabled hosts. An existing wildcard certificate already covers it via the X.509 matching in GetCertificateByHostname. The UI should recommend a generic name that is not one of the protected hostnames. Note that a public_name under the same apex domain hides which subdomain is requested, not the domain itself.

  4. DNS publishing (two phases):

    • Phase 1: Show the base64 ECHConfigList in the UI so users can add the ech= parameter to their HTTPS records manually.
    • Phase 2 (optional): Publish/update HTTPS records automatically via the DNS provider integration, ideally including alpn="h2,h3" when HTTP/3 is enabled. Zoraxy's DNS integration currently uses lego's challenge.Provider (src/mod/acme/acme_dns.go, acmedns/acmedns.go), which only presents/cleans up ACME TXT records, so this needs an additional record API (e.g. libdns, which Caddy uses for this).
  5. UI: Global toggle (e.g. in the redesigned Ingress Advanced Settings) plus per-rule selection of which hostnames get an ECH config published.

  6. Observability: Log/expose the ECH result per request via r.TLS.ECHAccepted and r.TLS.ServerName (inner SNI if accepted, outer SNI otherwise), available for both TCP and HTTP/3. This is similar in spirit to nginx's $ssl_ech_status / $ssl_ech_outer_server_name, though Go does not directly distinguish GREASE from real ECH attempts. It makes DNS misconfigurations and cases like the Cloudflare pitfall below visible immediately.

Describe alternatives you've considered

  • Putting Cloudflare or another ECH-capable edge in front of Zoraxy. This works, but moves TLS termination to a third party and bypasses Zoraxy's central management.
  • Switching to Caddy (automated ECH since v2.10) or nginx (ECH since 1.29.4, requires an ECH-capable OpenSSL, no built-in key rotation or DNS publishing). I'd prefer to keep Zoraxy.

Additional context

  • Implementation hint (from reading main): inbound TLS is terminated in a single place, the tls.Config in src/mod/dynamicproxy/dynamicproxy.go (GetCertificate: router.Option.TlsManager.GetCert). The HTTP/3 listener clones this config (tlsConfig.Clone()), so setting GetEncryptedClientHelloKeys there should cover both TCP and QUIC. go.mod is already on Go 1.26, so no toolchain bump is needed. r.TLS is currently only used as an "is HTTPS" check, so ECH logging would be new but small.
  • Reference implementations:
  • Testing: tools like echcheck perform real ECH negotiation and check retry configs, GREASE handling, certificate validity for inner/outer names, and that the real hostname never appears in cleartext.
  • Limitations worth noting: ECH only protects the hostname if the client also uses encrypted DNS (DoH/DoT), and the server IP stays visible. The benefit grows with the number of hostnames behind the same IP. Per-subdomain certificates also leak hostnames via Certificate Transparency logs, so ECH works best combined with wildcard certificates (which Zoraxy already supports via DNS-01); a hint or default in the UI would help.
  • Related leak: for unknown SNI or IP-only connections (e.g. scanners), GetCertificateByHostname falls back to the configured FallbackCert, otherwise ./tmp/localhost.pem. That is fine by default, but if a real (e.g. wildcard) certificate is set as fallback, scanners learn the domain without sending any SNI. The UI could warn about this, and an option to reject handshakes for unknown SNI (returning an error from GetCert, similar to nginx's ssl_reject_handshake) would close it.
  • Known pitfall (reported by Traefik users): with Cloudflare-proxied domains and split DNS, browsers may use Cloudflare's ECH config against the local proxy and fail with an outer SNI like cloudflare-ech.com. The observability from point 6 would make this visible immediately.
  • HTTP/3 (Add inbound HTTP/3 (QUIC) support #1311, v3.3.5-rc2): quic-go fixed ECH handling in v0.51 (unsuccessful Encrypted Client Hello (ech) quic-go/quic-go#4980), so the QUIC listener should be able to use the same keys. Not tested by me.

Maintenance footprint & incremental plan
I read the reply in the WAF issue about limited maintainer capacity and the unmaintained certificate/SNI manager. ECH should not need changes there: tlscert.Manager.GetCert already receives the inner SNI after decryption. The core could live in a small, isolated module (e.g. src/mod/ech) plus a few lines in dynamicproxy.go. Proposed as small, separately reviewable PRs:

  1. MVP: Generate/store one ECH key, wire GetEncryptedClientHelloKeys into the existing tls.Config, global toggle, show the ECHConfigList for manual DNS entry (Phase 1).
  2. Rotation: Scheduled key rotation with decrypt-only previous keys.
  3. Logging: ECHAccepted / ServerName in the request log.
  4. Independent small fix: Optional "reject handshakes for unknown SNI" (only touches GetCert's fallback branch).

Automatic DNS publishing (Phase 2), per-rule selection and PEM import/export are explicitly out of scope for the first iteration; the list above is meant as a direction, not a requirement.

To be transparent: I'm not a developer, so I can't maintain this feature myself, and I deliberately didn't want to submit AI-generated code. But I'm happy to test an implementation in my homelab and give feedback.

This feature request was written with the help of AI (Claude Opus 5.5).

Activity

  1. tobychui commented on Sep 28, 2026

    @tobychui
    Owner

    Hey @Cyantox

    Thanks for the idea. Since I do not have the proper knowledge to work on this function (or review code generated by AI), I will leave this here until someone with proper infosec or networking background to pick this up.

  2. Cyantox commented on Sep 28, 2026

    @Cyantox
    Author

    Hey @Cyantox

    Thanks for the idea. Since I do not have the proper knowledge to work on this function (or review code generated by AI), I will leave this here until someone with proper infosec or networking background to pick this up.

    Thanks for the reply! Then I hope someone picks this up and that my research above helps as a starting point :D

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions