sdk: add keyless tls

Kim committed Mar 6, 2026 at 13:33 UTC 097696ba7b422473c820d018396ce6d4145808c3
13 files changed +416 -251
AGENTS.md
+2 -2
@@ -40,8 +40,8 @@ Source of truth for architecture decisions: `docs/adr/README.md` and linked ADRs
40
41 ## TLS and Identity Invariants
42
43 -1. **Relay holds the TLS private key for admin/API (root domain) only.** SDK calls `/v1/sign` on the relay via `RemoteSigner` for admin/API TLS termination. For SNI-passthrough routes, the relay peeks the ClientHello for SNI then bridges the raw encrypted connection — the backend/tunnel endpoint terminates TLS and holds those keys, not the relay.
44 - - Why: admin/API key material stays on the relay; tenant TLS passthrough avoids key distribution to the relay entirely.
43 +1. **Relay terminates admin/API TLS directly and also exposes `/v1/sign` as the keyless signer for tenant passthrough TLS.** The relay still does not terminate tenant traffic; it peeks ClientHello for SNI and bridges raw encrypted bytes, while the backend/tunnel endpoint performs the handshake using a `RemoteSigner`.
44 + - Why: preserves SNI passthrough data flow while centralizing certificate signing behind the relay keyless endpoint.
45
46 2. **/sdk/* control-plane auth is token.** Admission order is IP ban -> Lease -> Token.
47 - Admin/API TLS listener does not request client certificates.
cmd/demo-app/main.go
+1 -2
@@ -40,11 +40,10 @@ var (
40 )
41
42 func main() {
43 - zerolog.TimeFieldFormat = time.RFC3339
43 log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stdout, TimeFormat: time.RFC3339})
44 logger := log.With().Str("component", "demo-app").Logger()
45
47 - flag.StringVar(&flagServerURL, "server-url", "https://localhost:4017", "relay API URL (https only)")
46 + flag.StringVar(&flagServerURL, "server-url", "https://gosunuts.xyz", "relay API URL (https only)")
47 flag.IntVar(&flagPort, "port", 8092, "local demo HTTP port")
48 flag.StringVar(&flagName, "name", "demo-app", "backend display name")
49 flag.StringVar(&flagDesc, "description", "Portal demo connectivity app", "lease description")
cmd/portal-tunnel/README.md
+1 -1
@@ -30,5 +30,5 @@ Portal-tunnel connects a local service to a Portal relay with the legacy CLI sha
30 ## Notes
31
32 - The current runtime accepts multiple relay URLs but uses the first one.
33 -- If tenant TLS material is not provided internally, the SDK generates a self-signed certificate for the registered hostnames.
33 +- Tenant TLS is provisioned automatically through the relay keyless signer. The SDK fetches the relay certificate chain and uses `/v1/sign` for remote signing.
34 - When the local service is unreachable, the tunnel returns an HTTP 503 page.
cmd/relay-server/main.go
-1
@@ -31,7 +31,6 @@ type relayServerConfig struct {
31 }
32
33 func main() {
34 - zerolog.TimeFieldFormat = time.RFC3339
34 log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stdout, TimeFormat: time.RFC3339})
35 logger := log.With().Str("component", "relay-server").Logger()
36
cmd/relay-server/serve.go
+15 -2
@@ -14,6 +14,7 @@ import (
14
15 "gosuda.org/portal/portal"
16 "gosuda.org/portal/portal/acme"
17 + "gosuda.org/portal/portal/keyless"
18 )
19
20 func runServer(cfg relayServerConfig) error {
@@ -42,6 +43,10 @@ func runServer(cfg relayServerConfig) error {
43 if err != nil {
44 return fmt.Errorf("ensure relay certificate: %w", err)
45 }
46 + signer, err := keyless.NewSigner(keyFile)
47 + if err != nil {
48 + return fmt.Errorf("create keyless signer: %w", err)
49 + }
50
51 frontend := NewFrontend(cfg.PortalURL)
52 admin := NewAdmin(cfg.AdminSecretKey, cfg.TrustProxyHeaders, frontend)
@@ -52,11 +57,17 @@ func runServer(cfg relayServerConfig) error {
57 SNIListenAddr: sniListenAddr,
58 RootHost: rootHost,
59 RootFallbackAddr: loopbackAddr(apiListenAddr),
60 + KeylessSignerHandler: func() http.Handler {
61 + if signer == nil {
62 + return nil
63 + }
64 + return signer.Handler()
65 + }(),
66 APITLS: portal.TLSMaterialConfig{
67 CertPEM: mustRead(certFile),
68 KeyPEM: mustRead(keyFile),
69 },
59 - APIHandlerWrapper: serveAPI(frontend, admin, cfg),
70 + APIHandlerWrapper: serveAPI(frontend, admin, signer, cfg),
71 })
72 if err != nil {
73 return fmt.Errorf("create relay server: %w", err)
@@ -81,12 +92,14 @@ func runServer(cfg relayServerConfig) error {
92 return server.Wait()
93 }
94
84 -func serveAPI(frontend *Frontend, admin *Admin, cfg relayServerConfig) func(http.Handler) http.Handler {
95 +func serveAPI(frontend *Frontend, admin *Admin, signer *keyless.Signer, cfg relayServerConfig) func(http.Handler) http.Handler {
96 return func(base http.Handler) http.Handler {
97 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
98 switch {
99 case isRelayControlPlanePath(r.URL.Path):
100 base.ServeHTTP(w, r)
101 + case r.URL.Path == "/v1/sign":
102 + base.ServeHTTP(w, r)
103 case r.URL.Path == "/healthz":
104 base.ServeHTTP(w, r)
105 case isFrontendRootAssetPath(r.URL.Path):
internal/testutil/cert.go deleted
-57
@@ -1,57 +0,0 @@
1 -package testutil
2 -
3 -import (
4 - "crypto/ecdsa"
5 - "crypto/elliptic"
6 - "crypto/rand"
7 - "crypto/x509"
8 - "crypto/x509/pkix"
9 - "encoding/pem"
10 - "math/big"
11 - "net"
12 - "time"
13 -)
14 -
15 -func SelfSignedCertPEM(host string) (certPEM, keyPEM []byte, err error) {
16 - priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
17 - if err != nil {
18 - return nil, nil, err
19 - }
20 -
21 - serialLimit := new(big.Int).Lsh(big.NewInt(1), 128)
22 - serial, err := rand.Int(rand.Reader, serialLimit)
23 - if err != nil {
24 - return nil, nil, err
25 - }
26 -
27 - template := &x509.Certificate{
28 - SerialNumber: serial,
29 - Subject: pkix.Name{
30 - CommonName: host,
31 - },
32 - NotBefore: time.Now().Add(-time.Hour),
33 - NotAfter: time.Now().Add(24 * time.Hour),
34 - KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
35 - ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
36 - BasicConstraintsValid: true,
37 - }
38 -
39 - if ip := net.ParseIP(host); ip != nil {
40 - template.IPAddresses = []net.IP{ip}
41 - } else {
42 - template.DNSNames = []string{host}
43 - }
44 -
45 - der, err := x509.CreateCertificate(rand.Reader, template, template, &priv.PublicKey, priv)
46 - if err != nil {
47 - return nil, nil, err
48 - }
49 -
50 - certPEM = pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
51 - keyBytes, err := x509.MarshalECPrivateKey(priv)
52 - if err != nil {
53 - return nil, nil, err
54 - }
55 - keyPEM = pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyBytes})
56 - return certPEM, keyPEM, nil
57 -}
portal/keyless/client.go new
+188
@@ -0,0 +1,188 @@
1 +package keyless
2 +
3 +import (
4 + "context"
5 + "crypto/tls"
6 + "crypto/x509"
7 + "encoding/pem"
8 + "errors"
9 + "fmt"
10 + "net"
11 + "net/url"
12 + "strings"
13 + "time"
14 +
15 + keylesslib "github.com/gosuda/keyless_tls/keyless"
16 +)
17 +
18 +func BuildClientTLSConfig(relayURL string, domains []string) (*tls.Config, ioCloser, error) {
19 + parsed, err := url.Parse(strings.TrimSpace(relayURL))
20 + if err != nil {
21 + return nil, nil, fmt.Errorf("parse relay url: %w", err)
22 + }
23 + serverName := parsed.Hostname()
24 + if serverName == "" {
25 + return nil, nil, errors.New("relay hostname is required")
26 + }
27 +
28 + certPEM, rootCAPEM, err := ResolveMaterials(context.Background(), relayURL, serverName)
29 + if err != nil {
30 + return nil, nil, fmt.Errorf("prepare keyless materials: %w", err)
31 + }
32 + for _, domain := range domains {
33 + domain = strings.TrimSpace(domain)
34 + if domain == "" {
35 + continue
36 + }
37 + verifyErr := VerifyCertificateHostname(certPEM, domain)
38 + if verifyErr != nil {
39 + return nil, nil, fmt.Errorf("keyless certificate does not cover %s: %w", domain, verifyErr)
40 + }
41 + }
42 +
43 + remoteSigner, err := keylesslib.NewRemoteSigner(keylesslib.RemoteSignerConfig{
44 + Endpoint: relayURL,
45 + ServerName: serverName,
46 + KeyID: RelayKeyID,
47 + RootCAPEM: rootCAPEM,
48 + }, certPEM)
49 + if err != nil {
50 + return nil, nil, fmt.Errorf("create keyless remote signer: %w", err)
51 + }
52 +
53 + tlsConfig, err := keylesslib.NewServerTLSConfig(keylesslib.ServerTLSConfig{
54 + CertPEM: certPEM,
55 + Signer: remoteSigner,
56 + NextProtos: []string{"http/1.1"},
57 + MinVersion: tls.VersionTLS12,
58 + })
59 + if err != nil {
60 + _ = remoteSigner.Close()
61 + return nil, nil, fmt.Errorf("create keyless tls config: %w", err)
62 + }
63 + return tlsConfig, remoteSigner, nil
64 +}
65 +
66 +type ioCloser interface {
67 + Close() error
68 +}
69 +
70 +func ResolveMaterials(ctx context.Context, endpoint, serverName string) ([]byte, []byte, error) {
71 + chainPEM, err := FetchEndpointCertificateChain(ctx, endpoint, serverName)
72 + if err != nil {
73 + return nil, nil, fmt.Errorf("fetch signer certificate chain: %w", err)
74 + }
75 + if len(chainPEM) == 0 {
76 + return nil, nil, errors.New("keyless certificate chain is required")
77 + }
78 + return append([]byte(nil), chainPEM...), append([]byte(nil), chainPEM...), nil
79 +}
80 +
81 +func VerifyCertificateHostname(certPEM []byte, hostname string) error {
82 + _, leaf, err := ParseCertificateChainPEM(certPEM)
83 + if err != nil {
84 + return err
85 + }
86 + return leaf.VerifyHostname(hostname)
87 +}
88 +
89 +func ParseCertificateChainPEM(certPEM []byte) ([][]byte, *x509.Certificate, error) {
90 + if len(certPEM) == 0 {
91 + return nil, nil, errors.New("certificate PEM is empty")
92 + }
93 +
94 + var chain [][]byte
95 + rest := certPEM
96 + for {
97 + block, next := pem.Decode(rest)
98 + if block == nil {
99 + break
100 + }
101 + if block.Type == "CERTIFICATE" {
102 + chain = append(chain, block.Bytes)
103 + }
104 + rest = next
105 + }
106 + if len(chain) == 0 {
107 + return nil, nil, errors.New("no certificate blocks found")
108 + }
109 +
110 + leaf, err := x509.ParseCertificate(chain[0])
111 + if err != nil {
112 + return nil, nil, fmt.Errorf("parse leaf certificate: %w", err)
113 + }
114 + return chain, leaf, nil
115 +}
116 +
117 +func FetchEndpointCertificateChain(ctx context.Context, endpoint, serverName string) ([]byte, error) {
118 + raw := strings.TrimSpace(endpoint)
119 + if raw == "" {
120 + return nil, errors.New("endpoint is required")
121 + }
122 + if !strings.Contains(raw, "://") {
123 + raw = "https://" + raw
124 + }
125 +
126 + u, err := url.Parse(raw)
127 + if err != nil {
128 + return nil, fmt.Errorf("parse endpoint url: %w", err)
129 + }
130 + if !strings.EqualFold(u.Scheme, "https") {
131 + return nil, errors.New("keyless endpoint must use https")
132 + }
133 +
134 + host := u.Hostname()
135 + if host == "" {
136 + return nil, errors.New("endpoint hostname is empty")
137 + }
138 + port := u.Port()
139 + if port == "" {
140 + port = "443"
141 + }
142 + if serverName == "" {
143 + serverName = host
144 + }
145 +
146 + dialer := &net.Dialer{Timeout: 5 * time.Second}
147 + rawConn, err := dialer.DialContext(ctx, "tcp", net.JoinHostPort(host, port))
148 + if err != nil {
149 + return nil, fmt.Errorf("dial signer endpoint: %w", err)
150 + }
151 +
152 + tlsConn := tls.Client(rawConn, &tls.Config{
153 + MinVersion: tls.VersionTLS12,
154 + ServerName: serverName,
155 + InsecureSkipVerify: isLocalhost(host),
156 + NextProtos: []string{"http/1.1"},
157 + })
158 + defer tlsConn.Close()
159 + if err := tlsConn.HandshakeContext(ctx); err != nil {
160 + return nil, fmt.Errorf("tls handshake with signer endpoint: %w", err)
161 + }
162 +
163 + peerCerts := tlsConn.ConnectionState().PeerCertificates
164 + if len(peerCerts) == 0 {
165 + return nil, errors.New("no peer certificates from signer endpoint")
166 + }
167 +
168 + var chainPEM []byte
169 + for _, cert := range peerCerts {
170 + chainPEM = append(chainPEM, pem.EncodeToMemory(&pem.Block{
171 + Type: "CERTIFICATE",
172 + Bytes: cert.Raw,
173 + })...)
174 + }
175 + return chainPEM, nil
176 +}
177 +
178 +func isLocalhost(host string) bool {
179 + host = strings.TrimSpace(strings.ToLower(host))
180 + switch host {
181 + case "", "localhost":
182 + return true
183 + }
184 + if ip := net.ParseIP(host); ip != nil {
185 + return ip.IsLoopback()
186 + }
187 + return strings.HasSuffix(host, ".localhost")
188 +}
portal/keyless/signer.go new
+110
@@ -0,0 +1,110 @@
1 +package keyless
2 +
3 +import (
4 + "context"
5 + "encoding/json"
6 + "errors"
7 + "fmt"
8 + "net/http"
9 + "os"
10 + "strings"
11 + "time"
12 +
13 + ksigner "github.com/gosuda/keyless_tls/relay/signer"
14 + "github.com/gosuda/keyless_tls/relay/signrpc"
15 +)
16 +
17 +const (
18 + RelayKeyID = "relay-cert"
19 + defaultAllowedSkew = 30 * time.Second
20 +)
21 +
22 +type Signer struct {
23 + service *ksigner.Service
24 + keyID string
25 +}
26 +
27 +func NewSigner(keyFile string) (*Signer, error) {
28 + keyPEM, err := os.ReadFile(strings.TrimSpace(keyFile))
29 + if err != nil {
30 + return nil, fmt.Errorf("read keyless signing key: %w", err)
31 + }
32 + signingKey, err := ksigner.ParsePrivateKeyPEM(keyPEM)
33 + if err != nil {
34 + return nil, fmt.Errorf("parse keyless signing key: %w", err)
35 + }
36 +
37 + store := ksigner.NewStaticKeyStore()
38 + if err := store.Put(RelayKeyID, signingKey); err != nil {
39 + return nil, fmt.Errorf("register keyless signing key: %w", err)
40 + }
41 +
42 + return &Signer{
43 + service: &ksigner.Service{
44 + Store: store,
45 + AllowedSkew: defaultAllowedSkew,
46 + },
47 + keyID: RelayKeyID,
48 + }, nil
49 +}
50 +
51 +func (s *Signer) KeyID() string {
52 + if s == nil {
53 + return ""
54 + }
55 + return s.keyID
56 +}
57 +
58 +func (s *Signer) Sign(ctx context.Context, req *signrpc.SignRequest) (*signrpc.SignResponse, error) {
59 + if s == nil || s.service == nil {
60 + return nil, errors.New("keyless signer is disabled")
61 + }
62 + return s.service.Sign(ctx, req)
63 +}
64 +
65 +func (s *Signer) Handler() http.Handler {
66 + mux := http.NewServeMux()
67 + mux.HandleFunc(signrpc.SignPath, func(w http.ResponseWriter, r *http.Request) {
68 + if r.Method != http.MethodPost {
69 + w.Header().Set("Allow", http.MethodPost)
70 + writeJSONError(w, http.StatusMethodNotAllowed, "method not allowed")
71 + return
72 + }
73 + if ct := r.Header.Get("Content-Type"); ct != "" && !strings.HasPrefix(ct, "application/json") {
74 + writeJSONError(w, http.StatusUnsupportedMediaType, "content type must be application/json")
75 + return
76 + }
77 +
78 + r.Body = http.MaxBytesReader(w, r.Body, 4<<10)
79 + defer r.Body.Close()
80 +
81 + var req signrpc.SignRequest
82 + if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
83 + writeJSONError(w, http.StatusBadRequest, "invalid json body")
84 + return
85 + }
86 +
87 + resp, err := s.Sign(r.Context(), &req)
88 + if err != nil {
89 + status := http.StatusInternalServerError
90 + switch {
91 + case errors.Is(err, ksigner.ErrInvalidArgument):
92 + status = http.StatusBadRequest
93 + case errors.Is(err, ksigner.ErrPermissionDenied):
94 + status = http.StatusForbidden
95 + }
96 + writeJSONError(w, status, err.Error())
97 + return
98 + }
99 +
100 + w.Header().Set("Content-Type", "application/json")
101 + _ = json.NewEncoder(w).Encode(resp)
102 + })
103 + return mux
104 +}
105 +
106 +func writeJSONError(w http.ResponseWriter, status int, message string) {
107 + w.Header().Set("Content-Type", "application/json")
108 + w.WriteHeader(status)
109 + _ = json.NewEncoder(w).Encode(signrpc.ErrorResponse{Error: message})
110 +}
portal/server.go
+5 -1
@@ -21,6 +21,7 @@ import (
21
22 type ServerConfig struct {
23 APIHandlerWrapper func(http.Handler) http.Handler
24 + KeylessSignerHandler http.Handler
25 PortalURL string
26 APIListenAddr string
27 SNIListenAddr string
@@ -240,13 +241,16 @@ func (s *Server) ListLeases() []LeaseSnapshot {
241
242 func (s *Server) apiHandler() http.Handler {
243 mux := http.NewServeMux()
243 - mux.HandleFunc("/", s.handleRoot)
244 + if s.cfg.KeylessSignerHandler != nil {
245 + mux.Handle("/v1/sign", s.cfg.KeylessSignerHandler)
246 + }
247 mux.HandleFunc("/healthz", s.handleHealthz)
248 mux.HandleFunc("/sdk/domain", s.handleDomain)
249 mux.HandleFunc("/sdk/register", s.handleRegister)
250 mux.HandleFunc("/sdk/renew", s.handleRenew)
251 mux.HandleFunc("/sdk/unregister", s.handleUnregister)
252 mux.HandleFunc("/sdk/connect", s.handleConnect)
253 + mux.HandleFunc("/", s.handleRoot)
254 return mux
255 }
256
sdk/client.go
+5 -17
@@ -19,6 +19,7 @@ import (
19 "time"
20
21 "gosuda.org/portal/portal"
22 + "gosuda.org/portal/portal/keyless"
23 )
24
25 type ClientConfig struct {
@@ -130,23 +131,10 @@ func (c *Client) Listen(ctx context.Context, req ListenRequest) (*Listener, erro
131 return nil, err
132 }
133
133 - var (
134 - tlsConf *tls.Config
135 - tlsCloser io.Closer
136 - err error
137 - )
138 - if len(req.TLS.CertPEM) > 0 || len(req.TLS.KeyPEM) > 0 || req.TLS.Keyless != nil {
139 - tlsConf, tlsCloser, err = buildTenantTLSConfig(req.TLS)
140 - if err != nil {
141 - _ = c.unregisterLease(context.Background(), registerResp.LeaseID, reverseToken)
142 - return nil, err
143 - }
144 - } else {
145 - tlsConf, err = buildAutoTenantTLSConfig(registerResp.Hostnames)
146 - if err != nil {
147 - _ = c.unregisterLease(context.Background(), registerResp.LeaseID, reverseToken)
148 - return nil, err
149 - }
134 + tlsConf, tlsCloser, err := keyless.BuildClientTLSConfig(c.baseURL.String(), registerResp.Hostnames)
135 + if err != nil {
136 + _ = c.unregisterLease(context.Background(), registerResp.LeaseID, reverseToken)
137 + return nil, err
138 }
139
140 listenerCtx, cancel := context.WithCancel(ctx)
sdk/listener.go
-1
@@ -18,7 +18,6 @@ type LeaseMetadata = portal.LeaseMetadata
18 type ListenRequest struct {
19 Name string
20 ReverseToken string
21 - TLS portal.TLSMaterialConfig
21 Hostnames []string
22 Metadata LeaseMetadata
23 ReadyTarget int
sdk/listener_test.go
+89 -28
@@ -3,39 +3,90 @@ package sdk
3 import (
4 "bufio"
5 "context"
6 + "crypto/ecdsa"
7 + "crypto/elliptic"
8 + "crypto/rand"
9 "crypto/tls"
10 + "crypto/x509"
11 + "crypto/x509/pkix"
12 + "encoding/pem"
13 "errors"
14 "fmt"
15 "io"
16 + "math/big"
17 "net"
18 "net/http"
19 + "os"
20 "strings"
21 "testing"
22 "time"
23
16 - "gosuda.org/portal/internal/testutil"
24 "gosuda.org/portal/portal"
25 + portalkeyless "gosuda.org/portal/portal/keyless"
26 )
27
28 +func selfSignedCertPEM(hosts ...string) (certPEM, keyPEM []byte, err error) {
29 + priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
30 + if err != nil {
31 + return nil, nil, err
32 + }
33 +
34 + serialLimit := new(big.Int).Lsh(big.NewInt(1), 128)
35 + serial, err := rand.Int(rand.Reader, serialLimit)
36 + if err != nil {
37 + return nil, nil, err
38 + }
39 +
40 + template := &x509.Certificate{
41 + SerialNumber: serial,
42 + Subject: pkix.Name{
43 + CommonName: hosts[0],
44 + },
45 + NotBefore: time.Now().Add(-time.Hour),
46 + NotAfter: time.Now().Add(24 * time.Hour),
47 + KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
48 + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
49 + BasicConstraintsValid: true,
50 + }
51 +
52 + for _, host := range hosts {
53 + if ip := net.ParseIP(host); ip != nil {
54 + template.IPAddresses = append(template.IPAddresses, ip)
55 + continue
56 + }
57 + template.DNSNames = append(template.DNSNames, host)
58 + }
59 +
60 + der, err := x509.CreateCertificate(rand.Reader, template, template, &priv.PublicKey, priv)
61 + if err != nil {
62 + return nil, nil, err
63 + }
64 +
65 + certPEM = pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
66 + keyBytes, err := x509.MarshalECPrivateKey(priv)
67 + if err != nil {
68 + return nil, nil, err
69 + }
70 + keyPEM = pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyBytes})
71 + return certPEM, keyPEM, nil
72 +}
73 +
74 func TestListenerEndToEndTLSHTTP(t *testing.T) {
75 t.Parallel()
76
23 - apiCertPEM, apiKeyPEM, err := testutil.SelfSignedCertPEM("127.0.0.1")
77 + apiCertPEM, apiKeyPEM, err := selfSignedCertPEM("127.0.0.1", "portal.test", "*.portal.test")
78 if err != nil {
25 - t.Fatalf("SelfSignedCertPEM(api) error = %v", err)
79 + t.Fatalf("selfSignedCertPEM(api) error = %v", err)
80 }
81 tenantHost := "app.portal.test"
28 - tenantCertPEM, tenantKeyPEM, err := testutil.SelfSignedCertPEM(tenantHost)
29 - if err != nil {
30 - t.Fatalf("SelfSignedCertPEM(tenant) error = %v", err)
31 - }
82
83 relay, err := portal.NewServer(portal.ServerConfig{
34 - PortalURL: "https://127.0.0.1",
35 - APIListenAddr: "127.0.0.1:0",
36 - SNIListenAddr: "127.0.0.1:0",
37 - RootHost: "portal.test",
38 - RootFallbackAddr: "127.0.0.1:1",
84 + PortalURL: "https://127.0.0.1",
85 + APIListenAddr: "127.0.0.1:0",
86 + SNIListenAddr: "127.0.0.1:0",
87 + RootHost: "portal.test",
88 + RootFallbackAddr: "127.0.0.1:1",
89 + KeylessSignerHandler: newTestSignerHandler(t, apiKeyPEM),
90 APITLS: portal.TLSMaterialConfig{
91 CertPEM: apiCertPEM,
92 KeyPEM: apiKeyPEM,
@@ -78,10 +129,6 @@ func TestListenerEndToEndTLSHTTP(t *testing.T) {
129 Thumbnail: "https://example.test/thumb.png",
130 Hide: true,
131 },
81 - TLS: portal.TLSMaterialConfig{
82 - CertPEM: tenantCertPEM,
83 - KeyPEM: tenantKeyPEM,
84 - },
132 })
133 if err != nil {
134 t.Fatalf("Listen() error = %v", err)
@@ -145,21 +192,21 @@ func TestListenerEndToEndTLSHTTP(t *testing.T) {
192 }
193 }
194
148 -func TestListenerEndToEndTLSHTTP_AutoSelfSigned(t *testing.T) {
195 +func TestListenerEndToEndTLSHTTP_AutoKeyless(t *testing.T) {
196 t.Parallel()
197
151 - apiCertPEM, apiKeyPEM, err := testutil.SelfSignedCertPEM("127.0.0.1")
198 + apiCertPEM, apiKeyPEM, err := selfSignedCertPEM("127.0.0.1", "portal.test", "*.portal.test")
199 if err != nil {
153 - t.Fatalf("SelfSignedCertPEM(api) error = %v", err)
200 + t.Fatalf("selfSignedCertPEM(api) error = %v", err)
201 }
202 tenantHost := "auto.portal.test"
156 -
203 relay, err := portal.NewServer(portal.ServerConfig{
158 - PortalURL: "https://127.0.0.1",
159 - APIListenAddr: "127.0.0.1:0",
160 - SNIListenAddr: "127.0.0.1:0",
161 - RootHost: "portal.test",
162 - RootFallbackAddr: "127.0.0.1:1",
204 + PortalURL: "https://127.0.0.1",
205 + APIListenAddr: "127.0.0.1:0",
206 + SNIListenAddr: "127.0.0.1:0",
207 + RootHost: "portal.test",
208 + RootFallbackAddr: "127.0.0.1:1",
209 + KeylessSignerHandler: newTestSignerHandler(t, apiKeyPEM),
210 APITLS: portal.TLSMaterialConfig{
211 CertPEM: apiCertPEM,
212 KeyPEM: apiKeyPEM,
@@ -204,7 +251,7 @@ func TestListenerEndToEndTLSHTTP_AutoSelfSigned(t *testing.T) {
251 httpDone := make(chan error, 1)
252 server := &http.Server{
253 Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
207 - _, _ = io.WriteString(w, "auto tls ok\n")
254 + _, _ = io.WriteString(w, "auto keyless ok\n")
255 }),
256 ReadHeaderTimeout: 5 * time.Second,
257 }
@@ -228,8 +275,8 @@ func TestListenerEndToEndTLSHTTP_AutoSelfSigned(t *testing.T) {
275 for {
276 body, err := doTenantRequest(relay.SNIAddr(), tenantHost, "/")
277 if err == nil {
231 - if !strings.Contains(body, "auto tls ok") {
232 - t.Fatalf("body = %q, want auto tls payload", body)
278 + if !strings.Contains(body, "auto keyless ok") {
279 + t.Fatalf("body = %q, want auto keyless payload", body)
280 }
281 return
282 }
@@ -270,3 +317,17 @@ func doTenantRequest(addr, host, path string) (string, error) {
317 }
318 return string(body), nil
319 }
320 +
321 +func newTestSignerHandler(t *testing.T, keyPEM []byte) http.Handler {
322 + t.Helper()
323 +
324 + keyFile := t.TempDir() + "/relay-key.pem"
325 + if err := os.WriteFile(keyFile, keyPEM, 0o600); err != nil {
326 + t.Fatalf("WriteFile(key) error = %v", err)
327 + }
328 + signer, err := portalkeyless.NewSigner(keyFile)
329 + if err != nil {
330 + t.Fatalf("NewSigner() error = %v", err)
331 + }
332 + return signer.Handler()
333 +}
sdk/tls.go deleted
-139
@@ -1,139 +0,0 @@
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 - "io"
14 - "math/big"
15 - "net"
16 - "strings"
17 - "time"
18 -
19 - keylesslib "github.com/gosuda/keyless_tls/keyless"
20 -
21 - "gosuda.org/portal/portal"
22 -)
23 -
24 -func buildTenantTLSConfig(cfg portal.TLSMaterialConfig) (*tls.Config, io.Closer, error) {
25 - if len(cfg.CertPEM) == 0 {
26 - return nil, nil, errors.New("tenant certificate is required")
27 - }
28 - if cfg.Keyless != nil {
29 - remoteSigner, err := keylesslib.NewRemoteSigner(keylesslib.RemoteSignerConfig{
30 - Endpoint: cfg.Keyless.Endpoint,
31 - ServerName: cfg.Keyless.ServerName,
32 - KeyID: cfg.Keyless.KeyID,
33 - ClientCertPEM: cfg.Keyless.ClientCertPEM,
34 - ClientKeyPEM: cfg.Keyless.ClientKeyPEM,
35 - RootCAPEM: cfg.Keyless.RootCAPEM,
36 - }, cfg.CertPEM)
37 - if err != nil {
38 - return nil, nil, err
39 - }
40 - tlsConf, err := keylesslib.NewServerTLSConfig(keylesslib.ServerTLSConfig{
41 - CertPEM: cfg.CertPEM,
42 - Signer: remoteSigner,
43 - NextProtos: []string{"http/1.1"},
44 - MinVersion: tls.VersionTLS12,
45 - })
46 - if err != nil {
47 - _ = remoteSigner.Close()
48 - return nil, nil, err
49 - }
50 - return tlsConf, remoteSigner, nil
51 - }
52 -
53 - cert, err := tls.X509KeyPair(cfg.CertPEM, cfg.KeyPEM)
54 - if err != nil {
55 - return nil, nil, fmt.Errorf("parse tenant tls key pair: %w", err)
56 - }
57 -
58 - return &tls.Config{
59 - MinVersion: tls.VersionTLS12,
60 - NextProtos: []string{"http/1.1"},
61 - Certificates: []tls.Certificate{cert},
62 - }, nil, nil
63 -}
64 -
65 -func buildAutoTenantTLSConfig(hostnames []string) (*tls.Config, error) {
66 - certPEM, keyPEM, err := selfSignedTenantCert(hostnames)
67 - if err != nil {
68 - return nil, err
69 - }
70 - tlsConf, _, err := buildTenantTLSConfig(portal.TLSMaterialConfig{
71 - CertPEM: certPEM,
72 - KeyPEM: keyPEM,
73 - })
74 - return tlsConf, err
75 -}
76 -
77 -func selfSignedTenantCert(hostnames []string) (certPEM, keyPEM []byte, err error) {
78 - priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
79 - if err != nil {
80 - return nil, nil, fmt.Errorf("generate self-signed tenant key: %w", err)
81 - }
82 -
83 - serialLimit := new(big.Int).Lsh(big.NewInt(1), 128)
84 - serial, err := rand.Int(rand.Reader, serialLimit)
85 - if err != nil {
86 - return nil, nil, fmt.Errorf("generate self-signed tenant serial: %w", err)
87 - }
88 -
89 - template := &x509.Certificate{
90 - SerialNumber: serial,
91 - Subject: pkix.Name{
92 - CommonName: firstHostname(hostnames),
93 - },
94 - NotBefore: time.Now().Add(-1 * time.Hour),
95 - NotAfter: time.Now().Add(30 * 24 * time.Hour),
96 - KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
97 - ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
98 - BasicConstraintsValid: true,
99 - }
100 -
101 - for _, host := range hostnames {
102 - host = strings.TrimSpace(host)
103 - if host == "" {
104 - continue
105 - }
106 - if ip := net.ParseIP(host); ip != nil {
107 - template.IPAddresses = append(template.IPAddresses, ip)
108 - continue
109 - }
110 - template.DNSNames = append(template.DNSNames, host)
111 - }
112 - if len(template.DNSNames) == 0 && len(template.IPAddresses) == 0 {
113 - template.DNSNames = []string{"localhost"}
114 - template.Subject.CommonName = "localhost"
115 - }
116 -
117 - der, err := x509.CreateCertificate(rand.Reader, template, template, &priv.PublicKey, priv)
118 - if err != nil {
119 - return nil, nil, fmt.Errorf("create self-signed tenant certificate: %w", err)
120 - }
121 -
122 - certPEM = pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
123 - keyDER, err := x509.MarshalECPrivateKey(priv)
124 - if err != nil {
125 - return nil, nil, fmt.Errorf("marshal self-signed tenant key: %w", err)
126 - }
127 - keyPEM = pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER})
128 - return certPEM, keyPEM, nil
129 -}
130 -
131 -func firstHostname(hostnames []string) string {
132 - for _, host := range hostnames {
133 - host = strings.TrimSpace(host)
134 - if host != "" {
135 - return host
136 - }
137 - }
138 - return "localhost"
139 -}