feat(sdk): gate mTLS lifecycle identity on KEYLESS_DIR env var

When KEYLESS_DIR is set, the SDK acquires a lifecycle identity and presents a client certificate. When unset, the SDK operates in token-only mode — no hard-fail on missing cert. Listener TLS configs conditionally include Certificates only when controlPlaneCert is non-empty. AGENTS.md updated to document implicit mTLS semantics and admission order.

cognitive committed Mar 4, 2026 at 21:22 UTC 76e4132bc5334d971aad1fadf5bf056cb0ae3470
3 files changed +21 -14
AGENTS.md
+4 -2
@@ -40,8 +40,10 @@ Source of truth for architecture decisions: `docs/adr/README.md` and linked ADRs
40 1. **Relay holds the TLS private key; SDK/tunnel never does.** SDK calls `/v1/sign` on the relay via `RemoteSigner` for all private key operations.
41 - Why: prevents key material leakage to untrusted tunnel endpoints.
42
43 -2. **mTLS is mandatory for all `/sdk/*` control-plane paths.** No token-only fallback; hard-fail on missing client cert.
44 - - Why: ADR-0003 admission order (IP ban → Lease → CertBind → Token) requires mTLS at the CertBind stage.
43 +2. **mTLS is implicit (optional) for `/sdk/*` control-plane paths.** When a client cert is presented, the relay validates it (CertBind stage). When absent, CertBind is skipped and token auth alone is used.
44 + - `KEYLESS_DIR` env var presence triggers SDK lifecycle identity issuance and client cert presentation. When unset, the SDK operates in token-only mode.
45 + - Keyless TLS (`RemoteSigner` for `/v1/sign`) is independent of mTLS — always used for TLS termination regardless of client cert presence.
46 + - Why: ADR-0003 admission order is IP ban → Lease → [CertBind if cert present] → Token. Invalid certs are still rejected; absent certs skip CertBind.
47
48 3. **All relay URLs must be `https://`.** `NormalizeRelayAPIURL` rejects non-HTTPS. SDK and tunnel hard-fail on `http://`.
49 - Why: enforces transport security without opt-out.
sdk/client.go
+6 -3
@@ -108,9 +108,12 @@ func (c *Client) Listen(name string, options ...types.MetadataOption) (net.Liste
108 if err != nil {
109 return nil, err
110 }
111 - controlPlaneIdentity, err := acquireLifecycleIdentity(lease.ID)
112 - if err != nil {
113 - return nil, err
111 + var controlPlaneIdentity tls.Certificate
112 + if strings.TrimSpace(os.Getenv(keylessDirEnvVar)) != "" {
113 + controlPlaneIdentity, err = acquireLifecycleIdentity(lease.ID)
114 + if err != nil {
115 + return nil, err
116 + }
117 }
118
119 listeners := make([]net.Listener, 0, len(relayAddrs))
sdk/listener.go
+11 -9
@@ -122,22 +122,21 @@ func NewListener(relayAddr string, lease *portal.Lease, tlsConfig *tls.Config, c
122 if tlsConfig == nil {
123 return nil, errors.New("tls config is required")
124 }
125 - if len(controlPlaneCert.Certificate) == 0 {
126 - return nil, errors.New("control plane client certificate is required")
127 - }
128 -
125 apiURL, err := netutil.NormalizeRelayAPIURL(relayAddr)
126 if err != nil {
127 return nil, err
128 }
129 host := netutil.PortalRootHost(apiURL)
130 clientTransport := http.DefaultTransport.(*http.Transport).Clone()
135 - clientTransport.TLSClientConfig = &tls.Config{
131 + transportTLSConfig := &tls.Config{
132 MinVersion: tls.VersionTLS12,
133 ServerName: host,
134 InsecureSkipVerify: netutil.IsLocalhost(host),
139 - Certificates: []tls.Certificate{controlPlaneCert},
135 }
136 + if len(controlPlaneCert.Certificate) > 0 {
137 + transportTLSConfig.Certificates = []tls.Certificate{controlPlaneCert}
138 + }
139 + clientTransport.TLSClientConfig = transportTLSConfig
140
141 if reverseWorkers <= 0 {
142 reverseWorkers = defaultReverseWorkers
@@ -407,12 +406,15 @@ func (l *Listener) openReverseConnection() (net.Conn, error) {
406 _ = rawConn.Close()
407 return nil, errors.New("reverse connect URL missing TLS server name")
408 }
410 - tlsConn := tls.Client(rawConn, &tls.Config{
409 + reverseTLSConfig := &tls.Config{
410 MinVersion: tls.VersionTLS12,
411 ServerName: serverName,
412 InsecureSkipVerify: netutil.IsLocalhost(serverName),
414 - Certificates: []tls.Certificate{l.controlPlaneCert},
415 - })
413 + }
414 + if len(l.controlPlaneCert.Certificate) > 0 {
415 + reverseTLSConfig.Certificates = []tls.Certificate{l.controlPlaneCert}
416 + }
417 + tlsConn := tls.Client(rawConn, reverseTLSConfig)
418 err = tlsConn.HandshakeContext(ctx)
419 if err != nil {
420 _ = rawConn.Close()