Repository navigation
[ENHANCEMENTS] Native Support for Encrypted Client Hello (ECH) #1319
Description
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:
-
Key management & rotation: Generate HPKE key pairs (X25519; Go 1.26's stdlib
crypto/hpkemeans 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). Setmaximum_name_lengthin the ECHConfig from the longest configured hostname, so clients pad the inner SNI and name length does not reveal the target host. -
TLS termination: Use Go's crypto/tls server-side ECH (
EncryptedClientHelloKeys, Go 1.24+;GetEncryptedClientHelloKeyscallback 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
GetCertificatecallback (tlscert.Manager.GetCert->hostSpecificTlsBehavior), which crypto/tls calls after ECH decryption, so they already receive the inner SNI. Zoraxy does not useGetConfigForClientor 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,GetCertreceives thepublic_name, which needs a valid certificate (see point 3).
- Inner vs. outer SNI: Certificate selection and host-specific TLS behavior are both resolved inside the
-
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 inGetCertificateByHostname. The UI should recommend a generic name that is not one of the protected hostnames. Note that apublic_nameunder the same apex domain hides which subdomain is requested, not the domain itself. -
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'schallenge.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).
- Phase 1: Show the base64 ECHConfigList in the UI so users can add the
-
UI: Global toggle (e.g. in the redesigned Ingress Advanced Settings) plus per-rule selection of which hostnames get an ECH config published.
-
Observability: Log/expose the ECH result per request via
r.TLS.ECHAcceptedandr.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, thetls.Configinsrc/mod/dynamicproxy/dynamicproxy.go(GetCertificate: router.Option.TlsManager.GetCert). The HTTP/3 listener clones this config (tlsConfig.Clone()), so settingGetEncryptedClientHelloKeysthere should cover both TCP and QUIC.go.modis already on Go 1.26, so no toolchain bump is needed.r.TLSis currently only used as an "is HTTPS" check, so ECH logging would be new but small. - Reference implementations:
- Caddy's
modules/caddytls/ech.go(Go, key generation, DNS publishing). - Traefik has an open PR (Add server-side Encrypted Client Hello (ECH) support traefik/traefik#13758) with
echKeys/echDecryptOnlyKeys, RFC 9934 PEM keys, TCP + HTTP/3, and TLS options re-selected by the decrypted server name. - Envoy has an open request (Support for TLS ECH (Encrypted Client Hello) envoyproxy/envoy#44165).
- Caddy's
- Testing: tools like
echcheckperform 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),
GetCertificateByHostnamefalls back to the configuredFallbackCert, 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 fromGetCert, similar to nginx'sssl_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:
- MVP: Generate/store one ECH key, wire
GetEncryptedClientHelloKeysinto the existingtls.Config, global toggle, show the ECHConfigList for manual DNS entry (Phase 1). - Rotation: Scheduled key rotation with decrypt-only previous keys.
- Logging:
ECHAccepted/ServerNamein the request log. - 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
tobychui commentedon Sep 28, 2026
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.
Cyantox commentedon Sep 28, 2026
Thanks for the reply! Then I hope someone picks this up and that my research above helps as a starting point :D