feat: Improve ECH handling and validation in lease registration and API responses

Kim committed May 7, 2026 at 11:36 UTC efca077984690823458afda72cb29c463d9aaebc
5 files changed +109 -28
docs/src/routes/security-model/+page.md
+3 -3
@@ -33,9 +33,9 @@ Relay API TLS is separate from tenant TLS:
33
34 ## Tunnel ECH
35
36 -For default stream leases, the SDK derives an opaque lease identity and an opaque route hostname from the tunnel identity private key. The relay stores the route hostname for ECH routing and a hash of the public fallback hostname for plaintext-SNI fallback. When DNS automation is enabled, the relay also keeps the public hostname needed to publish and delete its HTTPS `ech` record.
36 +For default stream leases, the SDK derives an opaque route hostname from the tunnel identity private key. The relay still receives the lease identity name and can derive the public fallback hostname so it can validate plaintext-SNI fallback routing and manage DNS automation. The relay stores the route hostname for ECH routing and a validated hash of the public fallback hostname. When DNS automation is enabled, the relay also keeps the public hostname needed to publish and delete its HTTPS `ech` record.
37
38 -ECH-capable clients can use the opaque route hostname as the outer SNI while the real tenant SNI stays inside the ECH-protected ClientHello handled by the SDK. For multi-hop stream routes, the entry relay gets both matchers: a hostname hash for plaintext-SNI fallback and a hidden opaque route hostname for ECH. After the entry relay chooses the route, the remaining hops continue to use hop tokens and passthrough forwarding.
38 +ECH-capable clients can use the opaque route hostname as the outer SNI while the real tenant SNI stays inside the ECH-protected ClientHello handled by the SDK. For multi-hop stream routes, the entry relay gets the opaque route hostname for ECH and the public hostname needed to validate the plaintext-SNI fallback hash and manage DNS automation. After the entry relay chooses the route, the remaining hops continue to use hop tokens and passthrough forwarding.
39
40 When `ACME_DNS_PROVIDER` is configured, Portal publishes DNS HTTPS records with the `ech` parameter for the relay root and stream lease public hostnames. Without a DNS provider, operators must distribute the logged ECHConfigList through DNS HTTPS/SVCB or another ECH-capable bootstrap. Without that distribution, ordinary clients keep using the public hostname SNI and the relay routes them through the existing plaintext-SNI fallback.
41
@@ -52,7 +52,7 @@ Matching exporter values mean the sampled connection preserved passthrough. A mi
52 | Relays can see | Relays cannot see |
53 |---|---|
54 | Source IP and timing metadata | HTTP headers or body |
55 -| Tunnel hostname/SNI on the plaintext-SNI fallback path | Tenant TLS session keys |
55 +| Lease identity/public hostname, including SNI on the plaintext-SNI fallback path | Tenant TLS session keys |
56 | Opaque route hostnames on the ECH path | ECH-protected inner SNI when clients use the distributed ECHConfigList |
57 | Traffic volume and connection duration | Application payload on the stream path |
58 | Requested TCP/UDP transport metadata | Local service plaintext on the tenant TLS stream path |
portal/api_server.go
+55 -12
@@ -304,12 +304,34 @@ func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
304 if record.hasECHDNSRecord() {
305 err := manager.SyncECHConfig(r.Context(), record.ECHDNSHostname, record.ECHConfigList, s.cfg.SNIPort)
306 if err != nil {
307 - log.Warn().
308 - Err(err).
309 - Str("hostname", record.ECHDNSHostname).
310 - Str("route_hostname", record.Hostname).
311 - Str("address", record.Address).
312 - Msg("publish lease ech dns record")
307 + removed, _ := s.registry.Unregister(types.UnregisterRequest{AccessToken: resp.AccessToken})
308 + if removed == nil {
309 + record.Close()
310 + removed = record
311 + }
312 + if removed.hasENSGaslessDNSRecord() {
313 + deleteErr := manager.DeleteENSGaslessHostname(r.Context(), removed.Hostname)
314 + if deleteErr != nil {
315 + log.Warn().
316 + Err(deleteErr).
317 + Str("hostname", removed.Hostname).
318 + Str("address", removed.Address).
319 + Msg("delete lease ens gasless hostname after ech sync failure")
320 + }
321 + }
322 + if removed.hasECHDNSRecord() {
323 + deleteErr := manager.DeleteECHConfig(r.Context(), removed.ECHDNSHostname)
324 + if deleteErr != nil {
325 + log.Warn().
326 + Err(deleteErr).
327 + Str("hostname", removed.ECHDNSHostname).
328 + Str("route_hostname", removed.Hostname).
329 + Str("address", removed.Address).
330 + Msg("delete lease ech dns record after sync failure")
331 + }
332 + }
333 + writeAPIErrorResponse(w, err)
334 + return
335 }
336 }
337 }
@@ -547,12 +569,33 @@ func (s *Server) handleHop(w http.ResponseWriter, r *http.Request) {
569 if record.hasECHDNSRecord() {
570 err := manager.SyncECHConfig(r.Context(), record.ECHDNSHostname, record.ECHConfigList, s.cfg.SNIPort)
571 if err != nil {
550 - log.Warn().
551 - Err(err).
552 - Str("hostname", record.ECHDNSHostname).
553 - Str("route_hostname", record.Hostname).
554 - Str("address", record.Address).
555 - Msg("publish hop route ech dns record")
572 + removed := s.registry.DeleteHopRoute(&route)
573 + if removed == nil {
574 + removed = record
575 + }
576 + if removed.hasENSGaslessDNSRecord() {
577 + deleteErr := manager.DeleteENSGaslessHostname(r.Context(), removed.Hostname)
578 + if deleteErr != nil {
579 + log.Warn().
580 + Err(deleteErr).
581 + Str("hostname", removed.Hostname).
582 + Str("address", removed.Address).
583 + Msg("delete hop route ens gasless hostname after ech sync failure")
584 + }
585 + }
586 + if removed.hasECHDNSRecord() {
587 + deleteErr := manager.DeleteECHConfig(r.Context(), removed.ECHDNSHostname)
588 + if deleteErr != nil {
589 + log.Warn().
590 + Err(deleteErr).
591 + Str("hostname", removed.ECHDNSHostname).
592 + Str("route_hostname", removed.Hostname).
593 + Str("address", removed.Address).
594 + Msg("delete hop route ech dns record after sync failure")
595 + }
596 + }
597 + writeAPIErrorResponse(w, err)
598 + return
599 }
600 }
601 }
portal/lease.go
+22 -13
@@ -173,15 +173,26 @@ func (r *leaseRegistry) Register(req types.RegisterChallengeRequest, clientIP, r
173 if hostnameHash != "" && routeHostname == "" {
174 return nil, types.RegisterResponse{}, errors.New("hostname hash requires route hostname")
175 }
176 - if len(echConfigList) > 0 && (routeHostname == "" || hostnameHash == "") {
177 - return nil, types.RegisterResponse{}, errors.New("ech config list requires route hostname and hostname hash")
176 + if len(echConfigList) > 0 && routeHostname == "" {
177 + return nil, types.RegisterResponse{}, errors.New("ech config list requires route hostname")
178 }
179 + publicHostname := ""
180 if routeHostname != "" {
181 routeLabel, routeBase, ok := strings.Cut(routeHostname, ".")
182 normalizedRouteLabel, labelErr := utils.NormalizeDNSLabel(routeLabel)
183 if !ok || labelErr != nil || normalizedRouteLabel != routeLabel || routeBase != r.rootHostname {
184 return nil, types.RegisterResponse{}, errors.New("route hostname must be a child of relay root hostname")
185 }
186 +
187 + publicHostname, err = utils.LeaseHostname(identity.Name, r.rootHostname)
188 + if err != nil {
189 + return nil, types.RegisterResponse{}, err
190 + }
191 + expectedHostnameHash := utils.HostnameHash(publicHostname)
192 + if hostnameHash != "" && hostnameHash != expectedHostnameHash {
193 + return nil, types.RegisterResponse{}, errors.New("hostname hash does not match public hostname")
194 + }
195 + hostnameHash = expectedHostnameHash
196 }
197 if len(echConfigList) > 0 {
198 echConfigList, err = keyless.NormalizeEncryptedClientHelloConfigList(echConfigList)
@@ -191,13 +202,6 @@ func (r *leaseRegistry) Register(req types.RegisterChallengeRequest, clientIP, r
202 }
203 echDNSHostname := ""
204 if len(echConfigList) > 0 {
194 - publicHostname, err := utils.LeaseHostname(identity.Name, r.rootHostname)
195 - if err != nil {
196 - return nil, types.RegisterResponse{}, err
197 - }
198 - if utils.HostnameHash(publicHostname) != hostnameHash {
199 - return nil, types.RegisterResponse{}, errors.New("hostname hash does not match ech dns hostname")
200 - }
205 echDNSHostname = publicHostname
206 }
207 if req.UDPEnabled && !r.policy.IsUDPEnabled() {
@@ -509,15 +513,20 @@ func (r *leaseRegistry) RegisterHopRoute(route *types.HopRoute, now time.Time) (
513 return nil, errors.New("route hostname must be a child of relay root hostname")
514 }
515 }
512 - if len(echConfigList) > 0 {
513 - if publicHostname == "" || routeHostname == "" || hostnameHash == "" {
514 - return nil, errors.New("ech config list requires public hostname, route hostname, and hostname hash")
516 + if hostnameHash != "" {
517 + if publicHostname == "" {
518 + return nil, errors.New("hostname hash requires public hostname")
519 }
520 if !utils.HostnameMatchesBaseDomain(publicHostname, r.rootHostname) {
521 return nil, errors.New("public hostname must be a child of relay root hostname")
522 }
523 if utils.HostnameHash(publicHostname) != hostnameHash {
520 - return nil, errors.New("hostname hash does not match ech dns hostname")
524 + return nil, errors.New("hostname hash does not match public hostname")
525 + }
526 + }
527 + if len(echConfigList) > 0 {
528 + if publicHostname == "" || routeHostname == "" || hostnameHash == "" {
529 + return nil, errors.New("ech config list requires public hostname, route hostname, and hostname hash")
530 }
531 echConfigList, err = keyless.NormalizeEncryptedClientHelloConfigList(echConfigList)
532 if err != nil {
portal/lease_test.go
+28
@@ -139,6 +139,17 @@ func TestLeaseRegistryAutomaticECHRouteFallsBackToPlainSNI(t *testing.T) {
139 }, "203.0.113.10", ""); err == nil {
140 t.Fatal("Register(fallback hash only) error = nil, want error")
141 }
142 +
143 + if _, _, err := registry.Register(types.RegisterChallengeRequest{
144 + Identity: newTestLeaseIdentity(t, "attacker"),
145 + RouteHostname: "ech-attacker.example.com",
146 + HostnameHash: utils.HostnameHash("victim.example.com"),
147 + }, "203.0.113.10", ""); err == nil {
148 + t.Fatal("Register(mismatched hostname hash) error = nil, want error")
149 + }
150 + if lookedUp, ok := registry.Lookup("victim.example.com"); ok {
151 + t.Fatalf("Lookup(victim hostname) = %v, true; mismatched hash must not route", lookedUp)
152 + }
153 }
154
155 func TestLeaseRegistryHopRouteCanExposeECHAndPlainSNIFallback(t *testing.T) {
@@ -167,6 +178,7 @@ func TestLeaseRegistryHopRouteCanExposeECHAndPlainSNIFallback(t *testing.T) {
178 }
179 route := baseRoute
180 route.RouteHostname = "ech-demo.example.com"
181 + route.PublicHostname = "demo.example.com"
182 route.HostnameHash = utils.HostnameHash("demo.example.com")
183 route.Metadata.Hide = true
184
@@ -178,6 +190,22 @@ func TestLeaseRegistryHopRouteCanExposeECHAndPlainSNIFallback(t *testing.T) {
190 if _, err := registry.RegisterHopRoute(&hashOnlyRoute, now); err == nil {
191 t.Fatal("RegisterHopRoute(hash only) error = nil, want error")
192 }
193 + missingPublicRoute := baseRoute
194 + missingPublicRoute.RouteHostname = "ech-missing-public.example.com"
195 + missingPublicRoute.HostnameHash = utils.HostnameHash("missing-public.example.com")
196 + if _, err := registry.RegisterHopRoute(&missingPublicRoute, now); err == nil {
197 + t.Fatal("RegisterHopRoute(missing public hostname) error = nil, want error")
198 + }
199 + mismatchedRoute := baseRoute
200 + mismatchedRoute.RouteHostname = "ech-attacker.example.com"
201 + mismatchedRoute.PublicHostname = "attacker.example.com"
202 + mismatchedRoute.HostnameHash = utils.HostnameHash("victim.example.com")
203 + if _, err := registry.RegisterHopRoute(&mismatchedRoute, now); err == nil {
204 + t.Fatal("RegisterHopRoute(mismatched hostname hash) error = nil, want error")
205 + }
206 + if lookedUp, ok := registry.Lookup("victim.example.com"); ok {
207 + t.Fatalf("Lookup(victim hostname) = %v, true; mismatched hop hash must not route", lookedUp)
208 + }
209 if _, ok := registry.Lookup("demo.example.com"); !ok {
210 t.Fatal("Lookup(plain route) = false, want true")
211 }
sdk/api_client.go
+1
@@ -172,6 +172,7 @@ func (l *listener) registerLease(ctx context.Context, ttl time.Duration, udpEnab
172 route.RouteHostname = routeHostname
173 route.HostnameHash = utils.HostnameHash(publicHostname)
174 route.ECHConfigList = append([]byte(nil), echConfigList...)
175 + route.Metadata = l.metadata.Copy()
176 route.Metadata.Hide = true
177 hopRoutes = append(hopRoutes, route)
178 } else {