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 {