diff --git a/apps/ios/Sources/Gateway/GatewayConnectionController.swift b/apps/ios/Sources/Gateway/GatewayConnectionController.swift index f82b0d40f76..284b9f737b1 100644 --- a/apps/ios/Sources/Gateway/GatewayConnectionController.swift +++ b/apps/ios/Sources/Gateway/GatewayConnectionController.swift @@ -295,6 +295,47 @@ final class GatewayConnectionController { self.appModel?.gatewayStatusText = "Offline" } + @discardableResult + func trustRotatedGatewayCertificate(from problem: GatewayConnectionProblem) async -> Bool { + guard problem.canTrustRotatedCertificate, + let stableID = problem.tlsStoreKey, + let fingerprint = problem.tlsObservedFingerprint + else { + self.appModel?.gatewayStatusText = "Certificate review required" + return false + } + + guard GatewayTLSStore.replaceFingerprint(fingerprint, stableID: stableID) else { + self.appModel?.gatewayStatusText = "Could not update gateway certificate" + return false + } + + GatewayDiagnostics.log( + "gateway tls pin replaced stableID=\(stableID) " + + "old=\(problem.tlsExpectedFingerprint ?? "unknown") new=\(fingerprint)") + self.appModel?.gatewayStatusText = "Gateway certificate updated. Reconnecting…" + if let appModel = self.appModel, let cfg = appModel.activeGatewayConnectConfig { + let currentTLS = cfg.tls + let refreshedTLS = GatewayTLSParams( + required: currentTLS?.required ?? true, + expectedFingerprint: fingerprint, + allowTOFU: currentTLS?.allowTOFU ?? false, + storeKey: currentTLS?.storeKey ?? stableID) + let refreshedConfig = GatewayConnectConfig( + url: cfg.url, + stableID: cfg.stableID, + tls: refreshedTLS, + token: cfg.token, + bootstrapToken: cfg.bootstrapToken, + password: cfg.password, + nodeOptions: cfg.nodeOptions) + appModel.applyGatewayConnectConfig(refreshedConfig) + } else { + await self.connectLastKnown() + } + return true + } + private func updateFromDiscovery() { let newGateways = self.discovery.gateways self.gateways = newGateways diff --git a/apps/ios/Sources/Gateway/GatewayQuickSetupSheet.swift b/apps/ios/Sources/Gateway/GatewayQuickSetupSheet.swift index c8b4db0aec5..66a10d51e1c 100644 --- a/apps/ios/Sources/Gateway/GatewayQuickSetupSheet.swift +++ b/apps/ios/Sources/Gateway/GatewayQuickSetupSheet.swift @@ -1,3 +1,4 @@ +import OpenClawKit import SwiftUI struct GatewayQuickSetupSheet: View { @@ -19,6 +20,10 @@ struct GatewayQuickSetupSheet: View { if let gatewayProblem = self.appModel.lastGatewayProblem { GatewayProblemBanner( problem: gatewayProblem, + primaryActionTitle: self.gatewayProblemPrimaryActionTitle(gatewayProblem), + onPrimaryAction: { + Task { await self.handleGatewayProblemPrimaryAction(gatewayProblem) } + }, onShowDetails: { self.showGatewayProblemDetails = true }) @@ -115,7 +120,12 @@ struct GatewayQuickSetupSheet: View { } .sheet(isPresented: self.$showGatewayProblemDetails) { if let gatewayProblem = self.appModel.lastGatewayProblem { - GatewayProblemDetailsSheet(problem: gatewayProblem) + GatewayProblemDetailsSheet( + problem: gatewayProblem, + primaryActionTitle: self.gatewayProblemPrimaryActionTitle(gatewayProblem), + onPrimaryAction: { + Task { await self.handleGatewayProblemPrimaryAction(gatewayProblem) } + }) } } } @@ -124,4 +134,21 @@ struct GatewayQuickSetupSheet: View { // Prefer whatever discovery says is first; the list is already name-sorted. self.gatewayController.gateways.first } + + private func gatewayProblemPrimaryActionTitle(_ problem: GatewayConnectionProblem) -> String { + problem.canTrustRotatedCertificate ? "Trust certificate" : "Connect" + } + + private func handleGatewayProblemPrimaryAction(_ problem: GatewayConnectionProblem) async { + if problem.canTrustRotatedCertificate { + _ = await self.gatewayController.trustRotatedGatewayCertificate(from: problem) + return + } + guard let candidate = self.bestCandidate else { return } + self.connectError = nil + self.connecting = true + let err = await self.gatewayController.connectWithDiagnostics(candidate) + self.connecting = false + self.connectError = err + } } diff --git a/apps/ios/Sources/Onboarding/OnboardingWizardView.swift b/apps/ios/Sources/Onboarding/OnboardingWizardView.swift index 3f0f3d09f0b..61abd446019 100644 --- a/apps/ios/Sources/Onboarding/OnboardingWizardView.swift +++ b/apps/ios/Sources/Onboarding/OnboardingWizardView.swift @@ -217,9 +217,9 @@ struct OnboardingWizardView: View { if let currentProblem = self.currentProblem { GatewayProblemDetailsSheet( problem: currentProblem, - primaryActionTitle: "Retry", + primaryActionTitle: self.gatewayProblemPrimaryActionTitle(currentProblem), onPrimaryAction: { - Task { await self.retryLastAttempt() } + Task { await self.handleGatewayProblemPrimaryAction(currentProblem) } }) } } @@ -594,9 +594,9 @@ struct OnboardingWizardView: View { if let problem = self.currentProblem { GatewayProblemBanner( problem: problem, - primaryActionTitle: "Retry connection", + primaryActionTitle: self.gatewayProblemPrimaryActionTitle(problem), onPrimaryAction: { - Task { await self.retryLastAttempt() } + Task { await self.handleGatewayProblemPrimaryAction(problem) } }, onShowDetails: { self.showGatewayProblemDetails = true @@ -1014,6 +1014,22 @@ struct OnboardingWizardView: View { defer { self.connectingGatewayID = nil } await self.gatewayController.connectLastKnown() } + + private func gatewayProblemPrimaryActionTitle(_ problem: GatewayConnectionProblem) -> String { + problem.canTrustRotatedCertificate ? "Trust certificate" : "Retry connection" + } + + private func handleGatewayProblemPrimaryAction(_ problem: GatewayConnectionProblem) async { + if problem.canTrustRotatedCertificate { + self.connectingGatewayID = "trust-certificate" + self.connectMessage = "Updating gateway certificate…" + self.statusLine = "Updating gateway certificate…" + defer { self.connectingGatewayID = nil } + _ = await self.gatewayController.trustRotatedGatewayCertificate(from: problem) + return + } + await self.retryLastAttempt() + } } private struct OnboardingModeRow: View { diff --git a/apps/ios/Sources/RootCanvas.swift b/apps/ios/Sources/RootCanvas.swift index 5440796387a..7a92b47ad62 100644 --- a/apps/ios/Sources/RootCanvas.swift +++ b/apps/ios/Sources/RootCanvas.swift @@ -1,3 +1,4 @@ +import OpenClawKit import OpenClawProtocol import SwiftUI import UIKit @@ -454,6 +455,7 @@ private struct HomeCanvasAgentCard: Codable { private struct CanvasContent: View { @Environment(NodeAppModel.self) private var appModel + @Environment(GatewayConnectionController.self) private var gatewayController @AppStorage("talk.enabled") private var talkEnabled: Bool = false @AppStorage("talk.button.enabled") private var talkButtonEnabled: Bool = true @State private var showGatewayActions: Bool = false @@ -522,13 +524,9 @@ private struct CanvasContent: View { { GatewayProblemBanner( problem: gatewayProblem, - primaryActionTitle: gatewayProblem.retryable ? "Retry" : "Open Settings", + primaryActionTitle: self.gatewayProblemPrimaryActionTitle(gatewayProblem), onPrimaryAction: { - if gatewayProblem.retryable { - self.retryGatewayConnection() - } else { - self.openSettings() - } + self.handleGatewayProblemPrimaryAction(gatewayProblem) }, onShowDetails: { self.showGatewayProblemDetails = true @@ -556,9 +554,9 @@ private struct CanvasContent: View { if let gatewayProblem = self.appModel.lastGatewayProblem { GatewayProblemDetailsSheet( problem: gatewayProblem, - primaryActionTitle: "Open Settings", + primaryActionTitle: self.gatewayProblemPrimaryActionTitle(gatewayProblem), onPrimaryAction: { - self.openSettings() + self.handleGatewayProblemPrimaryAction(gatewayProblem) }) } } @@ -577,6 +575,21 @@ private struct CanvasContent: View { cameraHUDText: self.cameraHUDText, cameraHUDKind: self.cameraHUDKind) } + + private func gatewayProblemPrimaryActionTitle(_ problem: GatewayConnectionProblem) -> String { + if problem.canTrustRotatedCertificate { return "Trust certificate" } + return problem.retryable ? "Retry" : "Open Settings" + } + + private func handleGatewayProblemPrimaryAction(_ problem: GatewayConnectionProblem) { + if problem.canTrustRotatedCertificate { + Task { await self.gatewayController.trustRotatedGatewayCertificate(from: problem) } + } else if problem.retryable { + self.retryGatewayConnection() + } else { + self.openSettings() + } + } } private struct CameraFlashOverlay: View { diff --git a/apps/ios/Sources/RootTabs.swift b/apps/ios/Sources/RootTabs.swift index e79032bd84e..05f2d5f0b42 100644 --- a/apps/ios/Sources/RootTabs.swift +++ b/apps/ios/Sources/RootTabs.swift @@ -1,8 +1,10 @@ +import OpenClawKit import SwiftUI struct RootTabs: View { @Environment(NodeAppModel.self) private var appModel @Environment(VoiceWakeManager.self) private var voiceWake + @Environment(GatewayConnectionController.self) private var gatewayController @Environment(\.accessibilityReduceMotion) private var reduceMotion @AppStorage(VoiceWakePreferences.enabledKey) private var voiceWakeEnabled: Bool = false @State private var selectedTab: Int = 0 @@ -48,9 +50,9 @@ struct RootTabs: View { { GatewayProblemBanner( problem: gatewayProblem, - primaryActionTitle: "Open Settings", + primaryActionTitle: self.gatewayProblemPrimaryActionTitle(gatewayProblem), onPrimaryAction: { - self.selectedTab = 2 + self.handleGatewayProblemPrimaryAction(gatewayProblem) }, onShowDetails: { self.showGatewayProblemDetails = true @@ -99,9 +101,9 @@ struct RootTabs: View { if let gatewayProblem = self.appModel.lastGatewayProblem { GatewayProblemDetailsSheet( problem: gatewayProblem, - primaryActionTitle: "Open Settings", + primaryActionTitle: self.gatewayProblemPrimaryActionTitle(gatewayProblem), onPrimaryAction: { - self.selectedTab = 2 + self.handleGatewayProblemPrimaryAction(gatewayProblem) }) } } @@ -118,4 +120,16 @@ struct RootTabs: View { cameraHUDText: self.appModel.cameraHUDText, cameraHUDKind: self.appModel.cameraHUDKind) } + + private func gatewayProblemPrimaryActionTitle(_ problem: GatewayConnectionProblem) -> String { + problem.canTrustRotatedCertificate ? "Trust certificate" : "Open Settings" + } + + private func handleGatewayProblemPrimaryAction(_ problem: GatewayConnectionProblem) { + if problem.canTrustRotatedCertificate { + Task { await self.gatewayController.trustRotatedGatewayCertificate(from: problem) } + } else { + self.selectedTab = 2 + } + } } diff --git a/apps/ios/Sources/Settings/SettingsTab.swift b/apps/ios/Sources/Settings/SettingsTab.swift index 38057784870..c9a08b00ac2 100644 --- a/apps/ios/Sources/Settings/SettingsTab.swift +++ b/apps/ios/Sources/Settings/SettingsTab.swift @@ -72,9 +72,9 @@ struct SettingsTab: View { { GatewayProblemBanner( problem: gatewayProblem, - primaryActionTitle: "Retry connection", + primaryActionTitle: self.gatewayProblemPrimaryActionTitle(gatewayProblem), onPrimaryAction: { - Task { await self.retryGatewayConnectionFromProblem() } + Task { await self.handleGatewayProblemPrimaryAction(gatewayProblem) } }, onShowDetails: { self.showGatewayProblemDetails = true @@ -433,9 +433,9 @@ struct SettingsTab: View { if let gatewayProblem = self.appModel.lastGatewayProblem { GatewayProblemDetailsSheet( problem: gatewayProblem, - primaryActionTitle: "Retry", + primaryActionTitle: self.gatewayProblemPrimaryActionTitle(gatewayProblem), onPrimaryAction: { - Task { await self.retryGatewayConnectionFromProblem() } + Task { await self.handleGatewayProblemPrimaryAction(gatewayProblem) } }) } } @@ -1062,6 +1062,18 @@ struct SettingsTab: View { await self.connectLastKnown() } + private func gatewayProblemPrimaryActionTitle(_ problem: GatewayConnectionProblem) -> String { + problem.canTrustRotatedCertificate ? "Trust certificate" : "Retry connection" + } + + private func handleGatewayProblemPrimaryAction(_ problem: GatewayConnectionProblem) async { + if problem.canTrustRotatedCertificate { + _ = await self.gatewayController.trustRotatedGatewayCertificate(from: problem) + return + } + await self.retryGatewayConnectionFromProblem() + } + private func resetOnboarding() { // Disconnect first so RootCanvas doesn't instantly mark onboarding complete again. self.appModel.disconnectGateway() diff --git a/apps/ios/Tests/GatewayConnectionSecurityTests.swift b/apps/ios/Tests/GatewayConnectionSecurityTests.swift index 06b63d84628..2dd54eb7b4d 100644 --- a/apps/ios/Tests/GatewayConnectionSecurityTests.swift +++ b/apps/ios/Tests/GatewayConnectionSecurityTests.swift @@ -155,4 +155,48 @@ import Testing #expect(GatewayTLSStore.loadFingerprint(stableID: stableID1) == nil) #expect(GatewayTLSStore.loadFingerprint(stableID: stableID2) == nil) } + + @Test func trustedPinMismatchCanBeRecoveredByReplacingStoredPin() { + let stableID = "test|\(UUID().uuidString)" + defer { GatewayTLSStore.clearFingerprint(stableID: stableID) } + GatewayTLSStore.saveFingerprint("old", stableID: stableID) + + let error = GatewayTLSValidationError( + failure: GatewayTLSValidationFailure( + kind: .pinMismatch, + host: "gateway.tailnet.ts.net", + storeKey: stableID, + expectedFingerprint: "old", + observedFingerprint: "new", + systemTrustOk: true), + context: "connect to gateway") + + let problem = GatewayConnectionProblemMapper.map(error: error) + + #expect(problem?.kind == .tlsPinMismatch) + #expect(problem?.canTrustRotatedCertificate == true) + #expect(problem?.tlsStoreKey == stableID) + #expect(problem?.tlsExpectedFingerprint == "old") + #expect(problem?.tlsObservedFingerprint == "new") + + #expect(GatewayTLSStore.replaceFingerprint(problem?.tlsObservedFingerprint ?? "", stableID: stableID)) + #expect(GatewayTLSStore.loadFingerprint(stableID: stableID) == "new") + } + + @Test func untrustedPinMismatchCannotBeRecoveredInApp() { + let error = GatewayTLSValidationError( + failure: GatewayTLSValidationFailure( + kind: .pinMismatch, + host: "gateway.tailnet.ts.net", + storeKey: "gateway", + expectedFingerprint: "old", + observedFingerprint: "new", + systemTrustOk: false), + context: "connect to gateway") + + let problem = GatewayConnectionProblemMapper.map(error: error) + + #expect(problem?.kind == .tlsPinMismatch) + #expect(problem?.canTrustRotatedCertificate == false) + } } diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayConnectionProblem.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayConnectionProblem.swift index f7d71cbf3e7..8713a8ebfc5 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayConnectionProblem.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayConnectionProblem.swift @@ -55,6 +55,10 @@ public struct GatewayConnectionProblem: Equatable, Sendable { public let retryable: Bool public let pauseReconnect: Bool public let technicalDetails: String? + public let tlsStoreKey: String? + public let tlsExpectedFingerprint: String? + public let tlsObservedFingerprint: String? + public let tlsSystemTrustOk: Bool public init( kind: Kind, @@ -67,7 +71,11 @@ public struct GatewayConnectionProblem: Equatable, Sendable { requestId: String? = nil, retryable: Bool, pauseReconnect: Bool, - technicalDetails: String? = nil) + technicalDetails: String? = nil, + tlsStoreKey: String? = nil, + tlsExpectedFingerprint: String? = nil, + tlsObservedFingerprint: String? = nil, + tlsSystemTrustOk: Bool = false) { self.kind = kind self.owner = owner @@ -80,6 +88,10 @@ public struct GatewayConnectionProblem: Equatable, Sendable { self.retryable = retryable self.pauseReconnect = pauseReconnect self.technicalDetails = Self.trimmedOrNil(technicalDetails) + self.tlsStoreKey = Self.trimmedOrNil(tlsStoreKey) + self.tlsExpectedFingerprint = Self.trimmedOrNil(tlsExpectedFingerprint) + self.tlsObservedFingerprint = Self.trimmedOrNil(tlsObservedFingerprint) + self.tlsSystemTrustOk = tlsSystemTrustOk } public var needsPairingApproval: Bool { @@ -121,6 +133,13 @@ public struct GatewayConnectionProblem: Equatable, Sendable { } } + public var canTrustRotatedCertificate: Bool { + self.kind == .tlsPinMismatch + && self.tlsSystemTrustOk + && self.tlsStoreKey != nil + && self.tlsObservedFingerprint != nil + } + private static func trimmedOrNil(_ value: String?) -> String? { let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" return trimmed.isEmpty ? nil : trimmed @@ -541,7 +560,11 @@ public enum GatewayConnectionProblemMapper { docsURL: URL(string: "https://docs.openclaw.ai/gateway/troubleshooting"), retryable: false, pauseReconnect: true, - technicalDetails: tlsError.localizedDescription) + technicalDetails: tlsError.localizedDescription, + tlsStoreKey: failure.storeKey, + tlsExpectedFingerprint: failure.expectedFingerprint, + tlsObservedFingerprint: failure.observedFingerprint, + tlsSystemTrustOk: failure.systemTrustOk) case .certificateUnavailable: return GatewayConnectionProblem( kind: .tlsCertificateUnavailable, diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayNodeSession.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayNodeSession.swift index 4e497ae2039..19730692846 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayNodeSession.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayNodeSession.swift @@ -53,6 +53,7 @@ public actor GatewayNodeSession { private var activeBootstrapToken: String? private var activePassword: String? private var activeConnectOptionsKey: String? + private var activeSessionIdentity: ObjectIdentifier? private var connectOptions: GatewayConnectOptions? private var onConnected: (@Sendable () async -> Void)? private var onDisconnected: (@Sendable (String) async -> Void)? @@ -195,11 +196,13 @@ public actor GatewayNodeSession { onInvoke: @escaping @Sendable (BridgeInvokeRequest) async -> BridgeInvokeResponse) async throws { let nextOptionsKey = self.connectOptionsKey(connectOptions) + let nextSessionIdentity = sessionBox.map { ObjectIdentifier($0.session) } let shouldReconnect = self.activeURL != url || self.activeToken != token || self.activeBootstrapToken != bootstrapToken || self.activePassword != password || self.activeConnectOptionsKey != nextOptionsKey || + self.activeSessionIdentity != nextSessionIdentity || self.channel == nil self.connectOptions = connectOptions @@ -231,6 +234,7 @@ public actor GatewayNodeSession { self.activeBootstrapToken = bootstrapToken self.activePassword = password self.activeConnectOptionsKey = nextOptionsKey + self.activeSessionIdentity = nextSessionIdentity } guard let channel = self.channel else { @@ -256,6 +260,7 @@ public actor GatewayNodeSession { self.activeBootstrapToken = nil self.activePassword = nil self.activeConnectOptionsKey = nil + self.activeSessionIdentity = nil self.hasEverConnected = false self.resetConnectionState() } diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayErrorsTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayErrorsTests.swift index 4f64a53ed53..09437f88e42 100644 --- a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayErrorsTests.swift +++ b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayErrorsTests.swift @@ -107,6 +107,10 @@ import Testing #expect(problem?.retryable == false) #expect(problem?.pauseReconnect == true) #expect(problem?.actionLabel == "Review certificate") + #expect(problem?.canTrustRotatedCertificate == true) + #expect(problem?.tlsStoreKey == "gateway.example.ts.net:443") + #expect(problem?.tlsExpectedFingerprint == "old") + #expect(problem?.tlsObservedFingerprint == "new") } @Test func untrustedTLSCertificatePausesReconnect() { @@ -126,4 +130,21 @@ import Testing #expect(problem?.retryable == false) #expect(problem?.pauseReconnect == true) } + + @Test func untrustedTLSMismatchCannotBeRecoveredInApp() { + let error = GatewayTLSValidationError( + failure: GatewayTLSValidationFailure( + kind: .pinMismatch, + host: "gateway.example.ts.net", + storeKey: "gateway.example.ts.net:443", + expectedFingerprint: "old", + observedFingerprint: "new", + systemTrustOk: false), + context: "connect to gateway") + + let problem = GatewayConnectionProblemMapper.map(error: error) + + #expect(problem?.kind == .tlsPinMismatch) + #expect(problem?.canTrustRotatedCertificate == false) + } } diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayNodeSessionTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayNodeSessionTests.swift index bb7f074b076..f9dac81baff 100644 --- a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayNodeSessionTests.swift +++ b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayNodeSessionTests.swift @@ -285,6 +285,54 @@ struct GatewayNodeSessionTests { await gateway.disconnect() } + @Test + func changedSessionBoxRebuildsExistingGatewayChannel() async throws { + let firstSession = FakeGatewayWebSocketSession() + let secondSession = FakeGatewayWebSocketSession() + let gateway = GatewayNodeSession() + let options = GatewayConnectOptions( + role: "node", + scopes: [], + caps: [], + commands: [], + permissions: [:], + clientId: "openclaw-ios-test", + clientMode: "node", + clientDisplayName: "iOS Test", + includeDeviceIdentity: false) + + try await gateway.connect( + url: URL(string: "wss://example.invalid")!, + token: "shared-token", + bootstrapToken: nil, + password: nil, + connectOptions: options, + sessionBox: WebSocketSessionBox(session: firstSession), + onConnected: {}, + onDisconnected: { _ in }, + onInvoke: { req in + BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: nil, error: nil) + }) + + try await gateway.connect( + url: URL(string: "wss://example.invalid")!, + token: "shared-token", + bootstrapToken: nil, + password: nil, + connectOptions: options, + sessionBox: WebSocketSessionBox(session: secondSession), + onConnected: {}, + onDisconnected: { _ in }, + onInvoke: { req in + BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: nil, error: nil) + }) + + #expect(firstSession.snapshotMakeCount() == 1) + #expect(secondSession.snapshotMakeCount() == 1) + + await gateway.disconnect() + } + @Test func bootstrapHelloStoresAdditionalDeviceTokens() async throws { let tempDir = FileManager.default.temporaryDirectory