mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 16:20:43 +00:00
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:
@@ -2,6 +2,7 @@ import AppKit
|
||||
import AVFoundation
|
||||
import Foundation
|
||||
import Observation
|
||||
import OpenClawKit
|
||||
import SwiftUI
|
||||
|
||||
/// Menu contents for the OpenClaw menu bar extra.
|
||||
@@ -14,6 +15,7 @@ struct MenuContent: View {
|
||||
private let heartbeatStore = HeartbeatStore.shared
|
||||
private let controlChannel = ControlChannel.shared
|
||||
private let activityStore = WorkActivityStore.shared
|
||||
private let nodesStore = NodesStore.shared
|
||||
@Bindable private var pairingPrompter = NodePairingApprovalPrompter.shared
|
||||
@Bindable private var devicePairingPrompter = DevicePairingApprovalPrompter.shared
|
||||
@Environment(\.openSettings) private var openSettings
|
||||
@@ -44,6 +46,9 @@ struct MenuContent: View {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(self.connectionLabel)
|
||||
self.statusLine(label: self.healthStatus.label, color: self.healthStatus.color)
|
||||
if let macNodeStatus = self.macNodeStatus {
|
||||
self.statusLine(label: macNodeStatus.label, color: macNodeStatus.color)
|
||||
}
|
||||
if self.pairingPrompter.pendingCount > 0 {
|
||||
let repairCount = self.pairingPrompter.pendingRepairCount
|
||||
let repairSuffix = repairCount > 0 ? " · \(repairCount) repair" : ""
|
||||
@@ -351,6 +356,31 @@ struct MenuContent: View {
|
||||
}
|
||||
}
|
||||
|
||||
private var macNodeStatus: (label: String, color: Color)? {
|
||||
guard self.state.connectionMode != .unconfigured else { return nil }
|
||||
guard case .connected = self.controlChannel.state else { return nil }
|
||||
|
||||
let deviceId = DeviceIdentityStore.loadOrCreate().deviceId
|
||||
if let entry = self.nodesStore.nodes.first(where: { $0.nodeId == deviceId }) {
|
||||
guard entry.isConnected else {
|
||||
return ("Mac capabilities offline", .orange)
|
||||
}
|
||||
let commands = Set(entry.commands ?? [])
|
||||
let missingRequiredCommands = [
|
||||
OpenClawSystemCommand.notify.rawValue,
|
||||
OpenClawSystemCommand.run.rawValue,
|
||||
OpenClawSystemCommand.which.rawValue,
|
||||
].filter { !commands.contains($0) }
|
||||
if !missingRequiredCommands.isEmpty {
|
||||
return ("Mac capabilities incomplete", .orange)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
guard !self.nodesStore.isLoading, !self.nodesStore.nodes.isEmpty else { return nil }
|
||||
return ("Mac capabilities offline", .orange)
|
||||
}
|
||||
|
||||
private var healthStatus: (label: String, color: Color) {
|
||||
if let activity = self.activityStore.current {
|
||||
let color: Color = activity.role == .main ? .accentColor : .gray
|
||||
|
||||
@@ -1156,7 +1156,7 @@ extension MenuSessionsInjector {
|
||||
}
|
||||
|
||||
private func sortedNodeEntries() -> [NodeInfo] {
|
||||
let entries = self.nodesStore.nodes.filter(\.isConnected)
|
||||
let entries = self.nodesStore.nodes.filter { $0.isConnected || $0.isPaired }
|
||||
return entries.sorted { lhs, rhs in
|
||||
if lhs.isConnected != rhs.isConnected { return lhs.isConnected }
|
||||
if lhs.isPaired != rhs.isPaired { return lhs.isPaired }
|
||||
@@ -1239,5 +1239,9 @@ extension MenuSessionsInjector {
|
||||
func testingFindNodesInsertIndex(in menu: NSMenu) -> Int? {
|
||||
self.findNodesInsertIndex(in: menu)
|
||||
}
|
||||
|
||||
func testingSortedNodeEntries() -> [NodeInfo] {
|
||||
self.sortedNodeEntries()
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -10,6 +10,7 @@ final class MacNodeModeCoordinator {
|
||||
private var task: Task<Void, Never>?
|
||||
private let runtime = MacNodeRuntime()
|
||||
private let session = GatewayNodeSession()
|
||||
private var autoRepairedTLSFingerprintsByStoreKey: [String: String] = [:]
|
||||
|
||||
func start() {
|
||||
guard self.task == nil else { return }
|
||||
@@ -58,8 +59,10 @@ final class MacNodeModeCoordinator {
|
||||
try? await Task.sleep(nanoseconds: 200_000_000)
|
||||
}
|
||||
|
||||
var attemptedURL: URL?
|
||||
do {
|
||||
let config = try await GatewayEndpointStore.shared.requireConfig()
|
||||
attemptedURL = config.url
|
||||
let caps = self.currentCaps()
|
||||
let commands = self.currentCommands(caps: caps)
|
||||
let permissions = await self.currentPermissions()
|
||||
@@ -109,6 +112,10 @@ final class MacNodeModeCoordinator {
|
||||
retryDelay = 1_000_000_000
|
||||
try? await Task.sleep(nanoseconds: 1_000_000_000)
|
||||
} catch {
|
||||
if await self.autoRepairStaleTLSPinIfNeeded(error: error, url: attemptedURL) {
|
||||
retryDelay = 1_000_000_000
|
||||
continue
|
||||
}
|
||||
self.logger.error("mac node gateway connect failed: \(error.localizedDescription, privacy: .public)")
|
||||
try? await Task.sleep(nanoseconds: min(retryDelay, 10_000_000_000))
|
||||
retryDelay = min(retryDelay * 2, 10_000_000_000)
|
||||
@@ -188,11 +195,49 @@ final class MacNodeModeCoordinator {
|
||||
Self.resolvedCommands(caps: caps)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
private func buildSessionBox(url: URL) -> WebSocketSessionBox? {
|
||||
guard url.scheme?.lowercased() == "wss" else { return nil }
|
||||
let host = url.host ?? "gateway"
|
||||
let port = url.port ?? 443
|
||||
let stableID = "\(host):\(port)"
|
||||
let stableID = Self.tlsPinStoreKey(for: url)
|
||||
let stored = GatewayTLSStore.loadFingerprint(stableID: stableID)
|
||||
let params = GatewayTLSParams(
|
||||
required: true,
|
||||
|
||||
@@ -44,10 +44,12 @@ struct NodeMenuEntryFormatter {
|
||||
}
|
||||
|
||||
static func roleText(_ entry: NodeInfo) -> String {
|
||||
if entry.isConnected { return "connected" }
|
||||
if self.isGateway(entry) { return "disconnected" }
|
||||
if entry.isPaired { return "paired" }
|
||||
return "unpaired"
|
||||
if self.isGateway(entry) {
|
||||
return entry.isConnected ? "connected" : "disconnected"
|
||||
}
|
||||
let pairing = entry.isPaired ? "paired" : "unpaired"
|
||||
let connection = entry.isConnected ? "connected" : "disconnected"
|
||||
return "\(pairing) · \(connection)"
|
||||
}
|
||||
|
||||
static func detailLeft(_ entry: NodeInfo) -> String {
|
||||
|
||||
@@ -4,6 +4,30 @@ import Testing
|
||||
@testable import OpenClaw
|
||||
|
||||
struct GatewayChannelConnectTests {
|
||||
private final class TLSFailureSession: WebSocketSessioning, GatewayTLSFailureProviding, @unchecked Sendable {
|
||||
private var failure: GatewayTLSValidationFailure?
|
||||
|
||||
init(failure: GatewayTLSValidationFailure) {
|
||||
self.failure = failure
|
||||
}
|
||||
|
||||
func makeWebSocketTask(url: URL) -> WebSocketTaskBox {
|
||||
_ = url
|
||||
let task = GatewayTestWebSocketTask(receiveHook: { _, receiveIndex in
|
||||
if receiveIndex == 0 {
|
||||
return .data(GatewayWebSocketTestSupport.connectChallengeData())
|
||||
}
|
||||
throw URLError(.userCancelledAuthentication)
|
||||
})
|
||||
return WebSocketTaskBox(task: task)
|
||||
}
|
||||
|
||||
func consumeLastTLSFailure() -> GatewayTLSValidationFailure? {
|
||||
defer { self.failure = nil }
|
||||
return self.failure
|
||||
}
|
||||
}
|
||||
|
||||
private enum FakeResponse {
|
||||
case helloOk(delayMs: Int)
|
||||
case invalid(delayMs: Int)
|
||||
@@ -109,4 +133,28 @@ struct GatewayChannelConnectTests {
|
||||
Issue.record("unexpected error: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
@Test func `connect maps user cancelled authentication with cached TLS failure`() async throws {
|
||||
let failure = GatewayTLSValidationFailure(
|
||||
kind: .pinMismatch,
|
||||
host: "gateway.example.ts.net",
|
||||
storeKey: "gateway.example.ts.net:443",
|
||||
expectedFingerprint: "old",
|
||||
observedFingerprint: "new",
|
||||
systemTrustOk: true)
|
||||
let session = TLSFailureSession(failure: failure)
|
||||
let channel = try GatewayChannelActor(
|
||||
url: #require(URL(string: "wss://gateway.example.ts.net")),
|
||||
token: nil,
|
||||
session: WebSocketSessionBox(session: session))
|
||||
|
||||
do {
|
||||
try await channel.connect()
|
||||
Issue.record("expected GatewayTLSValidationError")
|
||||
} catch let error as GatewayTLSValidationError {
|
||||
#expect(error.failure == failure)
|
||||
} catch {
|
||||
Issue.record("unexpected error: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,4 +29,61 @@ struct MacNodeModeCoordinatorTests {
|
||||
#expect(caps.contains(OpenClawCapability.browser.rawValue))
|
||||
#expect(commands.contains(OpenClawBrowserCommand.proxy.rawValue))
|
||||
}
|
||||
|
||||
@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")
|
||||
}
|
||||
|
||||
@Test func `auto repairs trusted tailscale serve 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: "old",
|
||||
observedFingerprint: "new",
|
||||
systemTrustOk: true)
|
||||
|
||||
#expect(MacNodeModeCoordinator.shouldAutoRepairStaleTLSPin(url: url, failure: failure))
|
||||
}
|
||||
|
||||
@Test func `does not auto repair untrusted remote pin mismatch`() throws {
|
||||
let url = try #require(URL(string: "wss://gateway.example.com"))
|
||||
let failure = GatewayTLSValidationFailure(
|
||||
kind: .pinMismatch,
|
||||
host: "gateway.example.com",
|
||||
storeKey: "gateway.example.com:443",
|
||||
expectedFingerprint: "old",
|
||||
observedFingerprint: "new",
|
||||
systemTrustOk: true)
|
||||
|
||||
#expect(!MacNodeModeCoordinator.shouldAutoRepairStaleTLSPin(url: url, failure: failure))
|
||||
}
|
||||
|
||||
@Test func `auto repairs trusted loopback pin mismatch`() throws {
|
||||
let url = try #require(URL(string: "wss://127.0.0.1:18789"))
|
||||
let failure = GatewayTLSValidationFailure(
|
||||
kind: .pinMismatch,
|
||||
host: "127.0.0.1",
|
||||
storeKey: "127.0.0.1:18789",
|
||||
expectedFingerprint: "old",
|
||||
observedFingerprint: "new",
|
||||
systemTrustOk: true)
|
||||
|
||||
#expect(MacNodeModeCoordinator.shouldAutoRepairStaleTLSPin(url: url, failure: failure))
|
||||
}
|
||||
|
||||
@Test func `does not auto repair untrusted loopback pin mismatch`() throws {
|
||||
let url = try #require(URL(string: "wss://127.0.0.1:18789"))
|
||||
let failure = GatewayTLSValidationFailure(
|
||||
kind: .pinMismatch,
|
||||
host: "127.0.0.1",
|
||||
storeKey: "127.0.0.1:18789",
|
||||
expectedFingerprint: "old",
|
||||
observedFingerprint: "new",
|
||||
systemTrustOk: false)
|
||||
|
||||
#expect(!MacNodeModeCoordinator.shouldAutoRepairStaleTLSPin(url: url, failure: failure))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -165,4 +165,50 @@ struct MenuSessionsInjectorTests {
|
||||
#expect(usageCostItem?.submenu != nil)
|
||||
#expect(usageCostItem?.submenu?.delegate == nil)
|
||||
}
|
||||
|
||||
@Test func `node status text distinguishes paired disconnected nodes`() {
|
||||
let pairedDisconnected = Self.node(id: "paired", paired: true, connected: false)
|
||||
let unpairedDisconnected = Self.node(id: "unpaired", paired: false, connected: false)
|
||||
let connected = Self.node(id: "connected", paired: true, connected: true)
|
||||
|
||||
#expect(NodeMenuEntryFormatter.roleText(pairedDisconnected) == "paired · disconnected")
|
||||
#expect(NodeMenuEntryFormatter.roleText(unpairedDisconnected) == "unpaired · disconnected")
|
||||
#expect(NodeMenuEntryFormatter.roleText(connected) == "paired · connected")
|
||||
}
|
||||
|
||||
@Test func `sorted node entries include paired disconnected nodes`() {
|
||||
let injector = MenuSessionsInjector()
|
||||
defer { NodesStore.shared.nodes = [] }
|
||||
NodesStore.shared.nodes = [
|
||||
Self.node(id: "ignored", paired: false, connected: false, displayName: "Ignored"),
|
||||
Self.node(id: "paired", paired: true, connected: false, displayName: "MacBook"),
|
||||
Self.node(id: "connected", paired: true, connected: true, displayName: "iPhone"),
|
||||
]
|
||||
|
||||
let entries = injector.testingSortedNodeEntries()
|
||||
#expect(entries.map(\.nodeId) == ["connected", "paired"])
|
||||
}
|
||||
|
||||
private static func node(
|
||||
id: String,
|
||||
paired: Bool,
|
||||
connected: Bool,
|
||||
displayName: String? = nil) -> NodeInfo
|
||||
{
|
||||
NodeInfo(
|
||||
nodeId: id,
|
||||
displayName: displayName ?? id,
|
||||
platform: "macOS 26.3.1",
|
||||
version: nil,
|
||||
coreVersion: nil,
|
||||
uiVersion: nil,
|
||||
deviceFamily: "Mac",
|
||||
modelIdentifier: nil,
|
||||
remoteIp: nil,
|
||||
caps: nil,
|
||||
commands: nil,
|
||||
permissions: nil,
|
||||
paired: paired,
|
||||
connected: connected)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user