feat: Implement verification in keyless registration
Kim committed
May 7, 2026 at 16:31 UTC
b6c10c35c058f0455ee0895d0cde3df041468665
9 files changed
+188
-88
go.mod
+1
-1
@@ -16,7 +16,7 @@ require (
16
github.com/go-acme/lego/v4 v4.34.0
17
github.com/go-jose/go-jose/v4 v4.1.4
18
github.com/go-rod/rod v0.116.2
19
- github.com/gosuda/keyless_tls v0.0.1
19
+ github.com/gosuda/keyless_tls v0.0.2-0.20260507061030-5128be6b5008
20
github.com/hashicorp/yamux v0.1.2
21
github.com/knadh/koanf/parsers/toml/v2 v2.2.0
22
github.com/knadh/koanf/providers/file v1.2.1
go.sum
+2
@@ -115,6 +115,8 @@ github.com/googleapis/gax-go/v2 v2.21.0 h1:h45NjjzEO3faG9Lg/cFrBh2PgegVVgzqKzuZl
115
github.com/googleapis/gax-go/v2 v2.21.0/go.mod h1:But/NJU6TnZsrLai/xBAQLLz+Hc7fHZJt/hsCz3Fih4=
116
github.com/gosuda/keyless_tls v0.0.1 h1:IGuGHxqqxSTJL+7kHPwdoLveXpBbOhBGm/T4gnVTUtU=
117
github.com/gosuda/keyless_tls v0.0.1/go.mod h1:BOhUZgiAAQzxKO3QcC4fCXgd/+lqxgIu1OyIYTqtta8=
118
+github.com/gosuda/keyless_tls v0.0.2-0.20260507061030-5128be6b5008 h1:KuP/5VlPJwqZNyAV5U60C/j8Pc5O8ENkWPTgP7mEvj0=
119
+github.com/gosuda/keyless_tls v0.0.2-0.20260507061030-5128be6b5008/go.mod h1:BOhUZgiAAQzxKO3QcC4fCXgd/+lqxgIu1OyIYTqtta8=
120
github.com/hashicorp/yamux v0.1.2 h1:XtB8kyFOyHXYVFnwT5C3+Bdo8gArse7j2AQ0DA0Uey8=
121
github.com/hashicorp/yamux v0.1.2/go.mod h1:C+zze2n6e/7wshOZep2A70/aQU6QBRWJO/G6FT1wIns=
122
github.com/holiman/uint256 v1.3.2 h1:a9EgMPSC1AAaj1SZL5zIQD3WbwTuHrMGOerLjGmM/TA=
portal/api_server.go
+15
-1
@@ -118,6 +118,10 @@ func (s *Server) apiHandler(base *http.ServeMux, keylessSignerHandler http.Handl
118
http.NotFound(w, r)
119
return
120
}
121
+ if err := s.registry.verifySigningAccessToken(r.Header.Get(types.HeaderAccessToken)); err != nil {
122
+ writeAPIErrorResponse(w, err)
123
+ return
124
+ }
125
keylessSignerHandler.ServeHTTP(w, r)
126
default:
127
base.ServeHTTP(w, r)
@@ -599,7 +603,17 @@ func (s *Server) handleHop(w http.ResponseWriter, r *http.Request) {
603
}
604
}
605
}
602
- utils.WriteAPIData(w, http.StatusOK, map[string]any{})
606
+ var accessToken string
607
+ if record.isPublicEntry() {
608
+ accessToken, err = s.registry.issueLeaseAccessToken(record, now)
609
+ if err != nil {
610
+ writeAPIErrorResponse(w, &apiError{types.APIErrorCodeInternal, err.Error(), http.StatusInternalServerError})
611
+ return
612
+ }
613
+ }
614
+ utils.WriteAPIData(w, http.StatusOK, types.HopRouteResponse{
615
+ AccessToken: accessToken,
616
+ })
617
}
618
619
func (s *Server) handleConnect(w http.ResponseWriter, r *http.Request) {
portal/keyless/client.go
+3
-1
@@ -6,6 +6,7 @@ import (
6
"crypto/tls"
7
"errors"
8
"fmt"
9
+ "net/http"
10
"net/url"
11
"strings"
12
@@ -14,7 +15,7 @@ import (
15
"github.com/gosuda/portal-tunnel/v2/utils"
16
)
17
17
-func BuildClientTLSConfig(relayURL, hostname string, echKeys []tls.EncryptedClientHelloKey) (*tls.Config, ioCloser, error) {
18
+func BuildClientTLSConfig(relayURL, hostname string, echKeys []tls.EncryptedClientHelloKey, headers func() http.Header) (*tls.Config, ioCloser, error) {
19
normalizedRelayURL, err := utils.NormalizeRelayURL(relayURL)
20
if err != nil {
21
return nil, nil, err
@@ -46,6 +47,7 @@ func BuildClientTLSConfig(relayURL, hostname string, echKeys []tls.EncryptedClie
47
ServerName: serverName,
48
KeyID: RelayKeyID,
49
RootCAPEM: rootCAPEM,
50
+ Headers: headers,
51
}, certPEM)
52
if err != nil {
53
return nil, nil, fmt.Errorf("create keyless remote signer: %w", err)
portal/lease.go
+33
@@ -725,6 +725,39 @@ func (r *leaseRegistry) consumeVerifiedRegisterChallenge(req types.RegisterReque
725
return nil, auth.ErrRegisterChallengeNotFound
726
}
727
728
+func (r *leaseRegistry) issueLeaseAccessToken(record *leaseRecord, now time.Time) (string, error) {
729
+ token, _, err := auth.IssueLeaseAccessToken(r.tokenPrivateKey, r.tokenKeyID, r.tokenIssuer, record.Identity, record.ExpiresAt.Sub(now))
730
+ return token, err
731
+}
732
+
733
+func (r *leaseRegistry) verifySigningAccessToken(token string) error {
734
+ now := time.Now().UTC()
735
+ claims, err := auth.VerifyLeaseAccessToken(token, r.tokenPublicKey, r.tokenIssuer, time.Now().UTC())
736
+ if err != nil {
737
+ return errUnauthorized
738
+ }
739
+
740
+ r.mu.RLock()
741
+ defer r.mu.RUnlock()
742
+
743
+ for _, record := range r.records {
744
+ if record == nil || record.isExpired(now) || record.Key() != claims.Identity.Key() {
745
+ continue
746
+ }
747
+ if record.stream != nil && record.isPublicEntry() {
748
+ if !r.policy.IsIdentityRoutable(record.Key()) {
749
+ return errLeaseRejected
750
+ }
751
+ return nil
752
+ }
753
+ _, _, hasNextHop := record.nextHop()
754
+ if record.stream == nil && record.isPublicEntry() && hasNextHop {
755
+ return nil
756
+ }
757
+ }
758
+ return errUnauthorized
759
+}
760
+
761
func (r *leaseRegistry) Touch(key, clientIP string, now time.Time) {
762
r.mu.Lock()
763
defer r.mu.Unlock()
portal/server_test.go
+2
-2
@@ -165,8 +165,8 @@ func TestServerStartInitializesLocalACMEAndSigner(t *testing.T) {
165
}
166
defer signResp.Body.Close()
167
168
- if signResp.StatusCode != http.StatusMethodNotAllowed {
169
- t.Fatalf("GET /v1/sign status = %d, want %d", signResp.StatusCode, http.StatusMethodNotAllowed)
168
+ if signResp.StatusCode != http.StatusForbidden {
169
+ t.Fatalf("GET /v1/sign status = %d, want %d", signResp.StatusCode, http.StatusForbidden)
170
}
171
}
172
sdk/api_client.go
+69
-51
@@ -227,16 +227,10 @@ func (l *listener) registerLease(ctx context.Context, ttl time.Duration, udpEnab
227
_ = l.unregisterLease(context.Background(), resp.AccessToken, hopRoutes)
228
return types.RegisterResponse{}, nil, "", "", errors.New("relay returned mismatched lease identity")
229
}
230
- if len(hopRoutes) > 0 {
231
- if err := l.syncHopRoutes(ctx, http.MethodPost, resp.ExpiresAt, hopRoutes); err != nil {
232
- _ = l.unregisterLease(context.Background(), resp.AccessToken, hopRoutes)
233
- return types.RegisterResponse{}, nil, "", "", err
234
- }
235
- }
230
return resp, hopRoutes, publicHostname, routeHostname, nil
231
}
232
239
-func (l *listener) renewRegisteredLease(ctx context.Context, ttl time.Duration, accessToken string, hopRoutes []types.HopRoute) (types.RenewResponse, error) {
233
+func (l *listener) renewRegisteredLease(ctx context.Context, ttl time.Duration, accessToken string) (types.RenewResponse, error) {
234
var resp types.RenewResponse
235
if err := utils.HTTPDoAPIPath(ctx, l.httpClient, l.relayURL, http.MethodPost, types.PathSDKRenew, types.RenewRequest{
236
AccessToken: accessToken,
@@ -245,15 +239,12 @@ func (l *listener) renewRegisteredLease(ctx context.Context, ttl time.Duration,
239
}, nil, &resp); err != nil {
240
return types.RenewResponse{}, err
241
}
248
- if err := l.syncHopRoutes(ctx, http.MethodPost, resp.ExpiresAt, hopRoutes); err != nil {
249
- return types.RenewResponse{}, err
250
- }
242
return resp, nil
243
}
244
245
func (l *listener) unregisterLease(ctx context.Context, accessToken string, hopRoutes []types.HopRoute) error {
246
var unregisterErr error
256
- if err := l.syncHopRoutes(ctx, http.MethodDelete, time.Time{}, hopRoutes); err != nil {
247
+ if err := l.unregisterHopRoutes(ctx, hopRoutes); err != nil {
248
unregisterErr = errors.Join(unregisterErr, err)
249
}
250
err := utils.HTTPDoAPIPath(ctx, l.httpClient, l.relayURL, http.MethodPost, types.PathSDKUnregister, types.UnregisterRequest{
@@ -262,70 +253,97 @@ func (l *listener) unregisterLease(ctx context.Context, accessToken string, hopR
253
return errors.Join(unregisterErr, err)
254
}
255
265
-func (l *listener) syncHopRoutes(ctx context.Context, method string, expiresAt time.Time, routes []types.HopRoute) error {
256
+func (l *listener) registerHopRoutes(ctx context.Context, expiresAt time.Time, routes []types.HopRoute) (multihopAccessToken string, err error) {
257
if len(routes) == 0 {
267
- return nil
258
+ return "", nil
259
+ }
260
+ if l.relaySet == nil {
261
+ return "", errors.New("multi-hop relay set is unavailable")
262
}
263
270
- orderedRoutes := routes
271
- if method == http.MethodPost {
272
- if l.relaySet == nil {
273
- return errors.New("multi-hop relay set is unavailable")
274
- }
275
- orderedRoutes = append([]types.HopRoute(nil), routes...)
276
- now := time.Now().UTC()
277
- for i := range orderedRoutes {
278
- desc, ok := l.relaySet.OverlayRelayDescriptor(orderedRoutes[i].ForwardRelay.APIHTTPSAddr, now)
279
- if !ok {
280
- return fmt.Errorf("multi-hop forward relay %d descriptor is unavailable", i)
281
- }
282
- orderedRoutes[i].ForwardRelay = desc
264
+ orderedRoutes := append([]types.HopRoute(nil), routes...)
265
+ now := time.Now().UTC()
266
+ for i := range orderedRoutes {
267
+ desc, ok := l.relaySet.OverlayRelayDescriptor(orderedRoutes[i].ForwardRelay.APIHTTPSAddr, now)
268
+ if !ok {
269
+ return "", fmt.Errorf("multi-hop forward relay %d descriptor is unavailable", i)
270
}
284
- slices.Reverse(orderedRoutes)
271
+ orderedRoutes[i].ForwardRelay = desc
272
}
273
+ slices.Reverse(orderedRoutes)
274
287
- var syncErr error
275
for _, unsignedRoute := range orderedRoutes {
276
unsignedRoute.FirstSeenAt = expiresAt.Add(-30 * time.Second)
290
- route, err := auth.SignHopRoute(method, unsignedRoute, l.identity, expiresAt)
277
+ route, err := auth.SignHopRoute(http.MethodPost, unsignedRoute, l.identity, expiresAt)
278
if err != nil {
292
- if method == http.MethodDelete {
293
- syncErr = errors.Join(syncErr, err)
294
- continue
295
- }
296
- return err
279
+ return "", err
280
}
281
relayURL, err := url.Parse(route.RelayURL)
282
if err != nil {
300
- err = fmt.Errorf("parse hop route relay url: %w", err)
301
- if method == http.MethodDelete {
302
- syncErr = errors.Join(syncErr, err)
303
- continue
304
- }
305
- return err
283
+ return "", fmt.Errorf("parse hop route relay url: %w", err)
284
}
285
286
bootstrapCtx, cancel := context.WithTimeout(ctx, defaultDialTimeout+defaultHandshakeTimeout)
287
_, client, err := utils.NewHTTPTLSClient(bootstrapCtx, relayURL, l.requestTimeout)
288
cancel()
289
if err != nil {
312
- if method == http.MethodDelete {
313
- syncErr = errors.Join(syncErr, err)
314
- continue
315
- }
316
- return err
290
+ return "", err
291
}
292
transport, _ := client.Transport.(*http.Transport)
319
- err = utils.HTTPDoAPIPath(ctx, client, relayURL, method, types.PathSDKHop, route, nil, nil)
293
+ entryRoute := route.MatchToken == "" && route.RouteHostname != ""
294
+ var hopResp types.HopRouteResponse
295
+ var out any
296
+ if entryRoute {
297
+ out = &hopResp
298
+ }
299
+ err = utils.HTTPDoAPIPath(ctx, client, relayURL, http.MethodPost, types.PathSDKHop, route, nil, out)
300
if transport != nil {
301
transport.CloseIdleConnections()
302
}
303
if err != nil {
324
- if method == http.MethodDelete {
325
- syncErr = errors.Join(syncErr, err)
326
- continue
327
- }
328
- return err
304
+ return "", err
305
+ }
306
+ if !entryRoute {
307
+ continue
308
+ }
309
+ if hopResp.AccessToken == "" {
310
+ return "", errors.New("entry relay did not return access token")
311
+ }
312
+ multihopAccessToken = hopResp.AccessToken
313
+ return multihopAccessToken, nil
314
+ }
315
+ return "", errors.New("entry hop route did not return access token")
316
+}
317
+
318
+func (l *listener) unregisterHopRoutes(ctx context.Context, routes []types.HopRoute) error {
319
+ var syncErr error
320
+ for _, unsignedRoute := range routes {
321
+ route, err := auth.SignHopRoute(http.MethodDelete, unsignedRoute, l.identity, time.Time{})
322
+ if err != nil {
323
+ syncErr = errors.Join(syncErr, err)
324
+ continue
325
+ }
326
+ relayURL, err := url.Parse(route.RelayURL)
327
+ if err != nil {
328
+ syncErr = errors.Join(syncErr, fmt.Errorf("parse hop route relay url: %w", err))
329
+ continue
330
+ }
331
+
332
+ bootstrapCtx, cancel := context.WithTimeout(ctx, defaultDialTimeout+defaultHandshakeTimeout)
333
+ _, client, err := utils.NewHTTPTLSClient(bootstrapCtx, relayURL, l.requestTimeout)
334
+ cancel()
335
+ if err != nil {
336
+ syncErr = errors.Join(syncErr, err)
337
+ continue
338
+ }
339
+ transport, _ := client.Transport.(*http.Transport)
340
+ err = utils.HTTPDoAPIPath(ctx, client, relayURL, http.MethodDelete, types.PathSDKHop, route, nil, nil)
341
+ if transport != nil {
342
+ transport.CloseIdleConnections()
343
+ }
344
+ if err != nil {
345
+ syncErr = errors.Join(syncErr, err)
346
+ continue
347
}
348
}
349
return syncErr
sdk/listener.go
+59
-32
@@ -74,7 +74,7 @@ type listener struct {
74
tlsConfig *tls.Config
75
76
leaseMu sync.RWMutex
77
- lease *listenerLease
77
+ lease *listenerSnapshot
78
}
79
80
// newListener creates one relay listener and its dedicated relay transport for one relay URL.
@@ -241,21 +241,22 @@ func (l *listener) Close() error {
241
return closeErr
242
}
243
244
-type listenerLease struct {
245
- hostname string
246
- echConfigList []byte
247
- udpAddr string
248
- tcpAddr string
249
- accessToken string
250
- expiresAt time.Time
251
- sniPort int
252
- publicURLBase *url.URL
253
- tlsConfig *tls.Config
254
- tlsCloser io.Closer
255
- hopRoutes []types.HopRoute
244
+type listenerSnapshot struct {
245
+ hostname string
246
+ echConfigList []byte
247
+ udpAddr string
248
+ tcpAddr string
249
+ accessToken string
250
+ multihopAccessToken string
251
+ expiresAt time.Time
252
+ sniPort int
253
+ publicURLBase *url.URL
254
+ tlsConfig *tls.Config
255
+ tlsCloser io.Closer
256
+ hopRoutes []types.HopRoute
257
}
258
258
-func (l *listener) clearLease(reason string) *listenerLease {
259
+func (l *listener) clearLease(reason string) *listenerSnapshot {
260
l.leaseMu.Lock()
261
lease := l.lease
262
l.lease = nil
@@ -270,12 +271,12 @@ func (l *listener) clearLease(reason string) *listenerLease {
271
return lease
272
}
273
273
-func (l *listener) leaseSnapshot() (listenerLease, bool) {
274
+func (l *listener) leaseSnapshot() (listenerSnapshot, bool) {
275
l.leaseMu.RLock()
276
defer l.leaseMu.RUnlock()
277
278
if l.lease == nil {
278
- return listenerLease{}, false
279
+ return listenerSnapshot{}, false
280
}
281
return *l.lease, true
282
}
@@ -362,7 +363,7 @@ func (l *listener) datagramReady() (string, bool, bool) {
363
return udpAddr, ready, pending
364
}
365
365
-func (l *listener) publicURLForLease(lease listenerLease) string {
366
+func (l *listener) publicURLForLease(lease listenerSnapshot) string {
367
baseURL := lease.publicURLBase
368
if baseURL == nil {
369
baseURL = l.relayURL
@@ -671,8 +672,9 @@ func (l *listener) renewLease(ctx context.Context) error {
672
}
673
674
requestCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
674
- resp, err := l.renewRegisteredLease(requestCtx, l.leaseTTL, lease.accessToken, lease.hopRoutes)
675
- cancel()
675
+ defer cancel()
676
+
677
+ resp, err := l.renewRegisteredLease(requestCtx, l.leaseTTL, lease.accessToken)
678
if err != nil {
679
if errors.Is(err, &types.APIRequestError{Code: types.APIErrorCodeLeaseNotFound}) {
680
return errLeaseRefreshRequired
@@ -684,6 +686,13 @@ func (l *listener) renewLease(ctx context.Context) error {
686
if resp.AccessToken == "" {
687
return errors.New("relay did not return renewed access token")
688
}
689
+ multihopAccessToken := resp.AccessToken
690
+ if len(lease.hopRoutes) > 0 {
691
+ multihopAccessToken, err = l.registerHopRoutes(requestCtx, resp.ExpiresAt, lease.hopRoutes)
692
+ if err != nil {
693
+ return err
694
+ }
695
+ }
696
l.leaseMu.Lock()
697
if l.lease == nil || l.lease.accessToken != lease.accessToken {
698
l.leaseMu.Unlock()
@@ -692,6 +701,7 @@ func (l *listener) renewLease(ctx context.Context) error {
701
next := *l.lease
702
next.accessToken = resp.AccessToken
703
next.expiresAt = resp.ExpiresAt
704
+ next.multihopAccessToken = multihopAccessToken
705
l.lease = &next
706
l.leaseMu.Unlock()
707
return nil
@@ -721,6 +731,14 @@ func (l *listener) registerAndConfigure(ctx context.Context) error {
731
_ = l.unregisterLease(context.Background(), resp.AccessToken, hopRoutes)
732
return errors.New("relay did not return sni port for udp transport")
733
}
734
+ multihopAccessToken := resp.AccessToken
735
+ if len(hopRoutes) > 0 {
736
+ multihopAccessToken, err = l.registerHopRoutes(ctx, resp.ExpiresAt, hopRoutes)
737
+ if err != nil {
738
+ _ = l.unregisterLease(context.Background(), resp.AccessToken, hopRoutes)
739
+ return err
740
+ }
741
+ }
742
keylessURL := l.relayURL.String()
743
if len(l.multiHop) > 0 {
744
keylessURL = l.multiHop[0]
@@ -737,7 +755,15 @@ func (l *listener) registerAndConfigure(ctx context.Context) error {
755
return err
756
}
757
740
- tlsConf, tenantTLSCloser, err := keyless.BuildClientTLSConfig(keylessURL, publicHostname, echKeys)
758
+ tlsConf, tenantTLSCloser, err := keyless.BuildClientTLSConfig(keylessURL, publicHostname, echKeys, func() http.Header {
759
+ headers := http.Header{}
760
+ accessToken := multihopAccessToken
761
+ if snapshot, ok := l.leaseSnapshot(); ok && snapshot.multihopAccessToken != "" {
762
+ accessToken = snapshot.multihopAccessToken
763
+ }
764
+ headers.Set(types.HeaderAccessToken, accessToken)
765
+ return headers
766
+ })
767
if err != nil {
768
_ = l.unregisterLease(context.Background(), resp.AccessToken, hopRoutes)
769
if tenantTLSCloser != nil {
@@ -753,18 +779,19 @@ func (l *listener) registerAndConfigure(ctx context.Context) error {
779
}
780
return ctx.Err()
781
}
756
- next := &listenerLease{
757
- hostname: publicHostname,
758
- echConfigList: echConfigList,
759
- udpAddr: resp.UDPAddr,
760
- tcpAddr: resp.TCPAddr,
761
- accessToken: resp.AccessToken,
762
- expiresAt: resp.ExpiresAt,
763
- sniPort: resp.SNIPort,
764
- publicURLBase: publicURLBase,
765
- tlsConfig: tlsConf,
766
- tlsCloser: tenantTLSCloser,
767
- hopRoutes: hopRoutes,
782
+ next := &listenerSnapshot{
783
+ hostname: publicHostname,
784
+ echConfigList: echConfigList,
785
+ udpAddr: resp.UDPAddr,
786
+ tcpAddr: resp.TCPAddr,
787
+ accessToken: resp.AccessToken,
788
+ expiresAt: resp.ExpiresAt,
789
+ sniPort: resp.SNIPort,
790
+ publicURLBase: publicURLBase,
791
+ tlsConfig: tlsConf,
792
+ tlsCloser: tenantTLSCloser,
793
+ multihopAccessToken: multihopAccessToken,
794
+ hopRoutes: hopRoutes,
795
}
796
l.leaseMu.Lock()
797
oldLease := l.lease
types/api.go
+4
@@ -140,6 +140,10 @@ type HopRoute struct {
140
Signature string `json:"signature,omitempty"`
141
}
142
143
+type HopRouteResponse struct {
144
+ AccessToken string `json:"access_token,omitempty"`
145
+}
146
+
147
func HopRouteBytes(method string, route HopRoute) ([]byte, error) {
148
forwardRelay, err := CanonicalBytes(route.ForwardRelay)
149
if err != nil {