fix(macos): repair stale gateway tls pins (#75038)

Merged via squash.

Prepared head SHA: 35196f8f71
Co-authored-by: ngutman <1540134+ngutman@users.noreply.github.com>
Co-authored-by: ngutman <1540134+ngutman@users.noreply.github.com>
Reviewed-by: @ngutman
This commit is contained in:
Nimrod Gutman
2026-04-30 14:14:03 +03:00
committed by GitHub
parent 29d3b65a83
commit eecd758e39
13 changed files with 447 additions and 13 deletions

View File

@@ -1010,10 +1010,13 @@ public actor GatewayChannelActor {
/// Wrap low-level URLSession/WebSocket errors with context so UI can surface them.
private func wrap(_ error: Error, context: String) -> Error {
if error is GatewayConnectAuthError || error is GatewayResponseError || error is GatewayDecodingError {
if error is GatewayConnectAuthError || error is GatewayResponseError || error is GatewayDecodingError || error is GatewayTLSValidationError {
return error
}
if let urlError = error as? URLError {
if let failure = (self.session as? GatewayTLSFailureProviding)?.consumeLastTLSFailure() {
return GatewayTLSValidationError(failure: failure, context: context)
}
let desc = urlError.localizedDescription.isEmpty ? "cancelled" : urlError.localizedDescription
return NSError(
domain: URLError.errorDomain,

View File

@@ -30,6 +30,9 @@ public struct GatewayConnectionProblem: Equatable, Sendable {
case connectionRefused
case reachabilityFailed
case websocketCancelled
case tlsPinMismatch
case tlsCertificateUntrusted
case tlsCertificateUnavailable
case unknown
}
@@ -170,6 +173,9 @@ public enum GatewayConnectionProblemMapper {
if let responseError = error as? GatewayResponseError {
return self.map(responseError)
}
if let tlsError = error as? GatewayTLSValidationError {
return self.map(tlsError)
}
return self.mapTransportError(error)
}
@@ -518,6 +524,51 @@ public enum GatewayConnectionProblemMapper {
return nil
}
private static func map(_ tlsError: GatewayTLSValidationError) -> GatewayConnectionProblem {
let failure = tlsError.failure
switch failure.kind {
case .pinMismatch:
let trustedSuffix = failure.systemTrustOk
? " The new certificate is trusted by this device; this is commonly caused by certificate rotation."
: " This device could not verify the new certificate."
return GatewayConnectionProblem(
kind: .tlsPinMismatch,
owner: failure.systemTrustOk ? .network : .unknown,
title: "Gateway certificate changed",
message: "The saved TLS certificate pin for \(failure.host) no longer matches the gateway certificate.\(trustedSuffix)",
actionLabel: "Review certificate",
actionCommand: nil,
docsURL: URL(string: "https://docs.openclaw.ai/gateway/troubleshooting"),
retryable: false,
pauseReconnect: true,
technicalDetails: tlsError.localizedDescription)
case .certificateUnavailable:
return GatewayConnectionProblem(
kind: .tlsCertificateUnavailable,
owner: .network,
title: "Gateway certificate unavailable",
message: "OpenClaw could not read the gateway certificate for \(failure.host).",
actionLabel: "Retry",
actionCommand: nil,
docsURL: URL(string: "https://docs.openclaw.ai/gateway/troubleshooting"),
retryable: true,
pauseReconnect: false,
technicalDetails: tlsError.localizedDescription)
case .untrustedCertificate:
return GatewayConnectionProblem(
kind: .tlsCertificateUntrusted,
owner: .network,
title: "Gateway certificate is not trusted",
message: "This device does not trust the TLS certificate presented by \(failure.host).",
actionLabel: "Check certificate",
actionCommand: nil,
docsURL: URL(string: "https://docs.openclaw.ai/gateway/troubleshooting"),
retryable: false,
pauseReconnect: true,
technicalDetails: tlsError.localizedDescription)
}
}
private static func mapTransportError(_ error: Error) -> GatewayConnectionProblem? {
let nsError = error as NSError
let rawMessage = nsError.userInfo[NSLocalizedDescriptionKey] as? String ?? nsError.localizedDescription

View File

@@ -16,6 +16,65 @@ public struct GatewayTLSParams: Sendable {
}
}
public enum GatewayTLSValidationFailureKind: String, Sendable {
case pinMismatch
case certificateUnavailable
case untrustedCertificate
}
public struct GatewayTLSValidationFailure: Equatable, Sendable {
public let kind: GatewayTLSValidationFailureKind
public let host: String
public let storeKey: String?
public let expectedFingerprint: String?
public let observedFingerprint: String?
public let systemTrustOk: Bool
public init(
kind: GatewayTLSValidationFailureKind,
host: String,
storeKey: String?,
expectedFingerprint: String?,
observedFingerprint: String?,
systemTrustOk: Bool)
{
self.kind = kind
self.host = host
self.storeKey = storeKey
self.expectedFingerprint = expectedFingerprint
self.observedFingerprint = observedFingerprint
self.systemTrustOk = systemTrustOk
}
}
public struct GatewayTLSValidationError: LocalizedError, Sendable {
public let failure: GatewayTLSValidationFailure
public let context: String
public init(failure: GatewayTLSValidationFailure, context: String) {
self.failure = failure
self.context = context
}
public var errorDescription: String? {
let prefix = self.context.trimmingCharacters(in: .whitespacesAndNewlines)
switch self.failure.kind {
case .pinMismatch:
let expected = self.failure.expectedFingerprint ?? "unknown"
let observed = self.failure.observedFingerprint ?? "unknown"
return "\(prefix): TLS certificate pin mismatch for \(self.failure.host) (expected \(expected), observed \(observed))"
case .certificateUnavailable:
return "\(prefix): TLS certificate unavailable for \(self.failure.host)"
case .untrustedCertificate:
return "\(prefix): TLS certificate is not trusted for \(self.failure.host)"
}
}
}
public protocol GatewayTLSFailureProviding: AnyObject {
func consumeLastTLSFailure() -> GatewayTLSValidationFailure?
}
public enum GatewayTLSStore {
private static let keychainService = "ai.openclaw.tls-pinning"
@@ -35,6 +94,15 @@ public enum GatewayTLSStore {
_ = GenericPasswordKeychainStore.saveString(value, service: self.keychainService, account: stableID)
}
@discardableResult
public static func replaceFingerprint(_ value: String, stableID: String) -> Bool {
guard GenericPasswordKeychainStore.saveString(value, service: self.keychainService, account: stableID) else {
return false
}
self.clearLegacyFingerprint(stableID: stableID)
return true
}
@discardableResult
public static func clearFingerprint(stableID: String) -> Bool {
let removedKeychain = GenericPasswordKeychainStore.delete(
@@ -87,8 +155,10 @@ public enum GatewayTLSStore {
}
}
public final class GatewayTLSPinningSession: NSObject, WebSocketSessioning, URLSessionDelegate, @unchecked Sendable {
public final class GatewayTLSPinningSession: NSObject, WebSocketSessioning, URLSessionDelegate, GatewayTLSFailureProviding, @unchecked Sendable {
private let params: GatewayTLSParams
private let failureLock = NSLock()
private var lastTLSFailure: GatewayTLSValidationFailure?
private lazy var session: URLSession = {
let config = URLSessionConfiguration.default
config.waitsForConnectivity = true
@@ -100,6 +170,26 @@ public final class GatewayTLSPinningSession: NSObject, WebSocketSessioning, URLS
super.init()
}
public func consumeLastTLSFailure() -> GatewayTLSValidationFailure? {
self.failureLock.lock()
defer { self.failureLock.unlock() }
let failure = self.lastTLSFailure
self.lastTLSFailure = nil
return failure
}
private func recordTLSFailure(_ failure: GatewayTLSValidationFailure) {
self.failureLock.lock()
self.lastTLSFailure = failure
self.failureLock.unlock()
}
private func clearTLSFailure() {
self.failureLock.lock()
self.lastTLSFailure = nil
self.failureLock.unlock()
}
public func makeWebSocketTask(url: URL) -> WebSocketTaskBox {
let task = self.session.webSocketTask(with: url)
task.maximumMessageSize = 16 * 1024 * 1024
@@ -118,12 +208,23 @@ public final class GatewayTLSPinningSession: NSObject, WebSocketSessioning, URLS
return
}
let host = challenge.protectionSpace.host
let systemTrustOk = SecTrustEvaluateWithError(trust, nil)
let expected = self.params.expectedFingerprint.map(normalizeFingerprint)
if let fingerprint = certificateFingerprint(trust) {
let fingerprint = certificateFingerprint(trust)
if let fingerprint {
if let expected {
if fingerprint == expected {
self.clearTLSFailure()
completionHandler(.useCredential, URLCredential(trust: trust))
} else {
self.recordTLSFailure(GatewayTLSValidationFailure(
kind: .pinMismatch,
host: host,
storeKey: self.params.storeKey,
expectedFingerprint: expected,
observedFingerprint: fingerprint,
systemTrustOk: systemTrustOk))
completionHandler(.cancelAuthenticationChallenge, nil)
}
return
@@ -132,15 +233,23 @@ public final class GatewayTLSPinningSession: NSObject, WebSocketSessioning, URLS
if let storeKey = params.storeKey {
GatewayTLSStore.saveFingerprint(fingerprint, stableID: storeKey)
}
self.clearTLSFailure()
completionHandler(.useCredential, URLCredential(trust: trust))
return
}
}
let ok = SecTrustEvaluateWithError(trust, nil)
if ok || !self.params.required {
if systemTrustOk || !self.params.required {
self.clearTLSFailure()
completionHandler(.useCredential, URLCredential(trust: trust))
} else {
self.recordTLSFailure(GatewayTLSValidationFailure(
kind: fingerprint == nil ? .certificateUnavailable : .untrustedCertificate,
host: host,
storeKey: self.params.storeKey,
expectedFingerprint: expected,
observedFingerprint: fingerprint,
systemTrustOk: false))
completionHandler(.cancelAuthenticationChallenge, nil)
}
}

View File

@@ -89,4 +89,41 @@ import Testing
#expect(mapped == nil)
}
@Test func tlsPinMismatchMapsToActionableProblem() {
let error = GatewayTLSValidationError(
failure: GatewayTLSValidationFailure(
kind: .pinMismatch,
host: "gateway.example.ts.net",
storeKey: "gateway.example.ts.net:443",
expectedFingerprint: "old",
observedFingerprint: "new",
systemTrustOk: true),
context: "connect to gateway")
let problem = GatewayConnectionProblemMapper.map(error: error)
#expect(problem?.kind == .tlsPinMismatch)
#expect(problem?.retryable == false)
#expect(problem?.pauseReconnect == true)
#expect(problem?.actionLabel == "Review certificate")
}
@Test func untrustedTLSCertificatePausesReconnect() {
let error = GatewayTLSValidationError(
failure: GatewayTLSValidationFailure(
kind: .untrustedCertificate,
host: "gateway.example.com",
storeKey: "gateway.example.com:443",
expectedFingerprint: nil,
observedFingerprint: nil,
systemTrustOk: false),
context: "connect to gateway")
let problem = GatewayConnectionProblemMapper.map(error: error)
#expect(problem?.kind == .tlsCertificateUntrusted)
#expect(problem?.retryable == false)
#expect(problem?.pauseReconnect == true)
}
}