mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-06 20:43:44 +00:00
* fix(ios): classify gateway TLS fingerprint timeouts * Add discovered TLS trust regression test
325 lines
14 KiB
Swift
325 lines
14 KiB
Swift
import Foundation
|
|
import Network
|
|
import OpenClawKit
|
|
import Testing
|
|
import os
|
|
@testable import OpenClaw
|
|
|
|
@Suite(.serialized) struct GatewayConnectionSecurityTests {
|
|
@MainActor
|
|
private func makeController() -> GatewayConnectionController {
|
|
GatewayConnectionController(appModel: NodeAppModel(), startDiscovery: false)
|
|
}
|
|
|
|
private func makeDiscoveredGateway(
|
|
stableID: String,
|
|
lanHost: String?,
|
|
tailnetDns: String?,
|
|
gatewayPort: Int?,
|
|
fingerprint: String?) -> GatewayDiscoveryModel.DiscoveredGateway
|
|
{
|
|
let endpoint: NWEndpoint = .service(name: "Test", type: "_openclaw-gw._tcp", domain: "local.", interface: nil)
|
|
return GatewayDiscoveryModel.DiscoveredGateway(
|
|
name: "Test",
|
|
endpoint: endpoint,
|
|
stableID: stableID,
|
|
debugID: "debug",
|
|
lanHost: lanHost,
|
|
tailnetDns: tailnetDns,
|
|
gatewayPort: gatewayPort,
|
|
canvasPort: nil,
|
|
tlsEnabled: true,
|
|
tlsFingerprintSha256: fingerprint,
|
|
cliPath: nil)
|
|
}
|
|
|
|
private func clearTLSFingerprint(stableID: String) {
|
|
GatewayTLSStore.clearFingerprint(stableID: stableID)
|
|
}
|
|
|
|
@Test @MainActor func discoveredTLSParams_prefersStoredPinOverAdvertisedTXT() async {
|
|
let stableID = "test|\(UUID().uuidString)"
|
|
defer { clearTLSFingerprint(stableID: stableID) }
|
|
self.clearTLSFingerprint(stableID: stableID)
|
|
|
|
GatewayTLSStore.saveFingerprint("11", stableID: stableID)
|
|
|
|
let gateway = self.makeDiscoveredGateway(
|
|
stableID: stableID,
|
|
lanHost: "evil.example.com",
|
|
tailnetDns: "evil.example.com",
|
|
gatewayPort: 12345,
|
|
fingerprint: "22")
|
|
let controller = self.makeController()
|
|
|
|
let params = controller._test_resolveDiscoveredTLSParams(gateway: gateway)
|
|
#expect(params?.expectedFingerprint == "11")
|
|
#expect(params?.allowTOFU == false)
|
|
}
|
|
|
|
@Test @MainActor func discoveredTLSParams_doesNotTrustAdvertisedFingerprint() async {
|
|
let stableID = "test|\(UUID().uuidString)"
|
|
defer { clearTLSFingerprint(stableID: stableID) }
|
|
self.clearTLSFingerprint(stableID: stableID)
|
|
|
|
let gateway = self.makeDiscoveredGateway(
|
|
stableID: stableID,
|
|
lanHost: nil,
|
|
tailnetDns: nil,
|
|
gatewayPort: nil,
|
|
fingerprint: "22")
|
|
let controller = self.makeController()
|
|
|
|
let params = controller._test_resolveDiscoveredTLSParams(gateway: gateway)
|
|
#expect(params?.expectedFingerprint == nil)
|
|
#expect(params?.allowTOFU == false)
|
|
}
|
|
|
|
@Test @MainActor func autoconnectRequiresStoredPinForDiscoveredGateways() async {
|
|
let stableID = "test|\(UUID().uuidString)"
|
|
defer { clearTLSFingerprint(stableID: stableID) }
|
|
self.clearTLSFingerprint(stableID: stableID)
|
|
|
|
let defaults = UserDefaults.standard
|
|
defaults.set(true, forKey: "gateway.autoconnect")
|
|
defaults.set(false, forKey: "gateway.manual.enabled")
|
|
defaults.removeObject(forKey: "gateway.last.host")
|
|
defaults.removeObject(forKey: "gateway.last.port")
|
|
defaults.removeObject(forKey: "gateway.last.tls")
|
|
defaults.removeObject(forKey: "gateway.last.stableID")
|
|
defaults.removeObject(forKey: "gateway.last.kind")
|
|
defaults.removeObject(forKey: "gateway.preferredStableID")
|
|
defaults.set(stableID, forKey: "gateway.lastDiscoveredStableID")
|
|
|
|
let gateway = self.makeDiscoveredGateway(
|
|
stableID: stableID,
|
|
lanHost: "test.local",
|
|
tailnetDns: nil,
|
|
gatewayPort: 18789,
|
|
fingerprint: nil)
|
|
let controller = self.makeController()
|
|
controller._test_setGateways([gateway])
|
|
controller._test_triggerAutoConnect()
|
|
|
|
#expect(controller._test_didAutoConnect() == false)
|
|
}
|
|
|
|
@Test @MainActor func manualConnectionsForceTLSForNonLoopbackHosts() async {
|
|
let controller = self.makeController()
|
|
|
|
#expect(controller._test_resolveManualUseTLS(host: "gateway.example.com", useTLS: false) == true)
|
|
#expect(controller._test_resolveManualUseTLS(host: "127.attacker.example", useTLS: false) == true)
|
|
#expect(controller._test_resolveManualUseTLS(host: "gateway.ts.net", useTLS: false) == true)
|
|
#expect(controller._test_resolveManualUseTLS(host: "100.64.0.9", useTLS: false) == true)
|
|
|
|
#expect(controller._test_resolveManualUseTLS(host: "localhost", useTLS: false) == false)
|
|
#expect(controller._test_resolveManualUseTLS(host: "127.0.0.1", useTLS: false) == false)
|
|
#expect(controller._test_resolveManualUseTLS(host: "::1", useTLS: false) == false)
|
|
#expect(controller._test_resolveManualUseTLS(host: "[::1]", useTLS: false) == false)
|
|
#expect(controller._test_resolveManualUseTLS(host: "::ffff:127.0.0.1", useTLS: false) == false)
|
|
#expect(controller._test_resolveManualUseTLS(host: "0.0.0.0", useTLS: false) == false)
|
|
}
|
|
|
|
@Test @MainActor func manualConnectionsAllowPrivateLanPlaintext() async {
|
|
let controller = self.makeController()
|
|
|
|
#expect(controller._test_resolveManualUseTLS(host: "openclaw.local", useTLS: false) == false)
|
|
#expect(controller._test_resolveManualUseTLS(host: "192.168.1.20", useTLS: false) == false)
|
|
#expect(controller._test_resolveManualUseTLS(host: "10.0.0.5", useTLS: false) == false)
|
|
#expect(controller._test_resolveManualUseTLS(host: "172.16.1.5", useTLS: false) == false)
|
|
#expect(controller._test_resolveManualUseTLS(host: "169.254.1.5", useTLS: false) == false)
|
|
#expect(controller._test_resolveManualUseTLS(host: "fd00::1", useTLS: false) == false)
|
|
}
|
|
|
|
@Test @MainActor func manualDefaultPortUses443OnlyForTailnetTLSHosts() async {
|
|
let controller = self.makeController()
|
|
|
|
#expect(controller._test_resolveManualPort(host: "gateway.example.com", port: 0, useTLS: true) == 18789)
|
|
#expect(controller._test_resolveManualPort(host: "device.sample.ts.net", port: 0, useTLS: true) == 443)
|
|
#expect(controller._test_resolveManualPort(host: "device.sample.ts.net.", port: 0, useTLS: true) == 443)
|
|
#expect(controller._test_resolveManualPort(host: "device.sample.ts.net", port: 18789, useTLS: true) == 18789)
|
|
}
|
|
|
|
@Test @MainActor func manualFirstUseTLSProbeShowsTrustPromptAfterFingerprintCapture() async {
|
|
let host = "gateway-\(UUID().uuidString).example.com"
|
|
let port = 18789
|
|
let stableID = "manual|\(host.lowercased())|\(port)"
|
|
defer { clearTLSFingerprint(stableID: stableID) }
|
|
self.clearTLSFingerprint(stableID: stableID)
|
|
|
|
let appModel = NodeAppModel()
|
|
let controller = GatewayConnectionController(
|
|
appModel: appModel,
|
|
startDiscovery: false,
|
|
tcpReachabilityProbe: { _, _, _, _ in true },
|
|
tlsFingerprintProbe: { _ in .fingerprint("abc123") })
|
|
|
|
await controller.connectManual(host: host, port: port, useTLS: true)
|
|
|
|
#expect(controller.pendingTrustPrompt?.fingerprintSha256 == "abc123")
|
|
#expect(controller.pendingTrustPrompt?.host == host)
|
|
#expect(controller.pendingTrustPrompt?.port == port)
|
|
#expect(appModel.gatewayStatusText == "Verify gateway TLS fingerprint")
|
|
}
|
|
|
|
@Test @MainActor func manualFirstUseTLSProbeSkipsTLSWhenTCPIsUnreachable() async {
|
|
let host = "gateway-\(UUID().uuidString).example.com"
|
|
let port = 18789
|
|
let stableID = "manual|\(host.lowercased())|\(port)"
|
|
let tlsProbeCalls = OSAllocatedUnfairLock(initialState: 0)
|
|
defer { clearTLSFingerprint(stableID: stableID) }
|
|
self.clearTLSFingerprint(stableID: stableID)
|
|
|
|
let appModel = NodeAppModel()
|
|
let controller = GatewayConnectionController(
|
|
appModel: appModel,
|
|
startDiscovery: false,
|
|
tcpReachabilityProbe: { _, _, _, _ in false },
|
|
tlsFingerprintProbe: { _ in
|
|
tlsProbeCalls.withLock { $0 += 1 }
|
|
return .fingerprint("abc123")
|
|
})
|
|
|
|
await controller.connectManual(host: host, port: port, useTLS: true)
|
|
|
|
#expect(tlsProbeCalls.withLock { $0 } == 0)
|
|
#expect(controller.pendingTrustPrompt == nil)
|
|
#expect(appModel.gatewayStatusText == "Can't reach gateway at \(host):\(port). Check Tailscale or LAN.")
|
|
}
|
|
|
|
@Test @MainActor func manualFirstUseTLSProbeReportsHandshakeTimeoutWithoutTrustPrompt() async {
|
|
let host = "gateway-\(UUID().uuidString).example.com"
|
|
let port = 18789
|
|
let stableID = "manual|\(host.lowercased())|\(port)"
|
|
defer { clearTLSFingerprint(stableID: stableID) }
|
|
self.clearTLSFingerprint(stableID: stableID)
|
|
|
|
let appModel = NodeAppModel()
|
|
let staleProblem = GatewayConnectionProblem(
|
|
kind: .pairingRequired,
|
|
owner: .gateway,
|
|
title: "Pairing required",
|
|
message: "Approve an old request.",
|
|
requestId: "stale-request",
|
|
retryable: true,
|
|
pauseReconnect: true)
|
|
appModel._test_applyOperatorGatewayConnectionProblem(staleProblem)
|
|
let controller = GatewayConnectionController(
|
|
appModel: appModel,
|
|
startDiscovery: false,
|
|
tcpReachabilityProbe: { _, _, _, _ in true },
|
|
tlsFingerprintProbe: { _ in .failure(.tlsHandshakeTimeout) })
|
|
|
|
await controller.connectManual(host: host, port: port, useTLS: true)
|
|
|
|
#expect(controller.pendingTrustPrompt == nil)
|
|
#expect(appModel.lastGatewayProblem == nil)
|
|
#expect(appModel.gatewayStatusText.contains("TLS fingerprint verification timed out"))
|
|
#expect(appModel.gatewayStatusText.contains("\(host):\(port)"))
|
|
}
|
|
|
|
@Test @MainActor func discoveredFirstUseTLSProbeFailureClearsStaleTrustPrompt() async {
|
|
let staleHost = "stale-\(UUID().uuidString).example.com"
|
|
let stalePort = 18789
|
|
let staleStableID = "manual|\(staleHost.lowercased())|\(stalePort)"
|
|
let discoveredStableID = "discovered|\(UUID().uuidString)"
|
|
let discoveredHost = "gateway.tailnet.ts.net"
|
|
let discoveredPort = 443
|
|
defer {
|
|
clearTLSFingerprint(stableID: staleStableID)
|
|
clearTLSFingerprint(stableID: discoveredStableID)
|
|
}
|
|
self.clearTLSFingerprint(stableID: staleStableID)
|
|
self.clearTLSFingerprint(stableID: discoveredStableID)
|
|
|
|
let tlsResults = OSAllocatedUnfairLock(initialState: [
|
|
GatewayTLSFingerprintProbeResult.fingerprint("abc123"),
|
|
.failure(.tlsHandshakeTimeout),
|
|
])
|
|
let appModel = NodeAppModel()
|
|
let controller = GatewayConnectionController(
|
|
appModel: appModel,
|
|
startDiscovery: false,
|
|
tcpReachabilityProbe: { _, _, _, _ in true },
|
|
tlsFingerprintProbe: { _ in tlsResults.withLock { $0.removeFirst() } },
|
|
serviceEndpointResolver: { _ in (host: discoveredHost, port: discoveredPort) })
|
|
|
|
await controller.connectManual(host: staleHost, port: stalePort, useTLS: true)
|
|
#expect(controller.pendingTrustPrompt?.fingerprintSha256 == "abc123")
|
|
|
|
let gateway = self.makeDiscoveredGateway(
|
|
stableID: discoveredStableID,
|
|
lanHost: nil,
|
|
tailnetDns: discoveredHost,
|
|
gatewayPort: discoveredPort,
|
|
fingerprint: nil)
|
|
let message = await controller.connectWithDiagnostics(gateway)
|
|
|
|
#expect(controller.pendingTrustPrompt == nil)
|
|
#expect(message?.contains("TLS fingerprint verification timed out") == true)
|
|
#expect(message?.contains("\(discoveredHost):\(discoveredPort)") == true)
|
|
#expect(appModel.gatewayStatusText == message)
|
|
}
|
|
|
|
@Test @MainActor func clearAllTLSFingerprints_removesStoredPins() async {
|
|
let stableID1 = "test|\(UUID().uuidString)"
|
|
let stableID2 = "test|\(UUID().uuidString)"
|
|
defer { GatewayTLSStore.clearAllFingerprints() }
|
|
|
|
GatewayTLSStore.saveFingerprint("11", stableID: stableID1)
|
|
GatewayTLSStore.saveFingerprint("22", stableID: stableID2)
|
|
|
|
#expect(GatewayTLSStore.loadFingerprint(stableID: stableID1) == "11")
|
|
#expect(GatewayTLSStore.loadFingerprint(stableID: stableID2) == "22")
|
|
|
|
GatewayTLSStore.clearAllFingerprints()
|
|
|
|
#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)
|
|
}
|
|
}
|