From 6a41a542126544ffbcd0fcd8cd10e85bf4df1081 Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Wed, 13 May 2026 21:30:22 -0500 Subject: [PATCH] fix(macos): harden direct gateway TLS pinning Summary: - Require macOS system trust before saving and accepting first-use direct `wss://` gateway TLS pins. - Honor `gateway.remote.tlsFingerprint` in macOS direct node-mode TLS params. - Add focused Swift coverage and update remote gateway docs/changelog. Verification: - Local: swiftformat --lint on touched Swift files. - Local: git diff --check HEAD~1..HEAD. - Local: swift test --package-path apps/shared/OpenClawKit --filter GatewayTLSPinningTests. - Local: swift test --package-path apps/macos --filter 'MacNodeModeCoordinatorTests|GatewayEndpointStoreTests'. - Local: PATH=/Users/buns/.nvm/versions/node/v24.13.0/bin:$PATH pnpm docs:list. - CI: macos-node, macos-swift, check-docs, security-fast, security-scm-fast, security-dependency-audit, Opengrep OSS, and changed-path checks passed on PR head cf383fc0472a094dfe57f45731af238952023598. Fixes #50642. Supersedes #50643. --- CHANGELOG.md | 1 + .../OpenClaw/GatewayRemoteConfig.swift | 11 ++++ .../NodeMode/MacNodeModeCoordinator.swift | 36 ++++++++++--- .../GatewayEndpointStoreTests.swift | 32 +++++++++++ .../MacNodeModeCoordinatorTests.swift | 54 +++++++++++++++++++ .../OpenClawKit/GatewayTLSPinning.swift | 21 +++++--- .../GatewayTLSPinningTests.swift | 9 ++++ docs/gateway/remote.md | 2 +- docs/platforms/mac/remote.md | 2 +- 9 files changed, 153 insertions(+), 15 deletions(-) create mode 100644 apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayTLSPinningTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index d01c282b269..76829bbdb01 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ Docs: https://docs.openclaw.ai - Codex startup: treat selectable configured OpenAI agent models as Codex runtime requirements during plugin auto-enable, startup planning, and doctor install repair, so Anthropic-primary configs can still switch to OpenAI/Codex cleanly. - Agents: preserve source-reply delivery metadata when merging tool-returned media into the final reply, keeping message-tool-only replies deliverable and mirrored. Thanks @pashpashpash and @vincentkoc. +- macOS/companion: require system TLS trust before pinning a first-use direct `wss://` gateway certificate and honor `gateway.remote.tlsFingerprint` as the explicit pin for remote node-mode sessions, so fresh endpoints fail closed when macOS cannot trust the certificate unless configured out of band. Fixes #50642. Thanks @BunsDev. - Sessions/status: classify ACP spawn-child sessions as `kind: "spawn-child"` instead of `"direct"` in `openclaw sessions` and status output; extract the duplicated session-kind classifier into a shared helper (`src/sessions/classify-session-kind.ts`) so both surfaces stay in sync. Fixes catalog #19. (#79544) - Sessions/Gateway: report `agentRuntime.id: "acpx"` (or stored backend id) with `source: "session-key"` for ACP control-plane session rows in `openclaw sessions --json`, `openclaw status`, and Gateway session RPC responses instead of the incorrect `"auto"` / `"pi"` implicit fallback. Fixes catalog #18. (#79550) - Telegram: delete tool-progress-only draft bubbles before rotating to the real answer, preventing orphaned progress messages in streamed replies. diff --git a/apps/macos/Sources/OpenClaw/GatewayRemoteConfig.swift b/apps/macos/Sources/OpenClaw/GatewayRemoteConfig.swift index 4eee8165d52..629651e34e7 100644 --- a/apps/macos/Sources/OpenClaw/GatewayRemoteConfig.swift +++ b/apps/macos/Sources/OpenClaw/GatewayRemoteConfig.swift @@ -69,6 +69,17 @@ enum GatewayRemoteConfig { } } + static func resolveTLSFingerprint(root: [String: Any]) -> String? { + guard let gateway = root["gateway"] as? [String: Any], + let remote = gateway["remote"] as? [String: Any], + let raw = remote["tlsFingerprint"] as? String + else { + return nil + } + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } + static func resolveGatewayUrl(root: [String: Any]) -> URL? { guard let raw = self.resolveUrlString(root: root) else { return nil } return self.normalizeGatewayUrl(raw) diff --git a/apps/macos/Sources/OpenClaw/NodeMode/MacNodeModeCoordinator.swift b/apps/macos/Sources/OpenClaw/NodeMode/MacNodeModeCoordinator.swift index 7cf8938bcca..ae5bc8504a0 100644 --- a/apps/macos/Sources/OpenClaw/NodeMode/MacNodeModeCoordinator.swift +++ b/apps/macos/Sources/OpenClaw/NodeMode/MacNodeModeCoordinator.swift @@ -83,7 +83,9 @@ final class MacNodeModeCoordinator { clientId: "openclaw-macos", clientMode: "node", clientDisplayName: InstanceIdentity.displayName) - let sessionBox = self.buildSessionBox(url: config.url) + let sessionBox = self.buildSessionBox( + url: config.url, + connectionMode: AppStateStore.shared.connectionMode) try await self.session.connect( url: config.url, @@ -243,15 +245,35 @@ final class MacNodeModeCoordinator { return true } - private func buildSessionBox(url: URL) -> WebSocketSessionBox? { + nonisolated static func tlsParams( + for url: URL, + connectionMode: AppState.ConnectionMode, + root: [String: Any], + storedFingerprint: String?) -> GatewayTLSParams? + { + guard url.scheme?.lowercased() == "wss" else { return nil } + let stableID = Self.tlsPinStoreKey(for: url) + let configuredFingerprint = connectionMode == .remote + ? GatewayRemoteConfig.resolveTLSFingerprint(root: root) + : nil + let expectedFingerprint = configuredFingerprint ?? storedFingerprint + return GatewayTLSParams( + required: true, + expectedFingerprint: expectedFingerprint, + allowTOFU: expectedFingerprint == nil, + storeKey: stableID) + } + + private func buildSessionBox(url: URL, connectionMode: AppState.ConnectionMode) -> WebSocketSessionBox? { guard url.scheme?.lowercased() == "wss" else { return nil } let stableID = Self.tlsPinStoreKey(for: url) let stored = GatewayTLSStore.loadFingerprint(stableID: stableID) - let params = GatewayTLSParams( - required: true, - expectedFingerprint: stored, - allowTOFU: stored == nil, - storeKey: stableID) + guard let params = Self.tlsParams( + for: url, + connectionMode: connectionMode, + root: OpenClawConfigFile.loadDict(), + storedFingerprint: stored) + else { return nil } let session = GatewayTLSPinningSession(params: params) return WebSocketSessionBox(session: session) } diff --git a/apps/macos/Tests/OpenClawIPCTests/GatewayEndpointStoreTests.swift b/apps/macos/Tests/OpenClawIPCTests/GatewayEndpointStoreTests.swift index e4f14ea36d8..e28091591c3 100644 --- a/apps/macos/Tests/OpenClawIPCTests/GatewayEndpointStoreTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/GatewayEndpointStoreTests.swift @@ -287,4 +287,36 @@ struct GatewayEndpointStoreTests { let url = GatewayRemoteConfig.normalizeGatewayUrl("ws://127.attacker.example") #expect(url == nil) } + + @Test func `resolve tls fingerprint trims remote config value`() { + let root: [String: Any] = [ + "gateway": [ + "remote": [ + "tlsFingerprint": " sha256:ABC123 ", + ], + ], + ] + + #expect(GatewayRemoteConfig.resolveTLSFingerprint(root: root) == "sha256:ABC123") + } + + @Test func `resolve tls fingerprint ignores blank or non string values`() { + let blank: [String: Any] = [ + "gateway": [ + "remote": [ + "tlsFingerprint": " ", + ], + ], + ] + let nonString: [String: Any] = [ + "gateway": [ + "remote": [ + "tlsFingerprint": 123, + ], + ], + ] + + #expect(GatewayRemoteConfig.resolveTLSFingerprint(root: blank) == nil) + #expect(GatewayRemoteConfig.resolveTLSFingerprint(root: nonString) == nil) + } } diff --git a/apps/macos/Tests/OpenClawIPCTests/MacNodeModeCoordinatorTests.swift b/apps/macos/Tests/OpenClawIPCTests/MacNodeModeCoordinatorTests.swift index 339a9844b2a..c534352090a 100644 --- a/apps/macos/Tests/OpenClawIPCTests/MacNodeModeCoordinatorTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/MacNodeModeCoordinatorTests.swift @@ -35,6 +35,60 @@ struct MacNodeModeCoordinatorTests { #expect(MacNodeModeCoordinator.tlsPinStoreKey(for: url) == "gateway.example.ts.net:443") } + @Test func `remote tls params prefer configured fingerprint over stored pin`() throws { + let url = try #require(URL(string: "wss://gateway.example.com")) + let root: [String: Any] = [ + "gateway": [ + "remote": [ + "tlsFingerprint": "sha256:configured", + ], + ], + ] + + let params = try #require(MacNodeModeCoordinator.tlsParams( + for: url, + connectionMode: .remote, + root: root, + storedFingerprint: "stored")) + + #expect(params.expectedFingerprint == "sha256:configured") + #expect(params.allowTOFU == false) + #expect(params.storeKey == "gateway.example.com:443") + } + + @Test func `remote tls params allow first use only when no configured or stored pin exists`() throws { + let url = try #require(URL(string: "wss://gateway.example.com")) + + let params = try #require(MacNodeModeCoordinator.tlsParams( + for: url, + connectionMode: .remote, + root: [:], + storedFingerprint: nil)) + + #expect(params.expectedFingerprint == nil) + #expect(params.allowTOFU == true) + } + + @Test func `local tls params ignore remote configured fingerprint`() throws { + let url = try #require(URL(string: "wss://127.0.0.1:18789")) + let root: [String: Any] = [ + "gateway": [ + "remote": [ + "tlsFingerprint": "sha256:remote", + ], + ], + ] + + let params = try #require(MacNodeModeCoordinator.tlsParams( + for: url, + connectionMode: .local, + root: root, + storedFingerprint: "stored-local")) + + #expect(params.expectedFingerprint == "stored-local") + #expect(params.allowTOFU == false) + } + @Test func `auto repairs trusted tailscale serve pin mismatch`() throws { let url = try #require(URL(string: "wss://gateway.example.ts.net")) let failure = GatewayTLSValidationFailure( diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayTLSPinning.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayTLSPinning.swift index c0b45ed27f9..ea664ddeb9e 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayTLSPinning.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayTLSPinning.swift @@ -79,6 +79,12 @@ public protocol GatewayDeviceTokenRetryTrustProviding: AnyObject { var allowsDeviceTokenRetryAuth: Bool { get } } +enum GatewayTLSFirstUsePolicy { + static func allowsFirstUsePin(systemTrustOk: Bool) -> Bool { + systemTrustOk + } +} + public enum GatewayTLSStore { private static let keychainService = "ai.openclaw.tls-pinning" @@ -159,7 +165,8 @@ public enum GatewayTLSStore { } } -public final class GatewayTLSPinningSession: NSObject, WebSocketSessioning, URLSessionDelegate, GatewayTLSFailureProviding, GatewayDeviceTokenRetryTrustProviding, @unchecked Sendable { +public final class GatewayTLSPinningSession: NSObject, WebSocketSessioning, URLSessionDelegate, +GatewayTLSFailureProviding, GatewayDeviceTokenRetryTrustProviding, @unchecked Sendable { private let params: GatewayTLSParams private let failureLock = NSLock() private var lastTLSFailure: GatewayTLSValidationFailure? @@ -238,12 +245,14 @@ public final class GatewayTLSPinningSession: NSObject, WebSocketSessioning, URLS return } if self.params.allowTOFU { - if let storeKey = params.storeKey { - GatewayTLSStore.saveFingerprint(fingerprint, stableID: storeKey) + if GatewayTLSFirstUsePolicy.allowsFirstUsePin(systemTrustOk: systemTrustOk) { + if let storeKey = params.storeKey { + GatewayTLSStore.saveFingerprint(fingerprint, stableID: storeKey) + } + self.clearTLSFailure() + completionHandler(.useCredential, URLCredential(trust: trust)) + return } - self.clearTLSFailure() - completionHandler(.useCredential, URLCredential(trust: trust)) - return } } diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayTLSPinningTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayTLSPinningTests.swift new file mode 100644 index 00000000000..b1d478564d9 --- /dev/null +++ b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayTLSPinningTests.swift @@ -0,0 +1,9 @@ +import Testing +@testable import OpenClawKit + +struct GatewayTLSPinningTests { + @Test func `first use pinning requires system trust`() { + #expect(GatewayTLSFirstUsePolicy.allowsFirstUsePin(systemTrustOk: true)) + #expect(!GatewayTLSFirstUsePolicy.allowsFirstUsePin(systemTrustOk: false)) + } +} diff --git a/docs/gateway/remote.md b/docs/gateway/remote.md index a5edc21c8fa..cd0328c9f97 100644 --- a/docs/gateway/remote.md +++ b/docs/gateway/remote.md @@ -148,7 +148,7 @@ Short version: **keep the Gateway loopback-only** unless you're sure you need a - `gateway.remote.token` / `.password` are client credential sources. They do **not** configure server auth by themselves. - Local call paths can use `gateway.remote.*` as fallback only when `gateway.auth.*` is unset. - If `gateway.auth.token` / `gateway.auth.password` is explicitly configured via SecretRef and unresolved, resolution fails closed (no remote fallback masking). -- `gateway.remote.tlsFingerprint` pins the remote TLS cert when using `wss://`. +- `gateway.remote.tlsFingerprint` pins the remote TLS cert when using `wss://`, including macOS direct mode. Without a configured or previously stored pin, macOS only pins a first-use certificate after normal system trust passes; self-signed or private-CA gateways that macOS does not already trust need an explicit fingerprint or Remote over SSH. - **Tailscale Serve** can authenticate Control UI/WebSocket traffic via identity headers when `gateway.auth.allowTailscale: true`; HTTP API endpoints do not use that Tailscale header auth and instead follow the gateway's normal HTTP diff --git a/docs/platforms/mac/remote.md b/docs/platforms/mac/remote.md index 2e3cd4b5e34..fb6a95fe6e5 100644 --- a/docs/platforms/mac/remote.md +++ b/docs/platforms/mac/remote.md @@ -81,7 +81,7 @@ node. - **Health probe failed**: check SSH reachability, PATH, and that Baileys is logged in (`openclaw status --json`). - **Web Chat stuck**: confirm the gateway is running on the remote host and the forwarded port matches the gateway WS port; the UI requires a healthy WS connection. - **Node IP shows 127.0.0.1**: expected with the SSH tunnel. Switch **Transport** to **Direct (ws/wss)** if you want the gateway to see the real client IP. -- **Dashboard works but Mac capabilities are offline**: this means the app's operator/control connection is healthy, but the companion node connection is not connected or is missing its command surface. Open the menu bar device section and check whether the Mac is `paired · disconnected`. For `wss://*.ts.net` Tailscale Serve endpoints, the app detects stale legacy TLS leaf pins after certificate rotation, clears the stale pin when macOS trusts the new certificate, and retries automatically. If the certificate is not system-trusted or the host is not a Tailscale Serve name, review the certificate or switch to **Remote over SSH**. +- **Dashboard works but Mac capabilities are offline**: this means the app's operator/control connection is healthy, but the companion node connection is not connected or is missing its command surface. Open the menu bar device section and check whether the Mac is `paired · disconnected`. For `wss://*.ts.net` Tailscale Serve endpoints, the app detects stale legacy TLS leaf pins after certificate rotation, clears the stale pin when macOS trusts the new certificate, and retries automatically. If the certificate is not system-trusted or the host is not a Tailscale Serve name, set `gateway.remote.tlsFingerprint` to the expected certificate fingerprint, review the certificate, or switch to **Remote over SSH**. - **Voice Wake**: trigger phrases are forwarded automatically in remote mode; no separate forwarder is needed. ## Notification sounds