feat(control-plane): enforce mTLS client identity in sdk tunnel paths

cognitive committed Mar 4, 2026 at 06:27 UTC 01543bd9c684a8fb5d2b588900aa60fd149f1d2a
7 files changed +145 -7
cmd/relay-server/main_test.go
+4 -1
@@ -2,6 +2,7 @@ package main
2
3 import (
4 "context"
5 + "crypto/tls"
6 "encoding/json"
7 "net"
8 "net/http"
@@ -102,6 +103,7 @@ func TestSDKRegisterRejectsBannedIP(t *testing.T) {
103
104 reqBody := strings.NewReader(`{"lease_id":"lease-ban","name":"test-lease","tls":true,"reverse_token":"token-1"}`)
105 req := httptest.NewRequest(http.MethodPost, types.PathSDKRegister, reqBody)
106 + req.TLS = &tls.ConnectionState{}
107 req.RemoteAddr = "203.0.113.17:45678"
108 rr := httptest.NewRecorder()
109
@@ -159,8 +161,9 @@ func TestSDKUnregisterCleansRouteAndReversePoolImmediately(t *testing.T) {
161 t.Fatal("failed to seed reverse pool")
162 }
163
162 - reqBody := strings.NewReader(`{"lease_id":"lease-cleanup"}`)
164 + reqBody := strings.NewReader(`{"lease_id":"lease-cleanup","reverse_token":"token-cleanup"}`)
165 req := httptest.NewRequest(http.MethodPost, types.PathSDKUnregister, reqBody)
166 + attachPeerLeaseCertificate(req, lease.ID)
167 rr := httptest.NewRecorder()
168 registry.handleUnregister(rr, req, serv)
169
cmd/relay-server/serve.go
+1
@@ -129,6 +129,7 @@ func serveAPI(addr string, serv *portal.RelayServer, admin *Admin, frontend *Fro
129 acmeManager := serv.GetACMEManager()
130 rootHost := types.PortalRootHost(flagPortalURL)
131 srv.TLSConfig = &tls.Config{
132 + ClientAuth: tls.RequestClientCert,
133 GetCertificate: func(hello *tls.ClientHelloInfo) (*tls.Certificate, error) {
134 serverName := strings.TrimSpace(strings.ToLower(hello.ServerName))
135 if serverName != "" && !strings.EqualFold(serverName, rootHost) {
sdk/client.go
+5 -3
@@ -91,6 +91,10 @@ func (c *Client) Listen(name string, options ...types.MetadataOption) (net.Liste
91 if err != nil {
92 return nil, err
93 }
94 + controlPlaneIdentity, err := issueControlPlaneIdentity(lease.ID)
95 + if err != nil {
96 + return nil, err
97 + }
98
99 listeners := make([]net.Listener, 0, len(relayAddrs))
100 closeActiveListeners := func() {
@@ -115,7 +119,7 @@ func (c *Client) Listen(name string, options ...types.MetadataOption) (net.Liste
119 }
120
121 leaseCopy := *lease
118 - listener, listenerErr := NewListener(relayAddr, &leaseCopy, tlsConfig, 0, c.config.ReverseDialTimeout, listenerCloseFns...)
122 + listener, listenerErr := NewListener(relayAddr, &leaseCopy, tlsConfig, controlPlaneIdentity, 0, c.config.ReverseDialTimeout, listenerCloseFns...)
123 if listenerErr != nil {
124 runCloseFns(listenerCloseFns)
125 closeActiveListeners()
@@ -204,5 +208,3 @@ func (c *Client) buildTLSConfig(relayAddr, leaseName string) (*tls.Config, []fun
208 func (c *Client) Close() error {
209 return nil
210 }
207 -
208 -// Close closes the client.
sdk/controlplane_identity.go new
+77
@@ -0,0 +1,77 @@
1 +package sdk
2 +
3 +import (
4 + "crypto/ecdsa"
5 + "crypto/elliptic"
6 + "crypto/rand"
7 + "crypto/tls"
8 + "crypto/x509"
9 + "crypto/x509/pkix"
10 + "encoding/pem"
11 + "errors"
12 + "fmt"
13 + "math/big"
14 + "net/url"
15 + "strings"
16 + "time"
17 +)
18 +
19 +const (
20 + controlPlaneCertCNPrefix = "lease:"
21 + controlPlaneLeaseURIPfx = "spiffe://portal/lease/"
22 +)
23 +
24 +func issueControlPlaneIdentity(leaseID string) (tls.Certificate, error) {
25 + leaseID = strings.TrimSpace(leaseID)
26 + if leaseID == "" {
27 + return tls.Certificate{}, errors.New("lease id is required")
28 + }
29 +
30 + priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
31 + if err != nil {
32 + return tls.Certificate{}, fmt.Errorf("generate identity key: %w", err)
33 + }
34 +
35 + serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
36 + if err != nil {
37 + return tls.Certificate{}, fmt.Errorf("generate serial: %w", err)
38 + }
39 + notBefore := time.Now().Add(-1 * time.Minute)
40 + notAfter := notBefore.Add(24 * time.Hour)
41 +
42 + leaseURI, err := url.Parse(controlPlaneLeaseURIPfx + leaseID)
43 + if err != nil {
44 + return tls.Certificate{}, fmt.Errorf("build lease URI: %w", err)
45 + }
46 +
47 + template := &x509.Certificate{
48 + SerialNumber: serial,
49 + Subject: pkix.Name{
50 + CommonName: controlPlaneCertCNPrefix + leaseID,
51 + },
52 + NotBefore: notBefore,
53 + NotAfter: notAfter,
54 + KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
55 + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth},
56 + BasicConstraintsValid: true,
57 + URIs: []*url.URL{leaseURI},
58 + }
59 +
60 + der, err := x509.CreateCertificate(rand.Reader, template, template, &priv.PublicKey, priv)
61 + if err != nil {
62 + return tls.Certificate{}, fmt.Errorf("create lease identity certificate: %w", err)
63 + }
64 +
65 + certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
66 + keyDER, err := x509.MarshalECPrivateKey(priv)
67 + if err != nil {
68 + return tls.Certificate{}, fmt.Errorf("marshal identity key: %w", err)
69 + }
70 + keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER})
71 +
72 + cert, err := tls.X509KeyPair(certPEM, keyPEM)
73 + if err != nil {
74 + return tls.Certificate{}, fmt.Errorf("load identity key pair: %w", err)
75 + }
76 + return cert, nil
77 +}
sdk/controlplane_identity_test.go new
+42
@@ -0,0 +1,42 @@
1 +package sdk
2 +
3 +import (
4 + "crypto/x509"
5 + "strings"
6 + "testing"
7 + "time"
8 +)
9 +
10 +func TestIssueControlPlaneIdentity(t *testing.T) {
11 + t.Parallel()
12 +
13 + identity, err := issueControlPlaneIdentity("lease-identity")
14 + if err != nil {
15 + t.Fatalf("issueControlPlaneIdentity returned error: %v", err)
16 + }
17 + if len(identity.Certificate) == 0 {
18 + t.Fatal("identity certificate chain is empty")
19 + }
20 +
21 + leaf, err := x509.ParseCertificate(identity.Certificate[0])
22 + if err != nil {
23 + t.Fatalf("parse issued certificate: %v", err)
24 + }
25 + if got := strings.TrimSpace(leaf.Subject.CommonName); got != controlPlaneCertCNPrefix+"lease-identity" {
26 + t.Fatalf("certificate common name = %q, want %q", got, controlPlaneCertCNPrefix+"lease-identity")
27 + }
28 + if len(leaf.URIs) == 0 || leaf.URIs[0].String() != controlPlaneLeaseURIPfx+"lease-identity" {
29 + t.Fatalf("certificate lease URI = %v, want %q", leaf.URIs, controlPlaneLeaseURIPfx+"lease-identity")
30 + }
31 + if time.Now().Before(leaf.NotBefore) || time.Now().After(leaf.NotAfter) {
32 + t.Fatalf("issued certificate validity window does not include current time")
33 + }
34 +}
35 +
36 +func TestIssueControlPlaneIdentityRejectsEmptyLeaseID(t *testing.T) {
37 + t.Parallel()
38 +
39 + if _, err := issueControlPlaneIdentity(" "); err == nil {
40 + t.Fatal("expected error for empty lease ID")
41 + }
42 +}
sdk/listener.go
+14 -2
@@ -39,6 +39,10 @@ var fatalReverseConnectRejectionCodes = map[string]struct{}{
39 "tls_required": {},
40 "unauthorized": {},
41 "unsupported_transport": {},
42 + "client_cert_required": {},
43 + "client_cert_invalid": {},
44 + "cert_lease_missing": {},
45 + "cert_lease_mismatch": {},
46 }
47
48 type reverseConnectRejectionError struct {
@@ -81,6 +85,7 @@ func (e *reverseConnectRejectionError) IsFatal() bool {
85 // The relay connects to this listener after SNI routing resolves the lease.
86 type Listener struct {
87 tlsConfig *tls.Config
88 + controlPlaneCert tls.Certificate
89 lease *portal.Lease
90 httpClient *http.Client
91 stopCh chan struct{}
@@ -99,7 +104,7 @@ var _ net.Listener = (*Listener)(nil)
104
105 // NewListener creates a relay-backed listener.
106 // If tlsConfig is provided, reverse workers complete TLS handshakes before enqueueing connections.
102 -func NewListener(relayAddr string, lease *portal.Lease, tlsConfig *tls.Config, reverseWorkers int, reverseDialTimeout time.Duration, closeFns ...func()) (*Listener, error) {
107 +func NewListener(relayAddr string, lease *portal.Lease, tlsConfig *tls.Config, controlPlaneCert tls.Certificate, reverseWorkers int, reverseDialTimeout time.Duration, closeFns ...func()) (*Listener, error) {
108 if lease == nil {
109 return nil, errors.New("lease is required")
110 }
@@ -115,6 +120,9 @@ func NewListener(relayAddr string, lease *portal.Lease, tlsConfig *tls.Config, r
120 if tlsConfig == nil {
121 return nil, errors.New("tls config is required")
122 }
123 + if len(controlPlaneCert.Certificate) == 0 {
124 + return nil, errors.New("control plane client certificate is required")
125 + }
126
127 apiURL, err := types.NormalizeRelayAPIURL(relayAddr)
128 if err != nil {
@@ -126,6 +134,7 @@ func NewListener(relayAddr string, lease *portal.Lease, tlsConfig *tls.Config, r
134 MinVersion: tls.VersionTLS12,
135 ServerName: host,
136 InsecureSkipVerify: types.IsLocalhost(host),
137 + Certificates: []tls.Certificate{controlPlaneCert},
138 }
139
140 if reverseWorkers <= 0 {
@@ -144,6 +153,7 @@ func NewListener(relayAddr string, lease *portal.Lease, tlsConfig *tls.Config, r
153 Transport: clientTransport,
154 },
155 tlsConfig: tlsConfig,
156 + controlPlaneCert: controlPlaneCert,
157 closeFns: closeFns,
158 stopCh: make(chan struct{}),
159 acceptCh: make(chan net.Conn, 128),
@@ -399,6 +409,7 @@ func (l *Listener) openReverseConnection() (net.Conn, error) {
409 MinVersion: tls.VersionTLS12,
410 ServerName: serverName,
411 InsecureSkipVerify: types.IsLocalhost(serverName),
412 + Certificates: []tls.Certificate{l.controlPlaneCert},
413 })
414 err = tlsConn.HandshakeContext(ctx)
415 if err != nil {
@@ -656,7 +667,8 @@ func (l *Listener) registerWithRelay() error {
667
668 func (l *Listener) unregisterFromRelay() error {
669 reqBody := types.UnregisterRequest{
659 - LeaseID: l.lease.ID,
670 + LeaseID: l.lease.ID,
671 + ReverseToken: l.lease.ReverseToken,
672 }
673 return l.postJSON(types.PathSDKUnregister, reqBody)
674 }
types/api.go
+2 -1
@@ -80,7 +80,8 @@ type RegisterResponse struct {
80
81 // UnregisterRequest is the lease unregistration request.
82 type UnregisterRequest struct {
83 - LeaseID string `json:"lease_id"`
83 + LeaseID string `json:"lease_id"`
84 + ReverseToken string `json:"reverse_token"`
85 }
86
87 // RenewRequest is the lease renewal request.