diff --git a/apps/.i18n/native-source.json b/apps/.i18n/native-source.json index 7dc44fd431f6..9b37aa40e9f5 100644 --- a/apps/.i18n/native-source.json +++ b/apps/.i18n/native-source.json @@ -41401,6 +41401,22 @@ "surface": "apple", "id": "native.apple.b737f84578f558eb" }, + { + "kind": "ui-localized-call", + "line": 769, + "path": "apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayConnectionProblem.swift", + "source": "Retry", + "surface": "apple", + "id": "native.apple.9a4c4bd6b41420ae" + }, + { + "kind": "ui-localized-call", + "line": 784, + "path": "apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayConnectionProblem.swift", + "source": "Check certificate", + "surface": "apple", + "id": "native.apple.2ace7dd34f4ca3a2" + }, { "kind": "conditional-branch", "line": 7, diff --git a/apps/macos/Sources/OpenClaw/GatewayConnection+WidgetSurface.swift b/apps/macos/Sources/OpenClaw/GatewayConnection+WidgetSurface.swift index 2dabbee0f4c5..9f0515abae47 100644 --- a/apps/macos/Sources/OpenClaw/GatewayConnection+WidgetSurface.swift +++ b/apps/macos/Sources/OpenClaw/GatewayConnection+WidgetSurface.swift @@ -87,9 +87,9 @@ extension GatewayConnection { private func currentCanvasPluginSurfaceRoute() -> GatewayCanvasHostRoute? { guard let url = self.canvasPluginSurfaceURL else { return nil } - // The operator channel uses platform trust. Pinned remote routes belong - // to MacNodeModeCoordinator and arrive through its node session. - return GatewayCanvasHostRoute(url: url, tlsFingerprintSHA256: nil) + return GatewayCanvasHostRoute( + url: url, + tlsFingerprintSHA256: self.configuredTLSFingerprintSHA256()) } func installCanvasPluginSurfaceURL(from snapshot: HelloOk) { diff --git a/apps/macos/Sources/OpenClaw/GatewayConnection.swift b/apps/macos/Sources/OpenClaw/GatewayConnection.swift index 66bd1bf4fa32..b59b6c05e35c 100644 --- a/apps/macos/Sources/OpenClaw/GatewayConnection.swift +++ b/apps/macos/Sources/OpenClaw/GatewayConnection.swift @@ -4,72 +4,9 @@ import OpenClawChatUI import OpenClawKit import OpenClawProtocol import OSLog -import Security private let gatewayConnectionLogger = Logger(subsystem: "ai.openclaw", category: "gateway.connection") -private struct GatewayRouteChangedAfterDispatchError: LocalizedError, Sendable { - let method: String - - var errorDescription: String? { - "The Gateway route changed after \(self.method) was sent. Its result is unknown; refresh before retrying." - } -} - -private enum GatewayActivationBindingKeyStore { - private static let service = "ai.openclaw.onboarding-route-binding" - private static let account = "credential-binding-v1" - private static let byteCount = 32 - - static func loadOrCreate() -> SymmetricKey? { - if let data = load() { - return SymmetricKey(data: data) - } - - var data = Data(count: byteCount) - let randomStatus = data.withUnsafeMutableBytes { bytes in - guard let baseAddress = bytes.baseAddress else { return errSecAllocate } - return SecRandomCopyBytes(kSecRandomDefault, self.byteCount, baseAddress) - } - guard randomStatus == errSecSuccess else { return nil } - - var query = self.baseQuery - query[kSecValueData as String] = data - query[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly - let addStatus = SecItemAdd(query as CFDictionary, nil) - if addStatus == errSecSuccess { - return SymmetricKey(data: data) - } - // Another process can win the first-launch create race. Only accept the - // secret after reading the Keychain item back through normal ACL checks. - if addStatus == errSecDuplicateItem, let existing = load() { - return SymmetricKey(data: existing) - } - return nil - } - - private static func load() -> Data? { - var query = self.baseQuery - query[kSecReturnData as String] = true - query[kSecMatchLimit as String] = kSecMatchLimitOne - var result: CFTypeRef? - guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess, - let data = result as? Data, - data.count == byteCount - else { return nil } - return data - } - - private static var baseQuery: [String: Any] { - [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: service, - kSecAttrAccount as String: account, - kSecAttrSynchronizable as String: false, - ] - } -} - /// Single, shared Gateway websocket connection for the whole app. /// /// This owns exactly one `GatewayChannelActor` and reuses it across all callers @@ -84,17 +21,20 @@ actor GatewayConnection { struct EndpointSnapshot { let config: Config + let tls: GatewayTLSRoute? let routeAuthority: UInt64? let deviceAuthGatewayID: String? let revision: UInt64? init( config: Config, + tls: GatewayTLSRoute? = nil, routeAuthority: UInt64?, deviceAuthGatewayID: String? = nil, revision: UInt64? = nil) { self.config = config + self.tls = tls self.routeAuthority = routeAuthority self.deviceAuthGatewayID = deviceAuthGatewayID self.revision = revision @@ -102,6 +42,7 @@ actor GatewayConnection { } typealias EndpointProvider = @Sendable () async throws -> EndpointSnapshot + typealias SessionProvider = @Sendable (GatewayTLSRoute?) -> WebSocketSessionBox? struct Route: Equatable, Sendable { fileprivate let generation: UInt64 @@ -109,19 +50,17 @@ actor GatewayConnection { fileprivate let url: URL fileprivate let token: String? fileprivate let password: String? + fileprivate let tls: GatewayTLSRoute? fileprivate let deviceAuthGatewayID: String? let activationOwnershipFingerprint: String? - fileprivate func matches( - _ config: Config, - authority: UInt64?, - deviceAuthGatewayID: String?) -> Bool - { - self.authority == authority && - self.url == config.url && - self.token == config.token && - self.password == config.password && - self.deviceAuthGatewayID == deviceAuthGatewayID + fileprivate func matches(_ endpoint: EndpointSnapshot) -> Bool { + self.authority == endpoint.routeAuthority && + self.url == endpoint.config.url && + self.token == endpoint.config.token && + self.password == endpoint.config.password && + GatewayTLSRoute.hasSameConnectionIdentity(self.tls, endpoint.tls) && + self.deviceAuthGatewayID == endpoint.deviceAuthGatewayID } } @@ -188,7 +127,7 @@ actor GatewayConnection { private let supportsSharedEndpointRecovery: Bool private let activationBindingKeyProvider: @Sendable () -> SymmetricKey? private let includeDeviceIdentity: Bool - private let sessionBox: WebSocketSessionBox? + private let sessionProvider: SessionProvider private let clientShutdown: @Sendable (GatewayChannelActor) async -> Void private let decoder = JSONDecoder() @@ -196,6 +135,8 @@ actor GatewayConnection { private var configuredURL: URL? private var configuredToken: String? private var configuredPassword: String? + private var configuredTLS: GatewayTLSRoute? + private var configuredTLSMetadataProvider: (any GatewayTLSRouteMetadataProviding)? private var configuredDeviceAuthGatewayID: String? private var configuredRouteAuthority: UInt64? private var configuredShutdownGeneration: UInt64? @@ -227,6 +168,7 @@ actor GatewayConnection { activationBindingKeyProvider: @escaping @Sendable () -> SymmetricKey? = GatewayConnection.defaultActivationBindingKey, sessionBox: WebSocketSessionBox? = nil, + sessionProvider: SessionProvider? = nil, clientShutdown: @escaping @Sendable (GatewayChannelActor) async -> Void = { client in await client.shutdown() }) @@ -235,7 +177,9 @@ actor GatewayConnection { self.supportsSharedEndpointRecovery = supportsSharedEndpointRecovery self.activationBindingKeyProvider = activationBindingKeyProvider self.includeDeviceIdentity = true - self.sessionBox = sessionBox + self.sessionProvider = Self.resolveSessionProvider( + sessionBox: sessionBox, + sessionProvider: sessionProvider) self.clientShutdown = clientShutdown } @@ -248,6 +192,7 @@ actor GatewayConnection { GatewayConnection.testingActivationBindingKey }, sessionBox: WebSocketSessionBox? = nil, + sessionProvider: SessionProvider? = nil, clientShutdown: @escaping @Sendable (GatewayChannelActor) async -> Void = { client in await client.shutdown() }) @@ -260,11 +205,28 @@ actor GatewayConnection { // Mock WebSocket routes do not exercise device authentication and must not // depend on the process-global persisted identity store. self.includeDeviceIdentity = false - self.sessionBox = sessionBox + self.sessionProvider = Self.resolveSessionProvider( + sessionBox: sessionBox, + sessionProvider: sessionProvider) self.clientShutdown = clientShutdown } #endif + private static func resolveSessionProvider( + sessionBox: WebSocketSessionBox?, + sessionProvider: SessionProvider?) -> SessionProvider + { + if let sessionProvider { + return sessionProvider + } + if let sessionBox { + return { _ in sessionBox } + } + return { route in + route.map { WebSocketSessionBox(session: GatewayTLSPinningSession(params: $0.params)) } + } + } + // MARK: - Low-level request func request( @@ -272,6 +234,21 @@ actor GatewayConnection { params: [String: AnyCodable]?, timeoutMs: Double? = nil, retryTransportFailures: Bool = true) async throws -> Data + { + try await self.request( + method: method, + params: params, + timeoutMs: timeoutMs, + retryTransportFailures: retryTransportFailures, + allowTLSRepair: true) + } + + private func request( + method: String, + params: [String: AnyCodable]?, + timeoutMs: Double?, + retryTransportFailures: Bool, + allowTLSRepair: Bool) async throws -> Data { let shutdownGeneration = shutdownGeneration let endpoint = try await currentEndpoint() @@ -280,6 +257,7 @@ actor GatewayConnection { url: cfg.url, token: cfg.token, password: cfg.password, + tls: endpoint.tls, deviceAuthGatewayID: endpoint.deviceAuthGatewayID, routeAuthority: endpoint.routeAuthority, shutdownGeneration: shutdownGeneration) @@ -287,6 +265,20 @@ actor GatewayConnection { do { return try await client.request(method: method, params: params, timeoutMs: timeoutMs) } catch { + if allowTLSRepair, + let tlsError = error as? GatewayTLSValidationError, + await GatewayTLSRepairCoordinator.shared.repair( + route: endpoint.tls, + url: cfg.url, + failure: tlsError.failure) + { + return try await self.request( + method: method, + params: params, + timeoutMs: timeoutMs, + retryTransportFailures: retryTransportFailures, + allowTLSRepair: false) + } if !retryTransportFailures || error is GatewayResponseError || error is GatewayDecodingError { throw error } @@ -327,6 +319,7 @@ actor GatewayConnection { url: fallbackConfig.url, token: fallbackConfig.token, password: fallbackConfig.password, + tls: fallback.tls, deviceAuthGatewayID: fallback.deviceAuthGatewayID, routeAuthority: fallback.routeAuthority, shutdownGeneration: shutdownGeneration) @@ -372,6 +365,7 @@ actor GatewayConnection { url: cfg.url, token: cfg.token, password: cfg.password, + tls: endpoint.tls, deviceAuthGatewayID: endpoint.deviceAuthGatewayID, routeAuthority: endpoint.routeAuthority, shutdownGeneration: shutdownGeneration) @@ -400,15 +394,12 @@ actor GatewayConnection { distinguishPreDispatchRouteChange: Bool = false) async throws -> Data { let endpoint = try await currentEndpoint() - let cfg = endpoint.config guard route.generation == self.routeGeneration, - route.matches( - cfg, - authority: endpoint.routeAuthority, - deviceAuthGatewayID: endpoint.deviceAuthGatewayID), + route.matches(endpoint), self.configuredURL == route.url, self.configuredToken == route.token, self.configuredPassword == route.password, + GatewayTLSRoute.hasSameConnectionIdentity(self.configuredTLS, route.tls), self.configuredDeviceAuthGatewayID == route.deviceAuthGatewayID, self.configuredRouteAuthority == route.authority, let client @@ -597,6 +588,7 @@ extension GatewayConnection { url: cfg.url, token: cfg.token, password: cfg.password, + tls: endpoint.tls, deviceAuthGatewayID: endpoint.deviceAuthGatewayID, routeAuthority: endpoint.routeAuthority, shutdownGeneration: shutdownGeneration) @@ -611,6 +603,7 @@ extension GatewayConnection { url: cfg.url, token: cfg.token, password: cfg.password, + tls: endpoint.tls, deviceAuthGatewayID: endpoint.deviceAuthGatewayID, routeAuthority: endpoint.routeAuthority, shutdownGeneration: shutdownGeneration) @@ -620,6 +613,7 @@ extension GatewayConnection { url: cfg.url, token: cfg.token, password: cfg.password, + tls: endpoint.tls, deviceAuthGatewayID: endpoint.deviceAuthGatewayID, activationOwnershipFingerprint: Self.activationOwnershipFingerprint( config: cfg, @@ -666,6 +660,7 @@ extension GatewayConnection { url: cfg.url, token: cfg.token, password: cfg.password, + tls: endpoint.tls, deviceAuthGatewayID: endpoint.deviceAuthGatewayID, routeAuthority: endpoint.routeAuthority, shutdownGeneration: shutdownGeneration) @@ -687,6 +682,7 @@ extension GatewayConnection { url: cfg.url, token: cfg.token, password: cfg.password, + tls: endpoint.tls, deviceAuthGatewayID: endpoint.deviceAuthGatewayID, activationOwnershipFingerprint: Self.activationOwnershipFingerprint( config: cfg, @@ -718,6 +714,7 @@ extension GatewayConnection { replacement.route.url == previous.route.url, replacement.route.token == previous.route.token, replacement.route.password == previous.route.password, + GatewayTLSRoute.hasSameConnectionIdentity(replacement.route.tls, previous.route.tls), replacement.route.deviceAuthGatewayID == previous.route.deviceAuthGatewayID else { throw OpenClawChatTransportSendError.notDispatched @@ -727,15 +724,12 @@ extension GatewayConnection { func isCurrentRoute(_ route: Route) async -> Bool { guard let endpoint = try? await currentEndpoint() else { return false } - let cfg = endpoint.config return route.generation == self.routeGeneration && - route.matches( - cfg, - authority: endpoint.routeAuthority, - deviceAuthGatewayID: endpoint.deviceAuthGatewayID) && + route.matches(endpoint) && self.configuredURL == route.url && self.configuredToken == route.token && self.configuredPassword == route.password && + GatewayTLSRoute.hasSameConnectionIdentity(self.configuredTLS, route.tls) && self.configuredDeviceAuthGatewayID == route.deviceAuthGatewayID && self.configuredRouteAuthority == route.authority } @@ -745,16 +739,13 @@ extension GatewayConnection { ifCurrentRoute route: Route) async -> Bool? { guard let endpoint = try? await currentEndpoint() else { return nil } - let cfg = endpoint.config guard route.generation == self.routeGeneration, - route.matches( - cfg, - authority: endpoint.routeAuthority, - deviceAuthGatewayID: endpoint.deviceAuthGatewayID), + route.matches(endpoint), self.configuredURL == route.url, self.configuredToken == route.token, self.configuredPassword == route.password, + GatewayTLSRoute.hasSameConnectionIdentity(self.configuredTLS, route.tls), self.configuredDeviceAuthGatewayID == route.deviceAuthGatewayID, self.configuredRouteAuthority == route.authority, let snapshot = lastSnapshot @@ -776,10 +767,7 @@ extension GatewayConnection { func isCurrentServerLease(_ lease: ServerLease) async -> Bool { guard let endpoint = try? await currentEndpoint(), serverLeaseMatchesCurrentState(lease), - lease.route.matches( - endpoint.config, - authority: endpoint.routeAuthority, - deviceAuthGatewayID: endpoint.deviceAuthGatewayID), + lease.route.matches(endpoint), await lease.client.currentConnectionGeneration() == lease.socketGeneration, serverLeaseMatchesCurrentState(lease) else { return false } @@ -798,6 +786,7 @@ extension GatewayConnection { lease.route.url == self.configuredURL && lease.route.token == self.configuredToken && lease.route.password == self.configuredPassword && + GatewayTLSRoute.hasSameConnectionIdentity(lease.route.tls, self.configuredTLS) && lease.route.deviceAuthGatewayID == self.configuredDeviceAuthGatewayID && lease.route.authority == self.configuredRouteAuthority && self.client === lease.client && @@ -818,6 +807,10 @@ extension GatewayConnection { self.configuredURL } + func configuredTLSFingerprintSHA256() -> String? { + self.configuredTLSMetadataProvider?.effectiveTLSFingerprintSHA256 + } + func authSource() async -> GatewayAuthSource? { guard let client else { return nil } return await client.authSource() @@ -834,6 +827,8 @@ extension GatewayConnection { self.configuredURL = nil self.configuredToken = nil self.configuredPassword = nil + self.configuredTLS = nil + self.configuredTLSMetadataProvider = nil self.configuredDeviceAuthGatewayID = nil self.configuredRouteAuthority = nil self.configuredShutdownGeneration = nil @@ -847,6 +842,7 @@ extension GatewayConnection { url: URL, token: String?, password: String?, + tls: GatewayTLSRoute?, deviceAuthGatewayID: String?, routeAuthority: UInt64?, shutdownGeneration: UInt64) async throws -> GatewayChannelActor @@ -856,6 +852,7 @@ extension GatewayConnection { url: url, token: token, password: password, + tls: tls, deviceAuthGatewayID: deviceAuthGatewayID, routeAuthority: routeAuthority, shutdownGeneration: shutdownGeneration) @@ -874,6 +871,8 @@ extension GatewayConnection { self.configuredURL = nil self.configuredToken = nil self.configuredPassword = nil + self.configuredTLS = nil + self.configuredTLSMetadataProvider = nil self.configuredDeviceAuthGatewayID = nil self.configuredRouteAuthority = nil self.configuredShutdownGeneration = nil @@ -887,6 +886,7 @@ extension GatewayConnection { url: url, token: token, password: password, + tls: tls, deviceAuthGatewayID: deviceAuthGatewayID, routeAuthority: routeAuthority, shutdownGeneration: shutdownGeneration) @@ -896,6 +896,7 @@ extension GatewayConnection { throw CancellationError() } let activationBindingKey = self.activationBindingKeyProvider() + let sessionBox = self.sessionProvider(tls) let client = GatewayChannelActor( url: url, token: token, @@ -935,6 +936,8 @@ extension GatewayConnection { self.configuredURL = url self.configuredToken = token self.configuredPassword = password + self.configuredTLS = tls + self.configuredTLSMetadataProvider = sessionBox?.session as? GatewayTLSRouteMetadataProviding self.configuredDeviceAuthGatewayID = deviceAuthGatewayID self.configuredRouteAuthority = routeAuthority self.configuredShutdownGeneration = shutdownGeneration @@ -946,6 +949,7 @@ extension GatewayConnection { url: URL, token: String?, password: String?, + tls: GatewayTLSRoute?, deviceAuthGatewayID: String?, routeAuthority: UInt64?, shutdownGeneration: UInt64) -> GatewayChannelActor? @@ -954,6 +958,7 @@ extension GatewayConnection { self.configuredURL == url, self.configuredToken == token, self.configuredPassword == password, + GatewayTLSRoute.hasSameConnectionIdentity(self.configuredTLS, tls), self.configuredDeviceAuthGatewayID == deviceAuthGatewayID, self.configuredRouteAuthority == routeAuthority else { return nil } @@ -1120,6 +1125,7 @@ extension GatewayConnection { let socketGeneration = activeSocketGeneration, controlUiRouteIsLive( config: config, + tls: endpoint.tls, routeAuthority: endpoint.routeAuthority, deviceAuthGatewayID: endpoint.deviceAuthGatewayID, client: client, @@ -1127,6 +1133,7 @@ extension GatewayConnection { await client.currentConnectionGeneration() == socketGeneration, controlUiRouteIsLive( config: config, + tls: endpoint.tls, routeAuthority: endpoint.routeAuthority, deviceAuthGatewayID: endpoint.deviceAuthGatewayID, client: client, @@ -1135,6 +1142,7 @@ extension GatewayConnection { ifCurrentConnectionGeneration: socketGeneration), controlUiRouteIsLive( config: config, + tls: endpoint.tls, routeAuthority: endpoint.routeAuthority, deviceAuthGatewayID: endpoint.deviceAuthGatewayID, client: client, @@ -1166,6 +1174,7 @@ extension GatewayConnection { private func controlUiRouteIsLive( config: Config, + tls: GatewayTLSRoute?, routeAuthority: UInt64?, deviceAuthGatewayID: String?, client: GatewayChannelActor, @@ -1174,6 +1183,7 @@ extension GatewayConnection { self.configuredURL == config.url && self.configuredToken == config.token && self.configuredPassword == config.password && + GatewayTLSRoute.hasSameConnectionIdentity(self.configuredTLS, tls) && self.configuredRouteAuthority == routeAuthority && self.configuredDeviceAuthGatewayID == deviceAuthGatewayID && self.configuredShutdownGeneration == self.shutdownGeneration && diff --git a/apps/macos/Sources/OpenClaw/GatewayConnectionSupport.swift b/apps/macos/Sources/OpenClaw/GatewayConnectionSupport.swift new file mode 100644 index 000000000000..863db342256f --- /dev/null +++ b/apps/macos/Sources/OpenClaw/GatewayConnectionSupport.swift @@ -0,0 +1,65 @@ +import CryptoKit +import Foundation +import Security + +struct GatewayRouteChangedAfterDispatchError: LocalizedError, Sendable { + let method: String + + var errorDescription: String? { + "The Gateway route changed after \(self.method) was sent. Its result is unknown; refresh before retrying." + } +} + +enum GatewayActivationBindingKeyStore { + private static let service = "ai.openclaw.onboarding-route-binding" + private static let account = "credential-binding-v1" + private static let byteCount = 32 + + static func loadOrCreate() -> SymmetricKey? { + if let data = load() { + return SymmetricKey(data: data) + } + + var data = Data(count: byteCount) + let randomStatus = data.withUnsafeMutableBytes { bytes in + guard let baseAddress = bytes.baseAddress else { return errSecAllocate } + return SecRandomCopyBytes(kSecRandomDefault, self.byteCount, baseAddress) + } + guard randomStatus == errSecSuccess else { return nil } + + var query = self.baseQuery + query[kSecValueData as String] = data + query[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly + let addStatus = SecItemAdd(query as CFDictionary, nil) + if addStatus == errSecSuccess { + return SymmetricKey(data: data) + } + // Another process can win the first-launch create race. Only accept the + // secret after reading the Keychain item back through normal ACL checks. + if addStatus == errSecDuplicateItem, let existing = load() { + return SymmetricKey(data: existing) + } + return nil + } + + private static func load() -> Data? { + var query = self.baseQuery + query[kSecReturnData as String] = true + query[kSecMatchLimit as String] = kSecMatchLimitOne + var result: CFTypeRef? + guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess, + let data = result as? Data, + data.count == byteCount + else { return nil } + return data + } + + private static var baseQuery: [String: Any] { + [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + kSecAttrSynchronizable as String: false, + ] + } +} diff --git a/apps/macos/Sources/OpenClaw/GatewayEndpointStore.swift b/apps/macos/Sources/OpenClaw/GatewayEndpointStore.swift index 1c52b9e39948..1c3abb6f7bf8 100644 --- a/apps/macos/Sources/OpenClaw/GatewayEndpointStore.swift +++ b/apps/macos/Sources/OpenClaw/GatewayEndpointStore.swift @@ -77,6 +77,7 @@ actor GatewayEndpointStore { let bindMode: String? let remoteTransport: SourceTransport let directRemoteURL: URL? + let remoteTLSFingerprint: String? /// Invalidates a suspended SSH lookup when its desired route changes. let sshRouteIdentity: SSHRouteIdentity? } @@ -366,12 +367,13 @@ actor GatewayEndpointStore { } let port = deps.localPort() + let root = OpenClawConfigFile.loadDict() let bind = GatewayEndpointStore.resolveGatewayBindMode( - root: OpenClawConfigFile.loadDict(), + root: root, env: ProcessInfo.processInfo.environment) - let customBindHost = GatewayEndpointStore.resolveGatewayCustomBindHost(root: OpenClawConfigFile.loadDict()) + let customBindHost = GatewayEndpointStore.resolveGatewayCustomBindHost(root: root) let scheme = GatewayEndpointStore.resolveGatewayScheme( - root: OpenClawConfigFile.loadDict(), + root: root, env: ProcessInfo.processInfo.environment) let host = GatewayEndpointStore.resolveLocalGatewayHost( bindMode: bind, @@ -396,6 +398,10 @@ actor GatewayEndpointStore { routeRevision: self.endpointRevision) self.resolvedEndpoint = GatewayConnection.EndpointSnapshot( config: (url, token, password), + tls: GatewayTLSRoute.resolve( + url: url, + connectionMode: .local, + configuredFingerprint: nil), routeAuthority: nil, deviceAuthGatewayID: deviceAuthGatewayID, revision: self.endpointRevision) @@ -484,11 +490,16 @@ actor GatewayEndpointStore { case .local: self.cancelRemoteEnsure() guard await self.sourceIsCurrent(source, generation: generation) else { return } + let url = URL(string: "\(source.scheme)://\(source.localHost):\(source.localPort)")! self.setReady( mode: .local, - url: URL(string: "\(source.scheme)://\(source.localHost):\(source.localPort)")!, + url: url, token: source.token, password: source.password, + tls: GatewayTLSRoute.resolve( + url: url, + connectionMode: .local, + configuredFingerprint: nil), deviceAuthGatewayID: source.deviceAuthGatewayID, routeAuthority: nil) case .remote: @@ -507,6 +518,10 @@ actor GatewayEndpointStore { url: url, token: source.token, password: source.password, + tls: GatewayTLSRoute.resolve( + url: url, + connectionMode: .remote, + configuredFingerprint: source.remoteTLSFingerprint), deviceAuthGatewayID: source.deviceAuthGatewayID, routeAuthority: nil) return @@ -520,11 +535,16 @@ actor GatewayEndpointStore { } guard await self.sourceIsCurrent(source, generation: generation) else { return } self.cancelRemoteEnsure() + let url = URL(string: "\(source.scheme)://127.0.0.1:\(Int(route.localPort))")! self.setReady( mode: .remote, - url: URL(string: "\(source.scheme)://127.0.0.1:\(Int(route.localPort))")!, + url: url, token: source.token, password: source.password, + tls: GatewayTLSRoute.resolve( + url: url, + connectionMode: .remote, + configuredFingerprint: source.remoteTLSFingerprint), deviceAuthGatewayID: source.deviceAuthGatewayID, routeAuthority: route.generation) case .unconfigured: @@ -679,6 +699,10 @@ actor GatewayEndpointStore { url: url, token: source.token, password: source.password, + tls: GatewayTLSRoute.resolve( + url: url, + connectionMode: .remote, + configuredFingerprint: source.remoteTLSFingerprint), deviceAuthGatewayID: source.deviceAuthGatewayID, routeAuthority: nil) } @@ -745,6 +769,10 @@ actor GatewayEndpointStore { url: url, token: source.token, password: source.password, + tls: GatewayTLSRoute.resolve( + url: url, + connectionMode: .remote, + configuredFingerprint: source.remoteTLSFingerprint), deviceAuthGatewayID: source.deviceAuthGatewayID, routeAuthority: route.generation) } @@ -815,6 +843,7 @@ actor GatewayEndpointStore { url: URL, token: String?, password: String?, + tls: GatewayTLSRoute?, deviceAuthGatewayID: String?, routeAuthority: UInt64?) -> GatewayConnection.EndpointSnapshot { @@ -822,6 +851,7 @@ actor GatewayEndpointStore { endpoint.config.url != url || endpoint.config.token != token || endpoint.config.password != password || + !GatewayTLSRoute.hasSameConnectionIdentity(endpoint.tls, tls) || endpoint.deviceAuthGatewayID != deviceAuthGatewayID || endpoint.routeAuthority != routeAuthority } ?? true @@ -830,6 +860,7 @@ actor GatewayEndpointStore { } let endpoint = GatewayConnection.EndpointSnapshot( config: (url, token, password), + tls: tls, routeAuthority: routeAuthority, deviceAuthGatewayID: deviceAuthGatewayID, revision: self.endpointRevision) @@ -842,7 +873,9 @@ actor GatewayEndpointStore { routeRevision: self.endpointRevision)) return endpoint } +} +extension GatewayEndpointStore { func maybeFallbackToTailnet(from currentURL: URL) async -> GatewayConnection.EndpointSnapshot? { guard let expectedEndpoint = resolvedEndpoint, expectedEndpoint.config.url == currentURL @@ -878,6 +911,10 @@ actor GatewayEndpointStore { url: url, token: source.token, password: source.password, + tls: GatewayTLSRoute.resolve( + url: url, + connectionMode: .local, + configuredFingerprint: nil), deviceAuthGatewayID: source.deviceAuthGatewayID, routeAuthority: nil) return self.resolvedEndpoint @@ -980,6 +1017,7 @@ extension GatewayEndpointStore { bindMode: bindMode, remoteTransport: SourceTransport(remoteResolution.transport), directRemoteURL: remoteResolution.directURL, + remoteTLSFingerprint: isRemote ? GatewayRemoteConfig.resolveTLSFingerprint(root: root) : nil, sshRouteIdentity: sshRouteIdentity) let selectionIsCurrent = await generationIsCurrent(app.generation) guard selectionIsCurrent, !Task.isCancelled else { @@ -997,6 +1035,7 @@ extension GatewayEndpointStore { bindMode: source.bindMode, remoteTransport: .ssh, directRemoteURL: nil, + remoteTLSFingerprint: nil, sshRouteIdentity: nil) } return source diff --git a/apps/macos/Sources/OpenClaw/GatewayTLSRoute.swift b/apps/macos/Sources/OpenClaw/GatewayTLSRoute.swift new file mode 100644 index 000000000000..eab05da579bd --- /dev/null +++ b/apps/macos/Sources/OpenClaw/GatewayTLSRoute.swift @@ -0,0 +1,140 @@ +import Foundation +import OpenClawKit + +struct GatewayTLSRoute: Equatable, Sendable { + let params: GatewayTLSParams + let allowsTrustedPinReplacement: Bool + + static func resolve( + url: URL, + connectionMode: AppState.ConnectionMode, + configuredFingerprint: String?, + storeKey: String? = nil) -> GatewayTLSRoute? + { + guard url.scheme?.lowercased() == "wss" else { return nil } + let storeKey = storeKey ?? self.storeKey(for: url) + let stored = GatewayTLSStore.loadFingerprint(stableID: storeKey) + return self.resolve( + url: url, + connectionMode: connectionMode, + configuredFingerprint: configuredFingerprint, + storedFingerprint: stored, + storeKey: storeKey) + } + + static func resolve( + url: URL, + connectionMode: AppState.ConnectionMode, + configuredFingerprint: String?, + storedFingerprint: String?, + storeKey: String? = nil) -> GatewayTLSRoute? + { + guard url.scheme?.lowercased() == "wss" else { return nil } + let storeKey = storeKey ?? self.storeKey(for: url) + let configured = connectionMode == .remote + ? configuredFingerprint?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty + : nil + let expected = configured ?? storedFingerprint + return GatewayTLSRoute( + params: GatewayTLSParams( + required: true, + expectedFingerprint: expected, + allowTOFU: expected == nil, + storeKey: storeKey), + allowsTrustedPinReplacement: configured == nil) + } + + static func storeKey(for url: URL) -> String { + let host = url.host?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty ?? "gateway" + return "\(host):\(url.port ?? 443)" + } + + static func hasSameConnectionIdentity( + _ lhs: GatewayTLSRoute?, + _ rhs: GatewayTLSRoute?) -> Bool + { + switch (lhs, rhs) { + case (nil, nil): + true + case let (lhs?, rhs?): + lhs.hasSameConnectionIdentity(as: rhs) + default: + false + } + } + + func hasSameConnectionIdentity(as other: GatewayTLSRoute) -> Bool { + if self == other { + return true + } + guard self.params.required == other.params.required, + self.params.storeKey == other.params.storeKey, + self.allowsTrustedPinReplacement, + other.allowsTrustedPinReplacement + else { return false } + + let firstUseRoute: GatewayTLSRoute + let persistedRoute: GatewayTLSRoute + if self.params.allowTOFU, self.params.expectedFingerprint == nil { + firstUseRoute = self + persistedRoute = other + } else if other.params.allowTOFU, other.params.expectedFingerprint == nil { + firstUseRoute = other + persistedRoute = self + } else { + return false + } + guard firstUseRoute.params.storeKey == persistedRoute.params.storeKey, + !persistedRoute.params.allowTOFU, + let storeKey = persistedRoute.params.storeKey, + let expectedFingerprint = persistedRoute.params.expectedFingerprint + else { return false } + return GatewayTLSStore.claimedFirstUseFingerprint(stableID: storeKey) == expectedFingerprint + } + + func permitsTrustedPinReplacement( + url: URL, + failure: GatewayTLSValidationFailure) -> Bool + { + let routeHost = url.host?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased().nonEmpty + let challengedHost = failure.host.trimmingCharacters(in: .whitespacesAndNewlines).lowercased().nonEmpty + guard self.allowsTrustedPinReplacement, + failure.kind == .pinMismatch, + failure.systemTrustOk, + url.scheme?.lowercased() == "wss", + failure.storeKey == self.params.storeKey, + let routeHost, + challengedHost == routeHost, + failure.port == (url.port ?? 443) + else { return false } + + return LoopbackHost.isLoopback(routeHost) || routeHost == "ts.net" || routeHost.hasSuffix(".ts.net") + } +} + +actor GatewayTLSRepairCoordinator { + static let shared = GatewayTLSRepairCoordinator() + + func repair( + route: GatewayTLSRoute?, + url: URL, + failure: GatewayTLSValidationFailure) -> Bool + { + guard let route, + route.permitsTrustedPinReplacement(url: url, failure: failure), + let storeKey = failure.storeKey, + let observedFingerprint = failure.observedFingerprint + else { return false } + + if GatewayTLSStore.loadFingerprint(stableID: storeKey) == observedFingerprint { + return true + } + guard route.params.expectedFingerprint != nil, + let failedFingerprint = failure.expectedFingerprint + else { return false } + return GatewayTLSStore.replaceFingerprint( + observedFingerprint, + ifCurrent: failedFingerprint, + stableID: storeKey) + } +} diff --git a/apps/macos/Sources/OpenClaw/MacGatewayProfiles.swift b/apps/macos/Sources/OpenClaw/MacGatewayProfiles.swift index 33296258690d..626495cbb7f7 100644 --- a/apps/macos/Sources/OpenClaw/MacGatewayProfiles.swift +++ b/apps/macos/Sources/OpenClaw/MacGatewayProfiles.swift @@ -1,5 +1,6 @@ import CryptoKit import Foundation +import OpenClawKit import Security struct MacGatewayProfile: Codable, Equatable, Identifiable, Sendable { @@ -105,6 +106,7 @@ actor MacGatewayProfileStore { url: url, token: stored.credentials.token, password: stored.credentials.password), + tls: Self.tlsRoute(for: stored.profile), routeAuthority: nil, deviceAuthGatewayID: stored.profile.id) } @@ -164,6 +166,14 @@ actor MacGatewayProfileStore { return "manual-" + digest.prefix(16).map { String(format: "%02x", $0) }.joined() } + static func tlsRoute(for profile: MacGatewayProfile) -> GatewayTLSRoute? { + GatewayTLSRoute.resolve( + url: profile.url, + connectionMode: .remote, + configuredFingerprint: nil, + storeKey: "profile:\(profile.id)") + } + static func resolvedCredentials( saved: Credentials?, submittedToken: String?, diff --git a/apps/macos/Sources/OpenClaw/NodeMode/MacNodeModeCoordinator.swift b/apps/macos/Sources/OpenClaw/NodeMode/MacNodeModeCoordinator.swift index 317ac6b020b8..be5457d559bd 100644 --- a/apps/macos/Sources/OpenClaw/NodeMode/MacNodeModeCoordinator.swift +++ b/apps/macos/Sources/OpenClaw/NodeMode/MacNodeModeCoordinator.swift @@ -56,7 +56,7 @@ final class MacNodeModeCoordinator: NSObject { let routeAuthorityGeneration: UInt64 let codexThreadCatalogAdvertised: Bool let claudeSessionCatalogAdvertised: Bool - let config: GatewayConnection.Config + let endpoint: GatewayConnection.EndpointSnapshot let options: GatewayConnectOptions let sessionBox: WebSocketSessionBox? let fallbackMainSessionKey: String @@ -100,7 +100,7 @@ final class MacNodeModeCoordinator: NSObject { private var endpointAttemptGeneration: UInt64 = 0 private var routeAuthorityGeneration: UInt64 = 0 private var completedRouteAuthorityGeneration: UInt64 = 0 - private var pendingEndpointConfig: GatewayConnection.Config? + private var pendingEndpoint: GatewayConnection.EndpointSnapshot? private var lastObservedPaused: Bool private var lastObservedComputerControlEnabled: Bool private let runtime: MacNodeRuntime @@ -111,7 +111,6 @@ final class MacNodeModeCoordinator: NSObject { private let routeInvalidationHook: (@Sendable () async -> Void)? private let refreshEvents: AsyncStream private let refreshContinuation: AsyncStream.Continuation - private var autoRepairedTLSFingerprintsByStoreKey: [String: String] = [:] private var tlsSessionCache = MacNodeGatewayTLSSessionCache() override private convenience init() { @@ -209,7 +208,7 @@ final class MacNodeModeCoordinator: NSObject { for await state in states { guard let self else { return } let initialStateMissedAttempt = previousState == nil && - self.pendingEndpointConfig.map { !Self.endpointState(state, matches: $0) } == true + self.pendingEndpoint.map { !Self.endpointState(state, matches: $0) } == true let endpointChanged = previousState.map { Self.endpointTransitionRequiresDisconnect(from: $0, to: state) } ?? false @@ -398,12 +397,12 @@ final class MacNodeModeCoordinator: NSObject { let codexThreadCatalogEnabled = MacNodeCodexThreadCatalog.shouldAdvertise() let claudeSessionCatalogEnabled = MacNodeClaudeSessionCatalog.shouldAdvertise() - var attemptedURL: URL? + var attemptedEndpoint: GatewayConnection.EndpointSnapshot? do { let endpointAttemptGeneration = self.endpointAttemptGeneration let routeAuthorityGeneration = self.routeAuthorityGeneration - let config = try await GatewayEndpointStore.shared.requireConfig() - self.pendingEndpointConfig = config + let endpoint = try await GatewayEndpointStore.shared.requireEndpoint() + self.pendingEndpoint = endpoint guard Self.endpointAttemptIsCurrent( capturedGeneration: endpointAttemptGeneration, currentGeneration: self.endpointAttemptGeneration), @@ -413,9 +412,9 @@ final class MacNodeModeCoordinator: NSObject { completedRouteAuthorityGeneration: self.completedRouteAuthorityGeneration, isPaused: false) else { continue } - attemptedURL = config.url + attemptedEndpoint = endpoint guard let attempt = try await self.prepareConnectionAttempt( - config: config, + endpoint: endpoint, endpointGeneration: endpointAttemptGeneration, routeAuthorityGeneration: routeAuthorityGeneration, browserControlEnabled: browserControlEnabled, @@ -432,7 +431,14 @@ final class MacNodeModeCoordinator: NSObject { // actually change instead of rereading config and TCC state every second. guard await refreshIterator.next() != nil else { return } } catch { - if await self.autoRepairStaleTLSPinIfNeeded(error: error, url: attemptedURL) { + if let tlsError = error as? GatewayTLSValidationError, + let attemptedEndpoint, + await GatewayTLSRepairCoordinator.shared.repair( + route: attemptedEndpoint.tls, + url: attemptedEndpoint.config.url, + failure: tlsError.failure) + { + await self.session.disconnect() retryDelay = 1_000_000_000 continue } @@ -444,7 +450,7 @@ final class MacNodeModeCoordinator: NSObject { } private func prepareConnectionAttempt( - config: GatewayConnection.Config, + endpoint: GatewayConnection.EndpointSnapshot, endpointGeneration: UInt64, routeAuthorityGeneration: UInt64, browserControlEnabled: Bool, @@ -452,6 +458,7 @@ final class MacNodeModeCoordinator: NSObject { codexThreadCatalogEnabled: Bool, claudeSessionCatalogEnabled: Bool) async throws -> ConnectionAttempt? { + let config = endpoint.config let workerManifest = try await self.startNodeHostWorkerIfConfigured() let nativeCaps = self.currentCaps( browserControlEnabled: browserControlEnabled, @@ -492,21 +499,19 @@ final class MacNodeModeCoordinator: NSObject { clientMode: "node", clientDisplayName: InstanceIdentity.displayName, deviceIdentityProfile: Self.nodeIdentityProfile) - let sessionBox = self.buildSessionBox( - url: config.url, - connectionMode: AppStateStore.shared.connectionMode) + let sessionBox = self.buildSessionBox(url: config.url, tls: endpoint.tls) // Resolve compatibility fallback before node admission. Operator recovery // here cannot block the node lifecycle callback or its successor cleanup. let fallbackMainSessionKey = await GatewayConnection.shared.refreshMainSessionKey() - let currentConfig = try await GatewayEndpointStore.shared.requireConfig() + let currentEndpoint = try await GatewayEndpointStore.shared.requireEndpoint() guard Self.endpointAttemptCanConnect( capturedGeneration: endpointGeneration, currentGeneration: self.endpointAttemptGeneration, isCancelled: Task.isCancelled, isPaused: AppStateStore.shared.isPaused, - capturedConfig: config, - currentConfig: currentConfig), + capturedEndpoint: endpoint, + currentEndpoint: currentEndpoint), Self.routeAuthorityAllowsInvoke( capturedRouteAuthorityGeneration: routeAuthorityGeneration, currentRouteAuthorityGeneration: self.routeAuthorityGeneration, @@ -521,7 +526,7 @@ final class MacNodeModeCoordinator: NSObject { MacNodeCodexThreadCatalogContract.listCommand), claudeSessionCatalogAdvertised: commands.contains( MacNodeClaudeSessionCatalogContract.listCommand), - config: config, + endpoint: endpoint, options: options, sessionBox: sessionBox, fallbackMainSessionKey: fallbackMainSessionKey) @@ -529,10 +534,10 @@ final class MacNodeModeCoordinator: NSObject { private func connect(_ attempt: ConnectionAttempt) async throws { try await self.session.connect( - url: attempt.config.url, + url: attempt.endpoint.config.url, credentials: GatewayNodeSessionCredentials( - token: attempt.config.token, - password: attempt.config.password), + token: attempt.endpoint.config.token, + password: attempt.endpoint.config.password), connectOptions: attempt.options, sessionBox: attempt.sessionBox, onConnected: { [weak self] in @@ -647,14 +652,14 @@ final class MacNodeModeCoordinator: NSObject { } private func validatePostConnect(_ attempt: ConnectionAttempt) async throws -> Bool { - let postConnectConfig = try await GatewayEndpointStore.shared.requireConfig() + let postConnectEndpoint = try await GatewayEndpointStore.shared.requireEndpoint() guard Self.endpointAttemptCanConnect( capturedGeneration: attempt.endpointGeneration, currentGeneration: self.endpointAttemptGeneration, isCancelled: Task.isCancelled, isPaused: AppStateStore.shared.isPaused, - capturedConfig: attempt.config, - currentConfig: postConnectConfig) + capturedEndpoint: attempt.endpoint, + currentEndpoint: postConnectEndpoint) else { if Self.stalePostConnectRequiresDisconnect( capturedRouteAuthorityGeneration: attempt.routeAuthorityGeneration, @@ -662,8 +667,8 @@ final class MacNodeModeCoordinator: NSObject { completedRouteAuthorityGeneration: self.completedRouteAuthorityGeneration, isCancelled: Task.isCancelled, isPaused: AppStateStore.shared.isPaused, - capturedConfig: attempt.config, - currentConfig: postConnectConfig) + capturedEndpoint: attempt.endpoint, + currentEndpoint: postConnectEndpoint) { await self.session.disconnect() } @@ -792,82 +797,12 @@ final class MacNodeModeCoordinator: NSObject { return try await nodeHostWorker.start(command: [executable, "node", "worker"]) } - nonisolated static func tlsPinStoreKey(for url: URL) -> String { - let host = url.host?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty ?? "gateway" - let port = url.port ?? 443 - return "\(host):\(port)" - } - - nonisolated static func shouldAutoRepairStaleTLSPin(url: URL, failure: GatewayTLSValidationFailure) -> Bool { - guard failure.kind == .pinMismatch else { return false } - guard url.scheme?.lowercased() == "wss" else { return false } - guard failure.storeKey == nil || failure.storeKey == self.tlsPinStoreKey(for: url) else { return false } - guard let host = url.host?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased(), !host.isEmpty - else { return false } - - if LoopbackHost.isLoopback(host) { - return failure.systemTrustOk - } - - // Tailscale Serve uses publicly trusted, rotating certificates for *.ts.net names. - // A stale legacy leaf pin should not leave the companion app half-connected forever. - if host == "ts.net" || host.hasSuffix(".ts.net") { - return failure.systemTrustOk - } - - return false - } - - private func autoRepairStaleTLSPinIfNeeded(error: Error, url: URL?) async -> Bool { - guard let tlsError = error as? GatewayTLSValidationError, let url else { return false } - guard Self.shouldAutoRepairStaleTLSPin(url: url, failure: tlsError.failure) else { return false } - let storeKey = tlsError.failure.storeKey ?? Self.tlsPinStoreKey(for: url) - guard let observedFingerprint = tlsError.failure.observedFingerprint else { return false } - guard self.autoRepairedTLSFingerprintsByStoreKey[storeKey] != observedFingerprint else { return false } - - guard GatewayTLSStore.replaceFingerprint(observedFingerprint, stableID: storeKey) else { return false } - self.autoRepairedTLSFingerprintsByStoreKey[storeKey] = observedFingerprint - self.logger.info("replaced stale gateway TLS pin storeKey=\(storeKey, privacy: .public)") - await self.session.disconnect() - return true - } - - 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 { + private func buildSessionBox(url: URL, tls: GatewayTLSRoute?) -> WebSocketSessionBox? { + guard let tls else { self.tlsSessionCache.invalidate() return nil } - let stableID = Self.tlsPinStoreKey(for: url) - let stored = GatewayTLSStore.loadFingerprint(stableID: stableID) - guard let params = Self.tlsParams( - for: url, - connectionMode: connectionMode, - root: OpenClawConfigFile.loadDict(), - storedFingerprint: stored) - else { - self.tlsSessionCache.invalidate() - return nil - } - return self.tlsSessionCache.sessionBox(url: url, params: params) + return self.tlsSessionCache.sessionBox(url: url, params: tls.params) } } @@ -902,10 +837,13 @@ extension MacNodeModeCoordinator { nonisolated static func endpointState( _ state: GatewayEndpointState, - matches config: GatewayConnection.Config) -> Bool + matches endpoint: GatewayConnection.EndpointSnapshot) -> Bool { - guard case let .ready(_, url, token, password, _) = state else { return false } - return url == config.url && token == config.token && password == config.password + guard case let .ready(_, url, token, password, routeRevision) = state else { return false } + return url == endpoint.config.url && + token == endpoint.config.token && + password == endpoint.config.password && + routeRevision == endpoint.revision } nonisolated static func endpointAttemptCanConnect( @@ -913,15 +851,13 @@ extension MacNodeModeCoordinator { currentGeneration: UInt64, isCancelled: Bool, isPaused: Bool, - capturedConfig: GatewayConnection.Config, - currentConfig: GatewayConnection.Config) -> Bool + capturedEndpoint: GatewayConnection.EndpointSnapshot, + currentEndpoint: GatewayConnection.EndpointSnapshot) -> Bool { capturedGeneration == currentGeneration && !isCancelled && !isPaused && - capturedConfig.url == currentConfig.url && - capturedConfig.token == currentConfig.token && - capturedConfig.password == currentConfig.password + self.sameEndpoint(capturedEndpoint, currentEndpoint) } nonisolated static func routeAuthorityAllowsInvoke( @@ -955,16 +891,27 @@ extension MacNodeModeCoordinator { completedRouteAuthorityGeneration: UInt64, isCancelled: Bool, isPaused: Bool, - capturedConfig: GatewayConnection.Config, - currentConfig: GatewayConnection.Config) -> Bool + capturedEndpoint: GatewayConnection.EndpointSnapshot, + currentEndpoint: GatewayConnection.EndpointSnapshot) -> Bool { capturedRouteAuthorityGeneration != currentRouteAuthorityGeneration || currentRouteAuthorityGeneration != completedRouteAuthorityGeneration || isCancelled || isPaused || - capturedConfig.url != currentConfig.url || - capturedConfig.token != currentConfig.token || - capturedConfig.password != currentConfig.password + !self.sameEndpoint(capturedEndpoint, currentEndpoint) + } + + private nonisolated static func sameEndpoint( + _ lhs: GatewayConnection.EndpointSnapshot, + _ rhs: GatewayConnection.EndpointSnapshot) -> Bool + { + lhs.config.url == rhs.config.url && + lhs.config.token == rhs.config.token && + lhs.config.password == rhs.config.password && + GatewayTLSRoute.hasSameConnectionIdentity(lhs.tls, rhs.tls) && + lhs.routeAuthority == rhs.routeAuthority && + lhs.deviceAuthGatewayID == rhs.deviceAuthGatewayID && + lhs.revision == rhs.revision } private static func effectiveEndpoint(from state: GatewayEndpointState) -> EffectiveEndpoint? { diff --git a/apps/macos/Tests/OpenClawIPCTests/GatewayConnectionControlTests.swift b/apps/macos/Tests/OpenClawIPCTests/GatewayConnectionControlTests.swift index b27b4b0bc57a..735dd4c0f446 100644 --- a/apps/macos/Tests/OpenClawIPCTests/GatewayConnectionControlTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/GatewayConnectionControlTests.swift @@ -133,6 +133,27 @@ private final class WebSocketMessageRecorder: @unchecked Sendable { } } +private final class GatewayConnectionEndpointSource: @unchecked Sendable { + private let lock = NSLock() + private var endpoint: GatewayConnection.EndpointSnapshot + + init(endpoint: GatewayConnection.EndpointSnapshot) { + self.endpoint = endpoint + } + + func setEndpoint(_ endpoint: GatewayConnection.EndpointSnapshot) { + lock.lock() + self.endpoint = endpoint + lock.unlock() + } + + func snapshot() -> GatewayConnection.EndpointSnapshot { + lock.lock() + defer { self.lock.unlock() } + return endpoint + } +} + private final class GatewayConnectionRouteConfigSource: @unchecked Sendable { private let lock = NSLock() private var url: URL @@ -326,6 +347,43 @@ private func makeTestGatewayConnection() -> (GatewayConnection, FakeWebSocketSes #expect(GatewayConnection.wizardCancellationOutcome(after: URLError(.timedOut)) == .unresolved) } + @Test func `operator connection rebuilds when direct TLS pin changes`() async throws { + let url = try #require(URL(string: "wss://gateway.example.invalid")) + let firstFingerprint = String(repeating: "a", count: 64) + let secondFingerprint = String(repeating: "b", count: 64) + let firstTLS = try #require(GatewayTLSRoute.resolve( + url: url, + connectionMode: .remote, + configuredFingerprint: firstFingerprint, + storedFingerprint: nil)) + let secondTLS = try #require(GatewayTLSRoute.resolve( + url: url, + connectionMode: .remote, + configuredFingerprint: secondFingerprint, + storedFingerprint: nil)) + let source = GatewayConnectionEndpointSource(endpoint: GatewayConnection.EndpointSnapshot( + config: (url: url, token: "token", password: nil), + tls: firstTLS, + routeAuthority: nil, + revision: 1)) + let connection = GatewayConnection(endpointProvider: { source.snapshot() }) + + try await connection.refresh() + let firstGeneration = await connection._test_routeGeneration() + #expect(await connection.configuredTLSFingerprintSHA256() == firstFingerprint) + + source.setEndpoint(GatewayConnection.EndpointSnapshot( + config: (url: url, token: "token", password: nil), + tls: secondTLS, + routeAuthority: nil, + revision: 2)) + try await connection.refresh() + + #expect(await connection._test_routeGeneration() > firstGeneration) + #expect(await connection.configuredTLSFingerprintSHA256() == secondFingerprint) + await connection.shutdown() + } + @Test func `direct endpoint never receives another route device token`() async throws { let urlA = try #require(URL(string: "wss://gateway-a.example")) let urlB = try #require(URL(string: "wss://gateway-b.example")) diff --git a/apps/macos/Tests/OpenClawIPCTests/GatewayEndpointStoreTests.swift b/apps/macos/Tests/OpenClawIPCTests/GatewayEndpointStoreTests.swift index 6c6f2c6671bf..d5b105a14de6 100644 --- a/apps/macos/Tests/OpenClawIPCTests/GatewayEndpointStoreTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/GatewayEndpointStoreTests.swift @@ -2,6 +2,7 @@ import ConcurrencyExtras import Foundation import Testing @testable import OpenClaw +@testable import OpenClawKit private actor GatewayEndpointSourceGate { private var current: GatewayEndpointStore.SourceSnapshot @@ -176,6 +177,7 @@ struct GatewayEndpointStoreTests { bindMode: String? = "loopback", transport: AppState.RemoteTransport = .ssh, directURL: URL? = nil, + tlsFingerprint: String? = nil, deviceAuthGatewayID: String = "test-gateway-route", routingGeneration: UInt64? = nil) -> GatewayEndpointStore.SourceSnapshot { @@ -191,6 +193,7 @@ struct GatewayEndpointStoreTests { bindMode: bindMode, remoteTransport: .init(transport), directRemoteURL: directURL, + remoteTLSFingerprint: tlsFingerprint, sshRouteIdentity: mode == .remote && transport == .ssh ? .init( target: "user@gateway.example", @@ -611,6 +614,75 @@ extension GatewayEndpointStoreTests { } } + @Test func `remote TLS fingerprint changes advance endpoint revision`() async throws { + try await TestIsolation.withUserDefaultsValues([connectionModeKey: "unconfigured"]) { + let url = try #require(URL(string: "wss://gateway.example.invalid")) + let sourceA = self.source( + mode: .remote, + transport: .direct, + directURL: url, + tlsFingerprint: String(repeating: "a", count: 64)) + let sourceGate = GatewayEndpointSourceGate(sourceA) + let store = GatewayEndpointStore(deps: .init( + token: { nil }, + password: { nil }, + localPort: { 18789 }, + remoteRouteIfRunning: { nil }, + remoteRouteIsCurrent: { _ in true }, + canStartRemoteTunnel: { true }, + ensureRemoteTunnel: { throw CancellationError() }, + routingGenerationIsCurrent: { _ in true }, + sourceSnapshot: { await sourceGate.snapshot() })) + + let first = try await store.requireEndpoint() + await sourceGate.update(self.source( + mode: .remote, + transport: .direct, + directURL: url, + tlsFingerprint: String(repeating: "b", count: 64))) + let second = try await store.requireEndpoint() + + let firstRevision = try #require(first.revision) + let secondRevision = try #require(second.revision) + #expect(first.tls?.params.expectedFingerprint == String(repeating: "a", count: 64)) + #expect(second.tls?.params.expectedFingerprint == String(repeating: "b", count: 64)) + #expect(secondRevision > firstRevision) + } + } + + @Test func `persisting active first use pin keeps endpoint revision stable`() async throws { + try await withFakeGatewayTLSKeychain { + try await TestIsolation.withUserDefaultsValues([connectionModeKey: "unconfigured"]) { + let url = try #require(URL(string: "wss://gateway.example.invalid")) + let storeKey = GatewayTLSRoute.storeKey(for: url) + let source = self.source( + mode: .remote, + transport: .direct, + directURL: url) + let store = GatewayEndpointStore(deps: .init( + token: { nil }, + password: { nil }, + localPort: { 18789 }, + remoteRouteIfRunning: { nil }, + remoteRouteIsCurrent: { _ in true }, + canStartRemoteTunnel: { true }, + ensureRemoteTunnel: { throw CancellationError() }, + routingGenerationIsCurrent: { _ in true }, + sourceSnapshot: { source })) + + let first = try await store.requireEndpoint() + let fingerprint = String(repeating: "a", count: 64) + _ = GatewayTLSStore.claimFirstUseFingerprint(fingerprint, stableID: storeKey) + let second = try await store.requireEndpoint() + + #expect(first.revision == second.revision) + #expect(second.tls?.params.allowTOFU == false) + #expect(second.tls?.params.expectedFingerprint == fingerprint) + #expect(GatewayTLSRoute.hasSameConnectionIdentity(first.tls, second.tls)) + } + } + } + @Test func `require endpoint rejects a source superseded by a different selection`() async throws { try await TestIsolation.withUserDefaultsValues([connectionModeKey: "unconfigured"]) { let sourceA = self.source(mode: .remote, token: "token-a", transport: .ssh) diff --git a/apps/macos/Tests/OpenClawIPCTests/GatewayTLSFakeKeychain.swift b/apps/macos/Tests/OpenClawIPCTests/GatewayTLSFakeKeychain.swift new file mode 100644 index 000000000000..08c80430fe56 --- /dev/null +++ b/apps/macos/Tests/OpenClawIPCTests/GatewayTLSFakeKeychain.swift @@ -0,0 +1,87 @@ +import Foundation +import Security +@testable import OpenClawKit + +private final class MacGatewayTLSFakeKeychain: @unchecked Sendable { + private let lock = NSLock() + private var items: [String: [String: Any]] = [:] + + var operations: GatewayTLSKeychainOperations { + GatewayTLSKeychainOperations( + copyMatching: { [self] query, result in self.copyMatching(query, result: result) }, + add: { [self] query in self.add(query) }, + update: { [self] query, updates in self.update(query, updates: updates) }, + delete: { [self] query in self.delete(query) }) + } + + private func copyMatching( + _ query: CFDictionary, + result: UnsafeMutablePointer?) -> OSStatus + { + let query = query as NSDictionary as! [String: Any] + guard let account = query[kSecAttrAccount as String] as? String else { return errSecParam } + self.lock.lock() + defer { self.lock.unlock() } + guard let item = self.items[account] else { return errSecItemNotFound } + if query[kSecReturnAttributes as String] as? Bool == true { + result?.pointee = item as CFDictionary + } else { + guard let data = item[kSecValueData as String] as? Data else { return errSecDecode } + result?.pointee = data as CFData + } + return errSecSuccess + } + + private func add(_ query: CFDictionary) -> OSStatus { + let query = query as NSDictionary as! [String: Any] + guard let account = query[kSecAttrAccount as String] as? String else { return errSecParam } + self.lock.lock() + defer { self.lock.unlock() } + guard self.items[account] == nil else { return errSecDuplicateItem } + self.items[account] = query + return errSecSuccess + } + + private func update(_ query: CFDictionary, updates: CFDictionary) -> OSStatus { + let query = query as NSDictionary as! [String: Any] + let updates = updates as NSDictionary as! [String: Any] + guard let account = query[kSecAttrAccount as String] as? String else { return errSecParam } + self.lock.lock() + defer { self.lock.unlock() } + guard var item = self.items[account] else { return errSecItemNotFound } + if let expected = query[kSecAttrGeneric as String] as? Data, + item[kSecAttrGeneric as String] as? Data != expected + { + return errSecItemNotFound + } + item.merge(updates) { _, replacement in replacement } + self.items[account] = item + return errSecSuccess + } + + private func delete(_ query: CFDictionary) -> OSStatus { + let query = query as NSDictionary as! [String: Any] + self.lock.lock() + defer { self.lock.unlock() } + guard let account = query[kSecAttrAccount as String] as? String else { + self.items.removeAll() + return errSecSuccess + } + self.items[account] = nil + return errSecSuccess + } +} + +func withFakeGatewayTLSKeychain(_ operation: () throws -> T) rethrows -> T { + let keychain = MacGatewayTLSFakeKeychain() + return try GatewayTLSStore.$keychainOperations.withValue(keychain.operations) { + try operation() + } +} + +func withFakeGatewayTLSKeychain(_ operation: () async throws -> T) async rethrows -> T { + let keychain = MacGatewayTLSFakeKeychain() + return try await GatewayTLSStore.$keychainOperations.withValue(keychain.operations) { + try await operation() + } +} diff --git a/apps/macos/Tests/OpenClawIPCTests/MacGatewayProfilesTests.swift b/apps/macos/Tests/OpenClawIPCTests/MacGatewayProfilesTests.swift index 075527ea54bb..10d3f9ffe845 100644 --- a/apps/macos/Tests/OpenClawIPCTests/MacGatewayProfilesTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/MacGatewayProfilesTests.swift @@ -26,6 +26,15 @@ struct MacGatewayProfilesTests { MacGatewayProfileStore.profileID(url: rootPath)) } + @Test func `profiles sharing an authority keep independent TLS pin owners`() throws { + let url = try #require(URL(string: "wss://studio.example")) + let first = MacGatewayProfile(id: "first", name: "First", url: url) + let second = MacGatewayProfile(id: "second", name: "Second", url: url) + + #expect(MacGatewayProfileStore.tlsRoute(for: first)?.params.storeKey == "profile:first") + #expect(MacGatewayProfileStore.tlsRoute(for: second)?.params.storeKey == "profile:second") + } + @Test func `profile URL rejects dashboard schemes`() { #expect(throws: MacGatewayProfileError.invalidURL) { try MacGatewayProfileStore.canonicalURL( diff --git a/apps/macos/Tests/OpenClawIPCTests/MacNodeModeCoordinatorTests.swift b/apps/macos/Tests/OpenClawIPCTests/MacNodeModeCoordinatorTests.swift index 5480571de509..a17e9e631f12 100644 --- a/apps/macos/Tests/OpenClawIPCTests/MacNodeModeCoordinatorTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/MacNodeModeCoordinatorTests.swift @@ -197,64 +197,74 @@ struct MacNodeModeCoordinatorTests { } @Test func `first endpoint snapshot rejects a stale captured endpoint`() throws { - let first = try GatewayConnection.Config( - url: #require(URL(string: "wss://first.example.invalid")), - token: "first-token", - password: nil) + let first = GatewayConnection.EndpointSnapshot( + config: try GatewayConnection.Config( + url: #require(URL(string: "wss://first.example.invalid")), + token: "first-token", + password: nil), + routeAuthority: nil, + revision: 1) let replacement = try GatewayEndpointState.ready( mode: .remote, url: #require(URL(string: "wss://second.example.invalid")), token: "second-token", - password: nil) + password: nil, + routeRevision: 2) #expect(!MacNodeModeCoordinator.endpointState(replacement, matches: first)) } - @Test func `stop pause and config changes revoke final connect admission`() throws { - let first = try GatewayConnection.Config( - url: #require(URL(string: "wss://first.example.invalid")), - token: "token", - password: nil) - let replacement = try GatewayConnection.Config( - url: #require(URL(string: "wss://second.example.invalid")), - token: "token", - password: nil) + @Test func `stop pause and endpoint changes revoke final connect admission`() throws { + let first = GatewayConnection.EndpointSnapshot( + config: try GatewayConnection.Config( + url: #require(URL(string: "wss://first.example.invalid")), + token: "token", + password: nil), + routeAuthority: nil, + revision: 1) + let replacement = GatewayConnection.EndpointSnapshot( + config: try GatewayConnection.Config( + url: #require(URL(string: "wss://second.example.invalid")), + token: "token", + password: nil), + routeAuthority: nil, + revision: 2) #expect(MacNodeModeCoordinator.endpointAttemptCanConnect( capturedGeneration: 4, currentGeneration: 4, isCancelled: false, isPaused: false, - capturedConfig: first, - currentConfig: first)) + capturedEndpoint: first, + currentEndpoint: first)) #expect(!MacNodeModeCoordinator.endpointAttemptCanConnect( capturedGeneration: 4, currentGeneration: 5, isCancelled: false, isPaused: false, - capturedConfig: first, - currentConfig: first)) + capturedEndpoint: first, + currentEndpoint: first)) #expect(!MacNodeModeCoordinator.endpointAttemptCanConnect( capturedGeneration: 4, currentGeneration: 4, isCancelled: true, isPaused: false, - capturedConfig: first, - currentConfig: first)) + capturedEndpoint: first, + currentEndpoint: first)) #expect(!MacNodeModeCoordinator.endpointAttemptCanConnect( capturedGeneration: 4, currentGeneration: 4, isCancelled: false, isPaused: true, - capturedConfig: first, - currentConfig: first)) + capturedEndpoint: first, + currentEndpoint: first)) #expect(!MacNodeModeCoordinator.endpointAttemptCanConnect( capturedGeneration: 4, currentGeneration: 4, isCancelled: false, isPaused: false, - capturedConfig: first, - currentConfig: replacement)) + capturedEndpoint: first, + currentEndpoint: replacement)) } @Test func `invoke admission stays bound to installed route authority`() { @@ -839,7 +849,13 @@ struct MacNodeModeCoordinatorTests { @Test func `tls pin store key uses default wss port`() throws { let url = try #require(URL(string: "wss://gateway.example.ts.net")) - #expect(MacNodeModeCoordinator.tlsPinStoreKey(for: url) == "gateway.example.ts.net:443") + #expect(GatewayTLSRoute.storeKey(for: url) == "gateway.example.ts.net:443") + } + + @Test func `tls pin store key preserves the shipped host identity`() throws { + let url = try #require(URL(string: "wss://Gateway.Example.ts.net")) + + #expect(GatewayTLSRoute.storeKey(for: url) == "Gateway.Example.ts.net:443") } @Test func `remote tls params prefer configured fingerprint over stored pin`() throws { @@ -852,28 +868,30 @@ struct MacNodeModeCoordinatorTests { ], ] - let params = try #require(MacNodeModeCoordinator.tlsParams( - for: url, + let route = try #require(GatewayTLSRoute.resolve( + url: url, connectionMode: .remote, - root: root, + configuredFingerprint: GatewayRemoteConfig.resolveTLSFingerprint(root: root), storedFingerprint: "stored")) - #expect(params.expectedFingerprint == "sha256:configured") - #expect(params.allowTOFU == false) - #expect(params.storeKey == "gateway.example.com:443") + #expect(route.params.expectedFingerprint == "sha256:configured") + #expect(route.params.allowTOFU == false) + #expect(route.params.storeKey == "gateway.example.com:443") + #expect(!route.allowsTrustedPinReplacement) } @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, + let route = try #require(GatewayTLSRoute.resolve( + url: url, connectionMode: .remote, - root: [:], + configuredFingerprint: nil, storedFingerprint: nil)) - #expect(params.expectedFingerprint == nil) - #expect(params.allowTOFU == true) + #expect(route.params.expectedFingerprint == nil) + #expect(route.params.allowTOFU == true) + #expect(route.allowsTrustedPinReplacement) } @Test func `local tls params ignore remote configured fingerprint`() throws { @@ -886,27 +904,28 @@ struct MacNodeModeCoordinatorTests { ], ] - let params = try #require(MacNodeModeCoordinator.tlsParams( - for: url, + let route = try #require(GatewayTLSRoute.resolve( + url: url, connectionMode: .local, - root: root, + configuredFingerprint: GatewayRemoteConfig.resolveTLSFingerprint(root: root), storedFingerprint: "stored-local")) - #expect(params.expectedFingerprint == "stored-local") - #expect(params.allowTOFU == false) + #expect(route.params.expectedFingerprint == "stored-local") + #expect(route.params.allowTOFU == false) + #expect(route.allowsTrustedPinReplacement) } @Test func `tls session cache reuses session box for unchanged params`() throws { let url = try #require(URL(string: "wss://gateway.example.com")) var cache = MacNodeGatewayTLSSessionCache() - let params = try #require(MacNodeModeCoordinator.tlsParams( - for: url, + let route = try #require(GatewayTLSRoute.resolve( + url: url, connectionMode: .remote, - root: ["gateway": ["remote": ["tlsFingerprint": "sha256:configured"]]], + configuredFingerprint: "sha256:configured", storedFingerprint: "stored")) - let first = cache.sessionBox(url: url, params: params) - let second = cache.sessionBox(url: url, params: params) + let first = cache.sessionBox(url: url, params: route.params) + let second = cache.sessionBox(url: url, params: route.params) #expect(ObjectIdentifier(first.session) == ObjectIdentifier(second.session)) } @@ -914,19 +933,19 @@ struct MacNodeModeCoordinatorTests { @Test func `tls session cache rebuilds session box when params change`() throws { let url = try #require(URL(string: "wss://gateway.example.com")) var cache = MacNodeGatewayTLSSessionCache() - let firstParams = try #require(MacNodeModeCoordinator.tlsParams( - for: url, + let firstRoute = try #require(GatewayTLSRoute.resolve( + url: url, connectionMode: .remote, - root: ["gateway": ["remote": ["tlsFingerprint": "sha256:configured"]]], + configuredFingerprint: "sha256:configured", storedFingerprint: "stored")) - let secondParams = try #require(MacNodeModeCoordinator.tlsParams( - for: url, + let secondRoute = try #require(GatewayTLSRoute.resolve( + url: url, connectionMode: .remote, - root: ["gateway": ["remote": ["tlsFingerprint": "sha256:rotated"]]], + configuredFingerprint: "sha256:rotated", storedFingerprint: "stored")) - let first = cache.sessionBox(url: url, params: firstParams) - let second = cache.sessionBox(url: url, params: secondParams) + let first = cache.sessionBox(url: url, params: firstRoute.params) + let second = cache.sessionBox(url: url, params: secondRoute.params) #expect(ObjectIdentifier(first.session) != ObjectIdentifier(second.session)) } @@ -939,9 +958,43 @@ struct MacNodeModeCoordinatorTests { storeKey: "gateway.example.ts.net:443", expectedFingerprint: "old", observedFingerprint: "new", - systemTrustOk: true) + systemTrustOk: true, + port: 443) + let route = try #require(GatewayTLSRoute.resolve( + url: url, + connectionMode: .remote, + configuredFingerprint: nil, + storedFingerprint: "old")) - #expect(MacNodeModeCoordinator.shouldAutoRepairStaleTLSPin(url: url, failure: failure)) + #expect(route.permitsTrustedPinReplacement(url: url, failure: failure)) + } + + @Test func `does not auto repair a redirected TLS authority`() throws { + let url = try #require(URL(string: "wss://gateway.example.ts.net")) + let route = try #require(GatewayTLSRoute.resolve( + url: url, + connectionMode: .remote, + configuredFingerprint: nil, + storedFingerprint: "old")) + let redirectedHost = GatewayTLSValidationFailure( + kind: .pinMismatch, + host: "redirect.example.ts.net", + storeKey: "gateway.example.ts.net:443", + expectedFingerprint: "old", + observedFingerprint: "new", + systemTrustOk: true, + port: 443) + let redirectedPort = GatewayTLSValidationFailure( + kind: .pinMismatch, + host: "gateway.example.ts.net", + storeKey: "gateway.example.ts.net:443", + expectedFingerprint: "old", + observedFingerprint: "new", + systemTrustOk: true, + port: 8443) + + #expect(!route.permitsTrustedPinReplacement(url: url, failure: redirectedHost)) + #expect(!route.permitsTrustedPinReplacement(url: url, failure: redirectedPort)) } @Test func `does not auto repair untrusted remote pin mismatch`() throws { @@ -952,9 +1005,77 @@ struct MacNodeModeCoordinatorTests { storeKey: "gateway.example.com:443", expectedFingerprint: "old", observedFingerprint: "new", - systemTrustOk: true) + systemTrustOk: true, + port: 443) + let route = try #require(GatewayTLSRoute.resolve( + url: url, + connectionMode: .remote, + configuredFingerprint: nil, + storedFingerprint: "old")) - #expect(!MacNodeModeCoordinator.shouldAutoRepairStaleTLSPin(url: url, failure: failure)) + #expect(!route.permitsTrustedPinReplacement(url: url, failure: failure)) + } + + @Test func `does not auto repair configured pin mismatch`() throws { + let url = try #require(URL(string: "wss://gateway.example.ts.net")) + let failure = GatewayTLSValidationFailure( + kind: .pinMismatch, + host: "gateway.example.ts.net", + storeKey: "gateway.example.ts.net:443", + expectedFingerprint: "configured", + observedFingerprint: "new", + systemTrustOk: true, + port: 443) + let route = try #require(GatewayTLSRoute.resolve( + url: url, + connectionMode: .remote, + configuredFingerprint: "configured", + storedFingerprint: "old")) + + #expect(!route.permitsTrustedPinReplacement(url: url, failure: failure)) + } + + @Test func `stale repair cannot replace a newer stored pin`() async throws { + try await withFakeGatewayTLSKeychain { + let url = try #require(URL(string: "wss://gateway.example.ts.net")) + let storeKey = "test-stale-repair" + GatewayTLSStore.saveFingerprint("old", stableID: storeKey) + let route = try #require(GatewayTLSRoute.resolve( + url: url, + connectionMode: .remote, + configuredFingerprint: nil, + storedFingerprint: "old", + storeKey: storeKey)) + let firstFailure = GatewayTLSValidationFailure( + kind: .pinMismatch, + host: "gateway.example.ts.net", + storeKey: storeKey, + expectedFingerprint: "old", + observedFingerprint: "new", + systemTrustOk: true, + port: 443) + let staleFailure = GatewayTLSValidationFailure( + kind: .pinMismatch, + host: "gateway.example.ts.net", + storeKey: storeKey, + expectedFingerprint: "old", + observedFingerprint: "stale", + systemTrustOk: true, + port: 443) + + let firstRepaired = await GatewayTLSRepairCoordinator.shared.repair( + route: route, + url: url, + failure: firstFailure) + let staleRepaired = await GatewayTLSRepairCoordinator.shared.repair( + route: route, + url: url, + failure: staleFailure) + + #expect(firstRepaired) + #expect(!staleRepaired) + #expect(GatewayTLSStore.loadFingerprint(stableID: storeKey) == "new") + } } @Test func `auto repairs trusted loopback pin mismatch`() throws { @@ -965,9 +1086,15 @@ struct MacNodeModeCoordinatorTests { storeKey: "127.0.0.1:18789", expectedFingerprint: "old", observedFingerprint: "new", - systemTrustOk: true) + systemTrustOk: true, + port: 18789) + let route = try #require(GatewayTLSRoute.resolve( + url: url, + connectionMode: .remote, + configuredFingerprint: nil, + storedFingerprint: "old")) - #expect(MacNodeModeCoordinator.shouldAutoRepairStaleTLSPin(url: url, failure: failure)) + #expect(route.permitsTrustedPinReplacement(url: url, failure: failure)) } @Test func `does not auto repair untrusted loopback pin mismatch`() throws { @@ -978,8 +1105,14 @@ struct MacNodeModeCoordinatorTests { storeKey: "127.0.0.1:18789", expectedFingerprint: "old", observedFingerprint: "new", - systemTrustOk: false) + systemTrustOk: false, + port: 18789) + let route = try #require(GatewayTLSRoute.resolve( + url: url, + connectionMode: .remote, + configuredFingerprint: nil, + storedFingerprint: "old")) - #expect(!MacNodeModeCoordinator.shouldAutoRepairStaleTLSPin(url: url, failure: failure)) + #expect(!route.permitsTrustedPinReplacement(url: url, failure: failure)) } } diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayConnectionProblem.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayConnectionProblem.swift index 476e6bea5b3a..b7b9de42868e 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayConnectionProblem.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayConnectionProblem.swift @@ -757,6 +757,36 @@ extension GatewayConnectionProblemMapper { retryable: false, pauseReconnect: true, technicalDetails: tlsError.localizedDescription) + case .pinStorageUnavailable: + return GatewayConnectionProblem( + kind: .tlsCertificateUnavailable, + owner: .unknown, + title: "Gateway certificate unavailable", + message: "OpenClaw could not securely save the TLS certificate pin for \(failure.host).", + actionLabel: "Retry", + messagePresentation: .verbatim( + "OpenClaw could not securely save the TLS certificate pin for \(failure.host)."), + actionLabelPresentation: .localized("Retry"), + actionCommand: nil, + docsURL: URL(string: "https://docs.openclaw.ai/gateway/troubleshooting"), + retryable: true, + pauseReconnect: false, + technicalDetails: tlsError.localizedDescription) + case .authorityMismatch: + return GatewayConnectionProblem( + kind: .tlsCertificateUntrusted, + owner: .network, + title: "Gateway certificate is not trusted", + message: "The TLS challenge came from a different host or port than the requested Gateway.", + actionLabel: "Check certificate", + messagePresentation: .verbatim( + "The TLS challenge came from a different host or port than the requested Gateway."), + actionLabelPresentation: .localized("Check certificate"), + actionCommand: nil, + docsURL: URL(string: "https://docs.openclaw.ai/gateway/troubleshooting"), + retryable: false, + pauseReconnect: true, + technicalDetails: tlsError.localizedDescription) } } diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayTLSPinning.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayTLSPinning.swift index 4a701b72096a..5f79dc79ecd0 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayTLSPinning.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayTLSPinning.swift @@ -2,7 +2,7 @@ import CryptoKit import Foundation import Security -public struct GatewayTLSParams: Sendable { +public struct GatewayTLSParams: Equatable, Sendable { public let required: Bool public let expectedFingerprint: String? public let allowTOFU: Bool @@ -20,6 +20,8 @@ public enum GatewayTLSValidationFailureKind: String, Sendable { case pinMismatch case certificateUnavailable case untrustedCertificate + case pinStorageUnavailable + case authorityMismatch } public struct GatewayTLSValidationFailure: Equatable, Sendable { @@ -29,6 +31,7 @@ public struct GatewayTLSValidationFailure: Equatable, Sendable { public let expectedFingerprint: String? public let observedFingerprint: String? public let systemTrustOk: Bool + public let port: Int? public init( kind: GatewayTLSValidationFailureKind, @@ -36,7 +39,8 @@ public struct GatewayTLSValidationFailure: Equatable, Sendable { storeKey: String?, expectedFingerprint: String?, observedFingerprint: String?, - systemTrustOk: Bool) + systemTrustOk: Bool, + port: Int? = nil) { self.kind = kind self.host = host @@ -44,6 +48,7 @@ public struct GatewayTLSValidationFailure: Equatable, Sendable { self.expectedFingerprint = expectedFingerprint self.observedFingerprint = observedFingerprint self.systemTrustOk = systemTrustOk + self.port = port } } @@ -68,6 +73,10 @@ public struct GatewayTLSValidationError: LocalizedError, Sendable { return "\(prefix): TLS certificate unavailable for \(self.failure.host)" case .untrustedCertificate: return "\(prefix): TLS certificate is not trusted for \(self.failure.host)" + case .pinStorageUnavailable: + return "\(prefix): TLS certificate pin could not be saved for \(self.failure.host)" + case .authorityMismatch: + return "\(prefix): TLS authority does not match the requested gateway for \(self.failure.host)" } } } @@ -86,41 +95,168 @@ enum GatewayTLSFirstUsePolicy { } } +enum GatewayTLSChallengeDecision: Equatable { + case accept(fingerprint: String?, enforcePin: Bool, saveFirstUse: Bool) + case reject(GatewayTLSValidationFailureKind) +} + +enum GatewayTLSValidationPolicy { + static func decide( + expectedFingerprint: String?, + observedFingerprint: String?, + allowTOFU: Bool, + required: Bool, + systemTrustOk: Bool) -> GatewayTLSChallengeDecision + { + if let expectedFingerprint { + guard let observedFingerprint else { + return .reject(.certificateUnavailable) + } + return observedFingerprint == expectedFingerprint + ? .accept(fingerprint: observedFingerprint, enforcePin: true, saveFirstUse: false) + : .reject(.pinMismatch) + } + if allowTOFU, + let observedFingerprint, + GatewayTLSFirstUsePolicy.allowsFirstUsePin(systemTrustOk: systemTrustOk) + { + return .accept(fingerprint: observedFingerprint, enforcePin: true, saveFirstUse: true) + } + if allowTOFU, required { + return .reject(observedFingerprint == nil ? .certificateUnavailable : .untrustedCertificate) + } + if systemTrustOk || !required { + return .accept(fingerprint: observedFingerprint, enforcePin: false, saveFirstUse: false) + } + return .reject(observedFingerprint == nil ? .certificateUnavailable : .untrustedCertificate) + } +} + +final class GatewayTLSFirstUseClaims: @unchecked Sendable { + private let lock = NSLock() + private var fingerprints: [String: String] = [:] + + func record(_ fingerprint: String, stableID: String) { + self.lock.lock() + self.fingerprints[stableID] = fingerprint + self.lock.unlock() + } + + func fingerprint(stableID: String) -> String? { + self.lock.lock() + defer { self.lock.unlock() } + return self.fingerprints[stableID] + } + + func clear(stableID: String) { + self.lock.lock() + self.fingerprints[stableID] = nil + self.lock.unlock() + } + + func clearAll() { + self.lock.lock() + self.fingerprints.removeAll() + self.lock.unlock() + } +} + +struct GatewayTLSKeychainOperations: @unchecked Sendable { + let copyMatching: (CFDictionary, UnsafeMutablePointer?) -> OSStatus + let add: (CFDictionary) -> OSStatus + let update: (CFDictionary, CFDictionary) -> OSStatus + let delete: (CFDictionary) -> OSStatus + + static let live = GatewayTLSKeychainOperations( + copyMatching: { SecItemCopyMatching($0, $1) }, + add: { SecItemAdd($0, nil) }, + update: { SecItemUpdate($0, $1) }, + delete: { SecItemDelete($0) }) +} + public enum GatewayTLSStore { + @TaskLocal static var keychainOperations = GatewayTLSKeychainOperations.live + + private enum FingerprintRead { + case missing + case value(String) + case unavailable + } + private static let keychainService = "ai.openclaw.tls-pinning" - private static let keychainAccountPrefix = "fingerprint.v2." + private static let keychainAccountPrefix = "fingerprint.v3." + private static let legacyCanonicalAccountPrefix = "fingerprint.v2." // Legacy UserDefaults location used before Keychain migration. private static let legacySuiteName = "ai.openclaw.shared" private static let legacyKeyPrefix = "gateway.tls." + private static let firstUseClaims = GatewayTLSFirstUseClaims() public static func loadFingerprint(stableID: String) -> String? { - guard let account = self.keychainAccount(stableID: stableID) else { return nil } - self.migrateLegacyFingerprintIfNeeded(stableID: stableID, account: account) - let raw = GenericPasswordKeychainStore.loadString(service: self.keychainService, account: account)? - .trimmingCharacters(in: .whitespacesAndNewlines) - if raw?.isEmpty == false { return raw } - return nil + guard case let .value(fingerprint) = self.loadFingerprintResult(stableID: stableID) else { + return nil + } + return fingerprint } public static func saveFingerprint(_ value: String, stableID: String) { - guard let account = self.keychainAccount(stableID: stableID), - GenericPasswordKeychainStore.saveString( - value, - service: self.keychainService, - account: account) - else { return } + guard self.writeCanonicalFingerprint(value, stableID: stableID) else { return } _ = self.clearSafeLegacyFingerprint(stableID: stableID) } + static func claimFirstUseFingerprint(_ value: String, stableID: String) -> String? { + guard let account = self.keychainAccount(stableID: stableID) else { return nil } + switch self.loadFingerprintResult(stableID: stableID) { + case let .value(existing): + self.firstUseClaims.record(existing, stableID: stableID) + return existing + case .unavailable: + return nil + case .missing: + break + } + + let claimed = self.createCanonicalFingerprintIfAbsent(value, account: account) + if claimed != nil { + _ = self.clearSafeLegacyFingerprint(stableID: stableID) + } + if let claimed { + self.firstUseClaims.record(claimed, stableID: stableID) + } + return claimed + } + + public static func claimedFirstUseFingerprint(stableID: String) -> String? { + self.firstUseClaims.fingerprint(stableID: stableID) + } + @discardableResult public static func replaceFingerprint(_ value: String, stableID: String) -> Bool { - guard let account = self.keychainAccount(stableID: stableID), - GenericPasswordKeychainStore.saveString( - value, - service: self.keychainService, - account: account) - else { + guard self.writeCanonicalFingerprint(value, stableID: stableID) else { return false } + return self.clearSafeLegacyFingerprint(stableID: stableID) + } + + @discardableResult + public static func replaceFingerprint( + _ value: String, + ifCurrent expectedValue: String, + stableID: String) -> Bool + { + guard let account = self.keychainAccount(stableID: stableID) else { return false } + let expectedData = Data(self.canonicalStoredFingerprint(expectedValue).utf8) + let replacementData = Data(self.canonicalStoredFingerprint(value).utf8) + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: self.keychainService, + kSecAttrAccount as String: account, + kSecAttrGeneric as String: expectedData, + ] + let updates: [String: Any] = [ + kSecValueData as String: replacementData, + kSecAttrGeneric as String: replacementData, + kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly, + ] + guard self.keychainOperations.update(query as CFDictionary, updates as CFDictionary) == errSecSuccess else { return false } return self.clearSafeLegacyFingerprint(stableID: stableID) @@ -129,67 +265,241 @@ public enum GatewayTLSStore { @discardableResult public static func clearFingerprint(stableID: String) -> Bool { guard let account = self.keychainAccount(stableID: stableID) else { return false } - let removedCanonical = GenericPasswordKeychainStore.delete( - service: self.keychainService, - account: account) + let removedCanonical = self.deleteFingerprint(account: account) let removedLegacy = self.clearSafeLegacyFingerprint(stableID: stableID) - return removedCanonical && removedLegacy + let removed = removedCanonical && removedLegacy + if removed { + self.firstUseClaims.clear(stableID: stableID) + } + return removed } @discardableResult public static func clearAllFingerprints() -> Bool { - let removedKeychain = SecItemDelete([ + let removedKeychain = self.keychainOperations.delete([ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: self.keychainService, ] as CFDictionary) self.clearAllLegacyFingerprints() - return removedKeychain == errSecSuccess || removedKeychain == errSecItemNotFound + let removed = removedKeychain == errSecSuccess || removedKeychain == errSecItemNotFound + if removed { + self.firstUseClaims.clearAll() + } + return removed } // MARK: - Migration - /// Legacy raw Keychain/UserDefaults keys can apply Unicode equivalence without - /// embedding their owner. Only ASCII owners are safe to attribute and migrate. - private static func migrateLegacyFingerprintIfNeeded(stableID: String, account: String) { - guard self.canSafelyReadLegacyRawStorageKey(stableID) else { return } - let canonical = self.normalizedFingerprint(GenericPasswordKeychainStore.loadString( - service: self.keychainService, - account: account)) - if canonical != nil { + /// v3 stores the canonical fingerprint in both value data and a searchable + /// comparison attribute. Older records migrate by atomically creating v3; + /// concurrent writers always keep the first complete v3 record. + private static func loadFingerprintResult(stableID: String) -> FingerprintRead { + guard let account = self.keychainAccount(stableID: stableID) else { return .unavailable } + switch self.readCanonicalFingerprint(account: account) { + case let .value(fingerprint): _ = self.clearSafeLegacyFingerprint(stableID: stableID) - return + return .value(fingerprint) + case .unavailable: + return .unavailable + case .missing: + return self.migrateLegacyFingerprint(stableID: stableID, account: account) } + } - let legacyKeychain = self.normalizedFingerprint(GenericPasswordKeychainStore.loadString( - service: self.keychainService, - account: stableID)) - let defaults = UserDefaults(suiteName: self.legacySuiteName) - let legacyDefaults = self.normalizedFingerprint(defaults?.string( - forKey: self.legacyKeyPrefix + stableID)) - guard let existing = legacyKeychain ?? legacyDefaults, - GenericPasswordKeychainStore.saveString( - existing, - service: self.keychainService, - account: account) - else { return } + private static func migrateLegacyFingerprint( + stableID: String, + account: String) -> FingerprintRead + { + let v2Account = self.keychainAccount( + stableID: stableID, + prefix: self.legacyCanonicalAccountPrefix) + if let v2Account { + switch self.readLegacyKeychainFingerprint(account: v2Account) { + case let .value(fingerprint): + return self.migrateLegacyFingerprint( + fingerprint, + stableID: stableID, + account: account) + case .unavailable: + return .unavailable + case .missing: + break + } + } + guard self.canSafelyReadLegacyRawStorageKey(stableID) else { return .missing } + + switch self.readLegacyKeychainFingerprint(account: stableID) { + case let .value(fingerprint): + return self.migrateLegacyFingerprint( + fingerprint, + stableID: stableID, + account: account) + case .unavailable: + return .unavailable + case .missing: + break + } + switch self.readLegacyDefaultsFingerprint(stableID: stableID) { + case let .value(fingerprint): + return self.migrateLegacyFingerprint( + fingerprint, + stableID: stableID, + account: account) + case .unavailable: + return .unavailable + case .missing: + return .missing + } + } + + private static func migrateLegacyFingerprint( + _ fingerprint: String, + stableID: String, + account: String) -> FingerprintRead + { + guard let winner = self.createCanonicalFingerprintIfAbsent(fingerprint, account: account) else { + return .unavailable + } _ = self.clearSafeLegacyFingerprint(stableID: stableID) + return .value(winner) + } + + private static func readCanonicalFingerprint(account: String) -> FingerprintRead { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: self.keychainService, + kSecAttrAccount as String: account, + kSecReturnData as String: true, + kSecReturnAttributes as String: true, + kSecMatchLimit as String: kSecMatchLimitOne, + ] + var result: CFTypeRef? + let status = self.keychainOperations.copyMatching(query as CFDictionary, &result) + if status == errSecItemNotFound { + return .missing + } + guard status == errSecSuccess, + let item = result as? [String: Any], + let data = item[kSecValueData as String] as? Data, + let comparisonData = item[kSecAttrGeneric as String] as? Data, + let value = String(data: data, encoding: .utf8), + let comparison = String(data: comparisonData, encoding: .utf8) + else { return .unavailable } + let fingerprint = self.canonicalStoredFingerprint(value) + return comparison == fingerprint ? .value(fingerprint) : .unavailable + } + + private static func loadCanonicalFingerprint(account: String) -> String? { + guard case let .value(fingerprint) = self.readCanonicalFingerprint(account: account) else { + return nil + } + return fingerprint + } + + private static func readLegacyKeychainFingerprint(account: String) -> FingerprintRead { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: self.keychainService, + kSecAttrAccount as String: account, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne, + ] + var result: CFTypeRef? + let status = self.keychainOperations.copyMatching(query as CFDictionary, &result) + if status == errSecItemNotFound { + return .missing + } + guard status == errSecSuccess, + let data = result as? Data, + let value = String(data: data, encoding: .utf8), + let fingerprint = self.normalizedFingerprint(value) + else { return .unavailable } + return .value(fingerprint) + } + + private static func readLegacyDefaultsFingerprint(stableID: String) -> FingerprintRead { + guard let defaults = UserDefaults(suiteName: self.legacySuiteName) else { return .unavailable } + let key = self.legacyKeyPrefix + stableID + guard let value = defaults.object(forKey: key) else { return .missing } + guard let raw = value as? String, + let fingerprint = self.normalizedFingerprint(raw) + else { return .unavailable } + return .value(fingerprint) + } + + private static func writeCanonicalFingerprint(_ value: String, stableID: String) -> Bool { + guard let account = self.keychainAccount(stableID: stableID) else { return false } + return self.writeCanonicalFingerprint(value, account: account) + } + + private static func writeCanonicalFingerprint(_ value: String, account: String) -> Bool { + let data = Data(self.canonicalStoredFingerprint(value).utf8) + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: self.keychainService, + kSecAttrAccount as String: account, + ] + let updates: [String: Any] = [ + kSecValueData as String: data, + kSecAttrGeneric as String: data, + kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly, + ] + let updateStatus = self.keychainOperations.update(query as CFDictionary, updates as CFDictionary) + if updateStatus == errSecSuccess { + return true + } + guard updateStatus == errSecItemNotFound else { return false } + return self.createCanonicalFingerprintIfAbsent(value, account: account) != nil + } + + private static func createCanonicalFingerprintIfAbsent( + _ value: String, + account: String) -> String? + { + let fingerprint = self.canonicalStoredFingerprint(value) + let data = Data(fingerprint.utf8) + let insert: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: self.keychainService, + kSecAttrAccount as String: account, + kSecValueData as String: data, + kSecAttrGeneric as String: data, + kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly, + ] + let addStatus = self.keychainOperations.add(insert as CFDictionary) + if addStatus == errSecSuccess { + return fingerprint + } + guard addStatus == errSecDuplicateItem else { return nil } + return self.loadCanonicalFingerprint(account: account) } private static func keychainAccount(stableID: String) -> String? { + self.keychainAccount(stableID: stableID, prefix: self.keychainAccountPrefix) + } + + private static func keychainAccount(stableID: String, prefix: String) -> String? { guard !stableID.isEmpty else { return nil } let component = Data(stableID.utf8).base64EncodedString() .replacingOccurrences(of: "+", with: "-") .replacingOccurrences(of: "/", with: "_") .replacingOccurrences(of: "=", with: "") - return self.keychainAccountPrefix + component + return prefix + component } private static func canSafelyReadLegacyRawStorageKey(_ stableID: String) -> Bool { !stableID.isEmpty && !stableID.hasPrefix(self.keychainAccountPrefix) && + !stableID.hasPrefix(self.legacyCanonicalAccountPrefix) && stableID.unicodeScalars.allSatisfy(\.isASCII) } + private static func canonicalStoredFingerprint(_ value: String) -> String { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + let normalized = normalizeFingerprint(trimmed) + return normalized.count == 64 ? normalized : trimmed + } + private static func normalizedFingerprint(_ value: String?) -> String? { let value = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" return value.isEmpty ? nil : value @@ -197,13 +507,26 @@ public enum GatewayTLSStore { @discardableResult private static func clearSafeLegacyFingerprint(stableID: String) -> Bool { - guard self.canSafelyReadLegacyRawStorageKey(stableID) else { return true } - let removedKeychain = GenericPasswordKeychainStore.delete( - service: self.keychainService, - account: stableID) + let removedV2 = self.keychainAccount( + stableID: stableID, + prefix: self.legacyCanonicalAccountPrefix).map { + self.deleteFingerprint(account: $0) + } ?? true + guard self.canSafelyReadLegacyRawStorageKey(stableID) else { return removedV2 } + let removedRaw = self.deleteFingerprint(account: stableID) UserDefaults(suiteName: self.legacySuiteName)? .removeObject(forKey: self.legacyKeyPrefix + stableID) - return removedKeychain + return removedRaw && removedV2 + } + + private static func deleteFingerprint(account: String) -> Bool { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: self.keychainService, + kSecAttrAccount as String: account, + ] + let status = self.keychainOperations.delete(query as CFDictionary) + return status == errSecSuccess || status == errSecItemNotFound } private static func clearAllLegacyFingerprints() { @@ -214,10 +537,55 @@ public enum GatewayTLSStore { } } -protocol GatewayTLSRouteMetadataProviding: AnyObject { +public protocol GatewayTLSRouteMetadataProviding: AnyObject { var effectiveTLSFingerprintSHA256: String? { get } } +struct GatewayTLSAuthority: Equatable, Sendable { + let host: String + let port: Int + + init?(url: URL) { + guard let host = Self.normalizedHost(url.host) else { return nil } + self.host = host + self.port = url.port ?? (url.scheme?.lowercased() == "wss" ? 443 : 80) + } + + init?(host: String, port: Int) { + guard let host = Self.normalizedHost(host) else { return nil } + self.host = host + self.port = port + } + + private static func normalizedHost(_ host: String?) -> String? { + let value = host?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() ?? "" + return value.isEmpty ? nil : value + } +} + +struct GatewayTLSPinningState { + private(set) var acceptedFingerprint: String? + private(set) var enforcedFingerprint: String? + + init(expectedFingerprint: String?) { + let expected = expectedFingerprint.map(normalizeFingerprint) + self.enforcedFingerprint = expected + self.acceptedFingerprint = expected.flatMap { $0.count == 64 ? $0 : nil } + } + + mutating func enforceFingerprint(_ fingerprint: String) { + self.enforcedFingerprint = fingerprint + } + + mutating func recordAcceptance(_ fingerprint: String?, enforcePin: Bool) { + guard let fingerprint else { return } + self.acceptedFingerprint = fingerprint + if enforcePin { + self.enforcedFingerprint = fingerprint + } + } +} + public final class GatewayTLSPinningSession: NSObject, WebSocketSessioning, URLSessionDelegate, GatewayTLSFailureProviding, GatewayDeviceTokenRetryTrustProviding, GatewayTLSRouteMetadataProviding, @unchecked Sendable @@ -225,7 +593,8 @@ public final class GatewayTLSPinningSession: NSObject, WebSocketSessioning, URLS private let params: GatewayTLSParams private let failureLock = NSLock() private var lastTLSFailure: GatewayTLSValidationFailure? - private var acceptedTLSFingerprintSHA256: String? + private var pinningState: GatewayTLSPinningState + private var expectedAuthority: GatewayTLSAuthority? private lazy var session: URLSession = { let config = URLSessionConfiguration.default config.waitsForConnectivity = true @@ -234,20 +603,20 @@ public final class GatewayTLSPinningSession: NSObject, WebSocketSessioning, URLS public init(params: GatewayTLSParams) { self.params = params - self.acceptedTLSFingerprintSHA256 = params.expectedFingerprint - .map(normalizeFingerprint) - .flatMap { $0.count == 64 ? $0 : nil } + self.pinningState = GatewayTLSPinningState(expectedFingerprint: params.expectedFingerprint) super.init() } public var allowsDeviceTokenRetryAuth: Bool { - self.params.expectedFingerprint?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false - } - - var effectiveTLSFingerprintSHA256: String? { self.failureLock.lock() defer { self.failureLock.unlock() } - return self.acceptedTLSFingerprintSHA256 + return self.pinningState.enforcedFingerprint != nil + } + + public var effectiveTLSFingerprintSHA256: String? { + self.failureLock.lock() + defer { self.failureLock.unlock() } + return self.pinningState.acceptedFingerprint } public func consumeLastTLSFailure() -> GatewayTLSValidationFailure? { @@ -264,20 +633,46 @@ public final class GatewayTLSPinningSession: NSObject, WebSocketSessioning, URLS self.failureLock.unlock() } - private func recordTLSAcceptance(_ fingerprint: String?) { + private func currentEnforcedFingerprint() -> String? { + self.failureLock.lock() + defer { self.failureLock.unlock() } + return self.pinningState.enforcedFingerprint + } + + private func recordTLSPinExpectation(_ fingerprint: String) { + self.failureLock.lock() + self.pinningState.enforceFingerprint(fingerprint) + self.failureLock.unlock() + } + + private func recordTLSAcceptance(_ fingerprint: String?, enforcePin: Bool) { self.failureLock.lock() self.lastTLSFailure = nil - if let fingerprint { - self.acceptedTLSFingerprintSHA256 = fingerprint + self.pinningState.recordAcceptance(fingerprint, enforcePin: enforcePin) + self.failureLock.unlock() + } + + private func registerExpectedAuthority(url: URL?) { + guard let url, let authority = GatewayTLSAuthority(url: url) else { return } + self.failureLock.lock() + if self.expectedAuthority == nil { + self.expectedAuthority = authority } self.failureLock.unlock() } + private func currentExpectedAuthority() -> GatewayTLSAuthority? { + self.failureLock.lock() + defer { self.failureLock.unlock() } + return self.expectedAuthority + } + public func makeWebSocketTask(url: URL) -> WebSocketTaskBox { self.makeWebSocketTask(request: URLRequest(url: url)) } public func makeWebSocketTask(request: URLRequest) -> WebSocketTaskBox { + self.registerExpectedAuthority(url: request.url) let task = self.session.webSocketTask(with: request) task.maximumMessageSize = 16 * 1024 * 1024 return WebSocketTaskBox(task: task) @@ -296,49 +691,77 @@ public final class GatewayTLSPinningSession: NSObject, WebSocketSessioning, URLS } let host = challenge.protectionSpace.host + let port = challenge.protectionSpace.port + let expected = self.currentEnforcedFingerprint() + let challengedAuthority = GatewayTLSAuthority(host: host, port: port) + guard let expectedAuthority = self.currentExpectedAuthority(), + challengedAuthority == expectedAuthority + else { + self.recordTLSFailure(GatewayTLSValidationFailure( + kind: .authorityMismatch, + host: host, + storeKey: self.params.storeKey, + expectedFingerprint: expected, + observedFingerprint: nil, + systemTrustOk: false, + port: port)) + completionHandler(.cancelAuthenticationChallenge, nil) + return + } let systemTrustOk = SecTrustEvaluateWithError(trust, nil) - let expected = self.params.expectedFingerprint.map(normalizeFingerprint) let fingerprint = certificateFingerprint(trust) - if let fingerprint { - if let expected { - if fingerprint == expected { - self.recordTLSAcceptance(fingerprint) - completionHandler(.useCredential, URLCredential(trust: trust)) - } else { + let decision = GatewayTLSValidationPolicy.decide( + expectedFingerprint: expected, + observedFingerprint: fingerprint, + allowTOFU: self.params.allowTOFU, + required: self.params.required, + systemTrustOk: systemTrustOk) + + switch decision { + case let .accept(acceptedFingerprint, enforcePin, saveFirstUse): + if saveFirstUse { + guard let acceptedFingerprint, + let storeKey = self.params.storeKey, + let claimedFingerprint = GatewayTLSStore.claimFirstUseFingerprint( + acceptedFingerprint, + stableID: storeKey) + else { + self.recordTLSFailure(GatewayTLSValidationFailure( + kind: .pinStorageUnavailable, + host: host, + storeKey: self.params.storeKey, + expectedFingerprint: nil, + observedFingerprint: acceptedFingerprint, + systemTrustOk: systemTrustOk, + port: challenge.protectionSpace.port)) + completionHandler(.cancelAuthenticationChallenge, nil) + return + } + guard claimedFingerprint == acceptedFingerprint else { + self.recordTLSPinExpectation(claimedFingerprint) self.recordTLSFailure(GatewayTLSValidationFailure( kind: .pinMismatch, host: host, - storeKey: self.params.storeKey, - expectedFingerprint: expected, - observedFingerprint: fingerprint, - systemTrustOk: systemTrustOk)) + storeKey: storeKey, + expectedFingerprint: claimedFingerprint, + observedFingerprint: acceptedFingerprint, + systemTrustOk: systemTrustOk, + port: challenge.protectionSpace.port)) completionHandler(.cancelAuthenticationChallenge, nil) - } - return - } - if self.params.allowTOFU { - if GatewayTLSFirstUsePolicy.allowsFirstUsePin(systemTrustOk: systemTrustOk) { - if let storeKey = params.storeKey { - GatewayTLSStore.saveFingerprint(fingerprint, stableID: storeKey) - } - self.recordTLSAcceptance(fingerprint) - completionHandler(.useCredential, URLCredential(trust: trust)) return } } - } - - if systemTrustOk || !self.params.required { - self.recordTLSAcceptance(fingerprint) + self.recordTLSAcceptance(acceptedFingerprint, enforcePin: enforcePin) completionHandler(.useCredential, URLCredential(trust: trust)) - } else { + case let .reject(kind): self.recordTLSFailure(GatewayTLSValidationFailure( - kind: fingerprint == nil ? .certificateUnavailable : .untrustedCertificate, + kind: kind, host: host, storeKey: self.params.storeKey, expectedFingerprint: expected, observedFingerprint: fingerprint, - systemTrustOk: false)) + systemTrustOk: systemTrustOk, + port: challenge.protectionSpace.port)) completionHandler(.cancelAuthenticationChallenge, nil) } } diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayErrorsTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayErrorsTests.swift index 3704840b2edd..89dc3d8ba871 100644 --- a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayErrorsTests.swift +++ b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayErrorsTests.swift @@ -300,6 +300,45 @@ struct GatewayErrorsTests { #expect(problem?.pauseReconnect == true) } + @Test func `TLS pin storage failure stays retryable`() { + let error = GatewayTLSValidationError( + failure: GatewayTLSValidationFailure( + kind: .pinStorageUnavailable, + host: "gateway.example.com", + storeKey: "gateway.example.com:443", + expectedFingerprint: nil, + observedFingerprint: "observed", + systemTrustOk: true), + context: "connect to gateway") + + let problem = GatewayConnectionProblemMapper.map(error: error) + + #expect(problem?.kind == .tlsCertificateUnavailable) + #expect(problem?.retryable == true) + #expect(problem?.pauseReconnect == false) + #expect(problem?.actionLabel == "Retry") + } + + @Test func `TLS authority mismatch pauses reconnect`() { + let error = GatewayTLSValidationError( + failure: GatewayTLSValidationFailure( + kind: .authorityMismatch, + host: "redirect.example.com", + storeKey: "gateway.example.com:443", + expectedFingerprint: "expected", + observedFingerprint: nil, + systemTrustOk: false, + port: 443), + context: "connect to gateway") + + let problem = GatewayConnectionProblemMapper.map(error: error) + + #expect(problem?.kind == .tlsCertificateUntrusted) + #expect(problem?.retryable == false) + #expect(problem?.pauseReconnect == true) + #expect(problem?.actionLabel == "Check certificate") + } + @Test func `untrusted TLS mismatch cannot be recovered in app`() { let error = GatewayTLSValidationError( failure: GatewayTLSValidationFailure( diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayTLSPinningTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayTLSPinningTests.swift index b1d478564d96..bbc4a9f95883 100644 --- a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayTLSPinningTests.swift +++ b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayTLSPinningTests.swift @@ -1,9 +1,288 @@ +import Foundation +import Security import Testing @testable import OpenClawKit +private final class GatewayTLSFakeKeychain: @unchecked Sendable { + private let lock = NSLock() + private var items: [String: [String: Any]] = [:] + + var operations: GatewayTLSKeychainOperations { + GatewayTLSKeychainOperations( + copyMatching: { [self] query, result in self.copyMatching(query, result: result) }, + add: { [self] query in self.add(query) }, + update: { [self] query, updates in self.update(query, updates: updates) }, + delete: { [self] query in self.delete(query) }) + } + + func seed(account: String, data: Data) { + self.lock.lock() + self.items[account] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: "ai.openclaw.tls-pinning", + kSecAttrAccount as String: account, + kSecValueData as String: data, + ] + self.lock.unlock() + } + + private func copyMatching( + _ query: CFDictionary, + result: UnsafeMutablePointer?) -> OSStatus + { + let query = query as NSDictionary as! [String: Any] + guard let account = query[kSecAttrAccount as String] as? String else { return errSecParam } + self.lock.lock() + defer { self.lock.unlock() } + guard let item = self.items[account] else { return errSecItemNotFound } + if query[kSecReturnAttributes as String] as? Bool == true { + result?.pointee = item as CFDictionary + } else { + guard let data = item[kSecValueData as String] as? Data else { return errSecDecode } + result?.pointee = data as CFData + } + return errSecSuccess + } + + private func add(_ query: CFDictionary) -> OSStatus { + let query = query as NSDictionary as! [String: Any] + guard let account = query[kSecAttrAccount as String] as? String else { return errSecParam } + self.lock.lock() + defer { self.lock.unlock() } + guard self.items[account] == nil else { return errSecDuplicateItem } + self.items[account] = query + return errSecSuccess + } + + private func update(_ query: CFDictionary, updates: CFDictionary) -> OSStatus { + let query = query as NSDictionary as! [String: Any] + let updates = updates as NSDictionary as! [String: Any] + guard let account = query[kSecAttrAccount as String] as? String else { return errSecParam } + self.lock.lock() + defer { self.lock.unlock() } + guard var item = self.items[account] else { return errSecItemNotFound } + if let expected = query[kSecAttrGeneric as String] as? Data, + item[kSecAttrGeneric as String] as? Data != expected + { + return errSecItemNotFound + } + item.merge(updates) { _, replacement in replacement } + self.items[account] = item + return errSecSuccess + } + + private func delete(_ query: CFDictionary) -> OSStatus { + let query = query as NSDictionary as! [String: Any] + self.lock.lock() + defer { self.lock.unlock() } + guard let account = query[kSecAttrAccount as String] as? String else { + self.items.removeAll() + return errSecSuccess + } + self.items[account] = nil + return errSecSuccess + } +} + struct GatewayTLSPinningTests { + private func withFakeKeychain(_ operation: (GatewayTLSFakeKeychain) throws -> T) rethrows -> T { + let keychain = GatewayTLSFakeKeychain() + return try GatewayTLSStore.$keychainOperations.withValue(keychain.operations) { + try operation(keychain) + } + } + + private func withFakeKeychain( + _ operation: (GatewayTLSFakeKeychain) async throws -> T) async rethrows -> T + { + let keychain = GatewayTLSFakeKeychain() + return try await GatewayTLSStore.$keychainOperations.withValue(keychain.operations) { + try await operation(keychain) + } + } + @Test func `first use pinning requires system trust`() { #expect(GatewayTLSFirstUsePolicy.allowsFirstUsePin(systemTrustOk: true)) #expect(!GatewayTLSFirstUsePolicy.allowsFirstUsePin(systemTrustOk: false)) } + + @Test func `TLS authority includes normalized host and effective port`() throws { + let url = try #require(URL(string: "wss://Gateway.Example.com/path")) + let route = try #require(GatewayTLSAuthority(url: url)) + + #expect(route == GatewayTLSAuthority(host: "gateway.example.com", port: 443)) + #expect(route != GatewayTLSAuthority(host: "redirect.example.com", port: 443)) + #expect(route != GatewayTLSAuthority(host: "gateway.example.com", port: 8443)) + } + + @Test func `matching explicit pin overrides system trust`() { + let decision = GatewayTLSValidationPolicy.decide( + expectedFingerprint: "expected", + observedFingerprint: "expected", + allowTOFU: false, + required: true, + systemTrustOk: false) + + #expect(decision == .accept( + fingerprint: "expected", + enforcePin: true, + saveFirstUse: false)) + } + + @Test func `explicit pin mismatch and unavailable certificate fail closed`() { + #expect(GatewayTLSValidationPolicy.decide( + expectedFingerprint: "expected", + observedFingerprint: "different", + allowTOFU: false, + required: true, + systemTrustOk: true) == .reject(.pinMismatch)) + #expect(GatewayTLSValidationPolicy.decide( + expectedFingerprint: "expected", + observedFingerprint: nil, + allowTOFU: false, + required: true, + systemTrustOk: true) == .reject(.certificateUnavailable)) + #expect(GatewayTLSValidationPolicy.decide( + expectedFingerprint: nil, + observedFingerprint: nil, + allowTOFU: true, + required: true, + systemTrustOk: true) == .reject(.certificateUnavailable)) + } + + @Test func `trusted first use is saved and enforced`() { + let decision = GatewayTLSValidationPolicy.decide( + expectedFingerprint: nil, + observedFingerprint: "observed", + allowTOFU: true, + required: true, + systemTrustOk: true) + + #expect(decision == .accept( + fingerprint: "observed", + enforcePin: true, + saveFirstUse: true)) + } + + @Test func `concurrent first use sessions share one durable fingerprint`() async { + await self.withFakeKeychain { _ in + let stableID = "test-first-use-claim" + let results = await withTaskGroup(of: String?.self, returning: [String?].self) { group in + for fingerprint in ["first", "second"] { + group.addTask { + GatewayTLSStore.claimFirstUseFingerprint(fingerprint, stableID: stableID) + } + } + var results: [String?] = [] + for await result in group { + results.append(result) + } + return results + } + let claimed = results.compactMap(\.self) + + #expect(claimed.count == 2) + #expect(Set(claimed).count == 1) + #expect(GatewayTLSStore.loadFingerprint(stableID: stableID) == claimed.first) + } + } + + @Test func `first use claim fails closed without a storage owner`() { + #expect(GatewayTLSStore.claimFirstUseFingerprint("observed", stableID: "") == nil) + } + + @Test func `losing first use session adopts the shared winner`() { + var state = GatewayTLSPinningState(expectedFingerprint: nil) + + state.enforceFingerprint("winner") + + #expect(state.enforcedFingerprint == "winner") + #expect(state.acceptedFingerprint == nil) + } + + @Test func `pin replacement compares the stored value atomically`() { + self.withFakeKeychain { _ in + let stableID = "test-pin-cas" + GatewayTLSStore.saveFingerprint("old", stableID: stableID) + + #expect(!GatewayTLSStore.replaceFingerprint("wrong", ifCurrent: "missing", stableID: stableID)) + #expect(GatewayTLSStore.loadFingerprint(stableID: stableID) == "old") + #expect(GatewayTLSStore.replaceFingerprint("new", ifCurrent: "old", stableID: stableID)) + #expect(GatewayTLSStore.loadFingerprint(stableID: stableID) == "new") + } + } + + @Test func `pin storage canonicalizes accepted fingerprint spelling`() { + self.withFakeKeychain { _ in + let stableID = "test-pin-canonical-spelling" + let uppercase = String(repeating: "AB", count: 32) + let lowercase = uppercase.lowercased() + + GatewayTLSStore.saveFingerprint("SHA256: \(uppercase)", stableID: stableID) + + #expect(GatewayTLSStore.loadFingerprint(stableID: stableID) == lowercase) + #expect(GatewayTLSStore.replaceFingerprint( + String(repeating: "c", count: 64), + ifCurrent: uppercase, + stableID: stableID)) + } + } + + @Test func `canonical pin without comparison metadata is upgraded for replacement`() { + self.withFakeKeychain { keychain in + let stableID = "测试-pin-canonical-migration" + let component = Data(stableID.utf8).base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + keychain.seed(account: "fingerprint.v2.\(component)", data: Data("old".utf8)) + + #expect(GatewayTLSStore.loadFingerprint(stableID: stableID) == "old") + #expect(GatewayTLSStore.replaceFingerprint("new", ifCurrent: "old", stableID: stableID)) + #expect(GatewayTLSStore.loadFingerprint(stableID: stableID) == "new") + } + } + + @Test func `unreadable v2 pin blocks a new first use claim`() { + self.withFakeKeychain { keychain in + let stableID = "test-pin-unreadable-v2" + let component = Data(stableID.utf8).base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + keychain.seed(account: "fingerprint.v2.\(component)", data: Data([0xFF])) + + #expect(GatewayTLSStore.loadFingerprint(stableID: stableID) == nil) + #expect(GatewayTLSStore.claimFirstUseFingerprint("new", stableID: stableID) == nil) + } + } + + @Test func `legacy raw pin is migrated before conditional replacement`() { + self.withFakeKeychain { keychain in + let stableID = "test-pin-legacy-migration" + keychain.seed(account: stableID, data: Data("old".utf8)) + + #expect(GatewayTLSStore.loadFingerprint(stableID: stableID) == "old") + #expect(GatewayTLSStore.replaceFingerprint("new", ifCurrent: "old", stableID: stableID)) + #expect(GatewayTLSStore.loadFingerprint(stableID: stableID) == "new") + } + } + + @Test func `first use fingerprint remains enforced for reconnects`() { + var state = GatewayTLSPinningState(expectedFingerprint: nil) + + state.recordAcceptance("first", enforcePin: true) + + #expect(state.acceptedFingerprint == "first") + #expect(state.enforcedFingerprint == "first") + } + + @Test func `untrusted first use is rejected`() { + #expect(GatewayTLSValidationPolicy.decide( + expectedFingerprint: nil, + observedFingerprint: "observed", + allowTOFU: true, + required: true, + systemTrustOk: false) == .reject(.untrustedCertificate)) + } } diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index f062981388ef..1fa6616eb6c0 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -663,6 +663,7 @@ See [Plugins](/tools/plugin). - `terminal.detachedSessionTimeoutSeconds`: how long a terminal session survives after its connection drops (page reload, laptop sleep), staying reattachable via `terminal.attach` with its recent output replayed. Default: `300`. Set `0` to kill sessions the moment their connection drops. Detached sessions keep running their commands, so shorten this on shared or exposed hosts. - `remote.transport`: `ssh` (default) or `direct` (ws/wss). For `direct`, `remote.url` must be `wss://` for public hosts; plaintext `ws://` is accepted only for loopback, LAN, link-local, `.local`, `.ts.net`, and Tailscale CGNAT hosts. - `remote.remotePort`: gateway port on the remote SSH host. Defaults to `18789`; use this when the local tunnel port differs from the remote gateway port. +- `remote.tlsFingerprint`: expected SHA-256 certificate fingerprint for a remote `wss://` Gateway. The macOS app applies it to both operator/control and companion-node connections. Without an explicit value, macOS records a first-use pin only after normal system trust succeeds. - `remote.sshHostKeyPolicy`: macOS SSH tunnel host-key policy. `strict` is the default and requires an already trusted key. `openssh` is an explicit opt-in to the effective OpenSSH configuration for managed aliases; review matching user and system SSH settings before using it. The macOS app and `configure-remote` reset this policy to `strict` when changing targets unless explicitly opted in again. - `gateway.remote.token` / `.password` are remote-client credential fields. They do not configure gateway auth by themselves. - `gateway.push.apns.relay.baseUrl`: base HTTPS URL for the external APNs relay used after relay-backed iOS builds publish registrations to the gateway. Public App Store builds use the hosted OpenClaw relay. Custom relay URLs must match a deliberately separate iOS build/deployment path whose relay URL points at that relay. diff --git a/docs/gateway/remote.md b/docs/gateway/remote.md index ea3f70ccb704..d07f009073b4 100644 --- a/docs/gateway/remote.md +++ b/docs/gateway/remote.md @@ -126,7 +126,7 @@ Keep the Gateway **loopback-only** unless you are sure you need a bind. - `gateway.remote.token` / `.password` are client credential sources; they do not configure server auth by themselves. - Local call paths can use `gateway.remote.*` as a 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 for `wss://`, including macOS direct mode. Without a stored pin, macOS only pins on first use after normal system trust passes; self-signed or private-CA Gateways need an explicit fingerprint or Remote over SSH. +- `gateway.remote.tlsFingerprint` pins the remote TLS cert for `wss://`, including both operator/control traffic and the companion node in macOS direct mode. Without a stored pin, macOS pins on first use only after normal system trust passes; self-signed or private-CA Gateways 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 header auth and instead follow the Gateway's normal HTTP auth mode. This tokenless flow assumes the Gateway host is trusted; set it to `false` for shared-secret auth everywhere. - **Trusted-proxy** auth expects a non-loopback identity-aware proxy by default. Same-host loopback reverse proxies require explicit `gateway.auth.trustedProxy.allowLoopback = true`. - Treat browser control like operator access: tailnet-only plus deliberate node pairing. diff --git a/docs/platforms/mac/remote.md b/docs/platforms/mac/remote.md index d8f67f5ea7d3..8b12e0ef2a4f 100644 --- a/docs/platforms/mac/remote.md +++ b/docs/platforms/mac/remote.md @@ -83,6 +83,7 @@ To configure from the UI instead: - Prefer loopback binds on the remote host and connect via SSH, Tailscale Serve, or a trusted Tailnet/LAN direct URL. - SSH tunneling requires an already-trusted host key by default. Trust the host key first (add it to the configured known-hosts file), or explicitly set `gateway.remote.sshHostKeyPolicy: "openssh"` for a managed alias whose OpenSSH trust policy you accept. - If you bind the Gateway to a non-loopback interface, require valid Gateway auth: token, password, or an identity-aware reverse proxy with `gateway.auth.mode: "trusted-proxy"`. +- Direct `wss://` connections apply one certificate policy to both operator/control traffic and the Mac companion node. Set `gateway.remote.tlsFingerprint` for an explicit pin. Without one, the app records a first-use pin only after normal macOS trust succeeds. - See [Security](/gateway/security) and [Tailscale](/gateway/tailscale). ## WhatsApp login flow (remote) @@ -92,14 +93,14 @@ To configure from the UI instead: ## Troubleshooting -| Symptom | Cause / fix | -| ------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `exit 127` / not found | `openclaw` is not on PATH for non-login shells. Add it to `/etc/paths`, your shell rc, or symlink into `/usr/local/bin`/`/opt/homebrew/bin`. | -| Health probe failed | Check SSH reachability, PATH, and that Baileys (WhatsApp) 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 | The 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 once 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 forward automatically in remote mode; no separate forwarder is needed. | +| Symptom | Cause / fix | +| ------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `exit 127` / not found | `openclaw` is not on PATH for non-login shells. Add it to `/etc/paths`, your shell rc, or symlink into `/usr/local/bin`/`/opt/homebrew/bin`. | +| Health probe failed | Check SSH reachability, PATH, and that Baileys (WhatsApp) 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 | The 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`. Direct `wss://` operator and node connections use the same configured or stored certificate policy. For trusted `wss://*.ts.net` Tailscale Serve endpoints, stale stored leaf pins are replaced after certificate rotation and retried automatically. Configured pins never rotate automatically; update `gateway.remote.tlsFingerprint` after reviewing the new certificate, or switch to **Remote over SSH**. | +| Voice Wake | Trigger phrases forward automatically in remote mode; no separate forwarder is needed. | ## Notification sounds diff --git a/docs/platforms/mac/webchat.md b/docs/platforms/mac/webchat.md index 0851612ce2ec..31983d26ecba 100644 --- a/docs/platforms/mac/webchat.md +++ b/docs/platforms/mac/webchat.md @@ -19,9 +19,12 @@ The anchored compact chat panel from the menu bar keeps the compact single-colum ## Multiple Gateway windows Open **Settings → Gateways** to add or remove reusable Gateway profiles. Each -profile contains a `ws://` or `wss://` endpoint and its optional token or -password; credentials are stored in the macOS Keychain. Removing a profile -also closes its open windows and shuts down its secondary connection. +profile contains a private-network `ws://` or secure `wss://` endpoint and its +optional token or password; credentials are stored in the macOS Keychain. +Secure profiles maintain their own system-trust-gated first-use certificate pin +and do not inherit `gateway.remote.tlsFingerprint` from the primary Gateway. +Removing a profile also closes its open windows and shuts down its secondary +connection. Choose **File → New Gateway Window…** or press Cmd-N, then select one of those saved profiles. The picker remembers the most recently used profile. Every @@ -61,7 +64,7 @@ After a reply finishes, choose **Paste to <app>** to copy its visible assi Disable the feature entirely with **Settings → General → Quick Chat**; the same section hosts the shortcut recorder. - **Local mode**: connects directly to the local Gateway WebSocket. -- **Remote mode**: forwards the Gateway control port over SSH and uses that tunnel as the data plane. +- **Remote mode**: uses the configured direct `ws://`/`wss://` route or the app-managed SSH tunnel as the data plane. ## Launch and debugging