fix(ios): recover rotated gateway certificates

## Summary
- allow iOS to trust system-valid rotated gateway certificates
- rebuild active gateway sessions after replacing the stored TLS pin
- expose certificate trust recovery from gateway problem banners

## Verification
- swift test --filter 'GatewayErrorsTests|GatewayNodeSessionTests/changedSessionBoxRebuildsExistingGatewayChannel'
- xcodebuild build -scheme OpenClaw -destination 'platform=iOS,id=00008140-000848A92EE3001C'
- installed and launched OpenClaw on attached iPhone with devicectl
- verified iOS gateway log connected to wss://gutsy-home.tail06a72.ts.net:443 after trust/pairing recovery
This commit is contained in:
Nimrod Gutman
2026-05-10 21:10:35 +03:00
committed by GitHub
parent 7139aa8ad4
commit 00a0858fd9
11 changed files with 287 additions and 23 deletions

View File

@@ -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

View File

@@ -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
}
}

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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
}
}
}

View File

@@ -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()

View File

@@ -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)
}
}

View File

@@ -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,

View File

@@ -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()
}

View File

@@ -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)
}
}

View File

@@ -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