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 {