import Foundation import Network import OpenClawKit import os import Testing @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?, tlsEnabled: Bool = true) -> 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: tlsEnabled, tlsFingerprintSha256: fingerprint, cliPath: nil) } private func clearTLSFingerprint(stableID: String) { GatewayTLSStore.clearFingerprint(stableID: stableID) } @Test @MainActor func `discovered TLS params prefers stored pin over advertised TXT`() { 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 `discovered TLS params does not trust advertised fingerprint`() { 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 `discovered gateway availability requires advertised TLS or a stored pin`() { let unpinnedID = "test|\(UUID().uuidString)" let pinnedID = "test|\(UUID().uuidString)" defer { clearTLSFingerprint(stableID: unpinnedID) clearTLSFingerprint(stableID: pinnedID) } self.clearTLSFingerprint(stableID: unpinnedID) self.clearTLSFingerprint(stableID: pinnedID) let controller = self.makeController() let unavailable = self.makeDiscoveredGateway( stableID: unpinnedID, lanHost: "gateway.local", tailnetDns: nil, gatewayPort: 18789, fingerprint: "untrusted-txt-fingerprint", tlsEnabled: false) let advertisedTLS = self.makeDiscoveredGateway( stableID: unpinnedID, lanHost: "gateway.local", tailnetDns: nil, gatewayPort: 18789, fingerprint: nil) let pinned = self.makeDiscoveredGateway( stableID: pinnedID, lanHost: "gateway.local", tailnetDns: nil, gatewayPort: 18789, fingerprint: nil, tlsEnabled: false) #expect(controller.discoveredGatewayConnectionAvailability(unavailable) == .secureTransportRequired) #expect(controller.discoveredGatewayConnectionAvailability(unavailable).canConnect == false) #expect(controller.discoveredGatewayConnectionAvailability(unavailable).guidanceText? .contains("trusted private-LAN") == true) #expect(controller.discoveredGatewayConnectionAvailability(advertisedTLS) == .available) GatewayTLSStore.saveFingerprint("stored-pin", stableID: pinnedID) #expect(controller.discoveredGatewayConnectionAvailability(pinned) == .available) } @Test @MainActor func `blocked discovered gateway does no connection work`() async { let stableID = "test|\(UUID().uuidString)" defer { clearTLSFingerprint(stableID: stableID) } self.clearTLSFingerprint(stableID: stableID) let tcpCalls = OSAllocatedUnfairLock(initialState: 0) let tlsCalls = OSAllocatedUnfairLock(initialState: 0) let resolverCalls = OSAllocatedUnfairLock(initialState: 0) let appModel = NodeAppModel() let controller = GatewayConnectionController( appModel: appModel, startDiscovery: false, tcpReachabilityProbe: { _, _, _, _ in tcpCalls.withLock { $0 += 1 } return true }, tlsFingerprintProbe: { _ in tlsCalls.withLock { $0 += 1 } return .fingerprint("unexpected") }, serviceEndpointResolver: { _ in resolverCalls.withLock { $0 += 1 } return (host: "unexpected.example", port: 443) }) let gateway = self.makeDiscoveredGateway( stableID: stableID, lanHost: "untrusted-txt.example", tailnetDns: nil, gatewayPort: 18789, fingerprint: "untrusted-txt-fingerprint", tlsEnabled: false) let message = await controller.connectWithDiagnostics(gateway) #expect(message?.contains("Manual Setup") == true) #expect(resolverCalls.withLock { $0 } == 0) #expect(tcpCalls.withLock { $0 } == 0) #expect(tlsCalls.withLock { $0 } == 0) #expect(controller.pendingTrustPrompt == nil) #expect(appModel.activeGatewayConnectConfig == nil) } @Test @MainActor func `quick setup prefers an eligible discovered gateway`() { let blockedID = "test|\(UUID().uuidString)" let eligibleID = "test|\(UUID().uuidString)" defer { clearTLSFingerprint(stableID: blockedID) clearTLSFingerprint(stableID: eligibleID) } self.clearTLSFingerprint(stableID: blockedID) self.clearTLSFingerprint(stableID: eligibleID) let controller = self.makeController() let blocked = self.makeDiscoveredGateway( stableID: blockedID, lanHost: nil, tailnetDns: nil, gatewayPort: nil, fingerprint: nil, tlsEnabled: false) let eligible = self.makeDiscoveredGateway( stableID: eligibleID, lanHost: nil, tailnetDns: nil, gatewayPort: nil, fingerprint: nil) controller._test_setGateways([blocked, eligible]) #expect(controller.preferredDiscoveredGateway()?.stableID == eligibleID) } @Test @MainActor func `autoconnect requires stored pin for discovered gateways`() { 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 `manual connections force TLS for non loopback hosts`() { 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 `manual transport presentation shows effective remote security`() { let presentation = GatewayConnectionController.manualTransportPresentation( host: "gateway.example.com", requestedTLS: false) #expect(presentation.requiresTLS) #expect(presentation.effectiveTLS) #expect(presentation.helperText == "Secure connection is required for this host.") } @Test @MainActor func `manual transport presentation allows unencrypted private LAN`() { let presentation = GatewayConnectionController.manualTransportPresentation( host: "192.168.1.20", requestedTLS: false) #expect(!presentation.requiresTLS) #expect(!presentation.effectiveTLS) #expect(presentation.helperText == "Use only on a trusted private network.") } @Test @MainActor func `manual transport presentation does not repeat selected private LAN TLS state`() { let presentation = GatewayConnectionController.manualTransportPresentation( host: "192.168.1.20", requestedTLS: true) #expect(!presentation.requiresTLS) #expect(presentation.effectiveTLS) #expect(presentation.helperText == nil) } @Test @MainActor func `manual connections allow private lan plaintext`() { 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 `manual default port uses 443 only for tailnet TLS hosts`() { 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 `setup route selection falls back to reachable tailnet endpoint`() async { let probes = OSAllocatedUnfairLock(initialState: [(String, Int)]()) let controller = GatewayConnectionController( appModel: NodeAppModel(), startDiscovery: false, tcpReachabilityProbe: { host, port, _, _ in probes.withLock { $0.append((host, port)) } return host.hasSuffix(".ts.net") }) let link = GatewayConnectDeepLink( host: "192.168.139.3", port: 18789, tls: false, bootstrapToken: "boot", token: nil, password: nil, fallbackEndpoints: [ .init(host: "clawmac.tail.ts.net", port: 8443, tls: true), ]) let selected = await controller.selectReachableSetupLink(link) #expect(selected.host == "clawmac.tail.ts.net") #expect(selected.port == 8443) #expect(selected.tls) #expect(selected.bootstrapToken == "boot") #expect(probes.withLock { $0.map(\.0) } == ["192.168.139.3", "clawmac.tail.ts.net"]) } @Test @MainActor func `setup route selection keeps legacy endpoint when every probe fails`() async { let controller = GatewayConnectionController( appModel: NodeAppModel(), startDiscovery: false, tcpReachabilityProbe: { _, _, _, _ in false }) let link = GatewayConnectDeepLink( host: "192.168.139.3", port: 18789, tls: false, bootstrapToken: "boot", token: nil, password: nil, fallbackEndpoints: [ .init(host: "clawmac.tail.ts.net", port: 8443, tls: true), ]) #expect(await controller.selectReachableSetupLink(link) == link) } @Test @MainActor func `manual first use TLS probe shows trust prompt after fingerprint capture`() 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 `stale trust acceptance releases auto connect suppression`() 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) _ = appModel.beginGatewayConnectAttempt() await controller.acceptPendingTrustPrompt() #expect(controller.pendingTrustPrompt == nil) #expect(appModel.activeGatewayConnectConfig == nil) #expect(!controller._test_isAutoConnectSuppressed()) } @Test @MainActor func `manual first use TLS probe skips TLS when TCP is unreachable`() 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 `unreachable tailscale host explains serve publishing`() async { let host = "gateway-\(UUID().uuidString).example.ts.net" let port = 443 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 false }) await controller.connectManual(host: host, port: port, useTLS: true) #expect(appModel.gatewayStatusText == "Can't reach gateway at \(host):\(port). Verify Tailscale Serve is enabled and publishes this Gateway.") } @Test @MainActor func `manual first use TLS probe reports handshake timeout without trust prompt`() 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 `discovered first use TLS probe failure clears stale trust prompt`() 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 resolverStarted = AsyncStream.makeStream() let resolverResults = AsyncStream<(host: String, port: Int)>.makeStream() let appModel = NodeAppModel() let controller = GatewayConnectionController( appModel: appModel, startDiscovery: false, tcpReachabilityProbe: { _, _, _, _ in true }, tlsFingerprintProbe: { _ in tlsResults.withLock { $0.removeFirst() } }, serviceEndpointResolver: { _ in resolverStarted.continuation.yield() for await result in resolverResults.stream { return result } return nil }) 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) var startedIterator = resolverStarted.stream.makeAsyncIterator() let connectTask = Task { await controller.connectWithDiagnostics(gateway) } _ = await startedIterator.next() #expect(controller.pendingTrustPrompt == nil) resolverResults.continuation.yield((host: discoveredHost, port: discoveredPort)) resolverResults.continuation.finish() let message = await connectTask.value #expect(message?.contains("TLS fingerprint verification timed out") == true) #expect(message?.contains("\(discoveredHost):\(discoveredPort)") == true) #expect(appModel.gatewayStatusText == message) } @Test @MainActor func `target switch cancels suspended discovery resolution`() async { let defaults = UserDefaults.standard let previousInstanceID = defaults.string(forKey: "node.instanceId") defer { if let previousInstanceID { defaults.set(previousInstanceID, forKey: "node.instanceId") } else { defaults.removeObject(forKey: "node.instanceId") } } defaults.set("ios-test", forKey: "node.instanceId") let resolverStarted = AsyncStream.makeStream() let resolverResults = AsyncStream<(host: String, port: Int)>.makeStream() let appModel = NodeAppModel() defer { appModel.disconnectGateway() } let controller = GatewayConnectionController( appModel: appModel, startDiscovery: false, serviceEndpointResolver: { _ in resolverStarted.continuation.yield() for await result in resolverResults.stream { return result } return nil }) let stableID = "discovered|\(UUID().uuidString)" defer { self.clearTLSFingerprint(stableID: stableID) } let gateway = self.makeDiscoveredGateway( stableID: stableID, lanHost: nil, tailnetDns: nil, gatewayPort: nil, fingerprint: nil) var startedIterator = resolverStarted.stream.makeAsyncIterator() let connectTask = Task { await controller.connectWithDiagnostics(gateway) } _ = await startedIterator.next() controller.cancelPendingConnectionAttempts() resolverResults.continuation.yield((host: "stale.gateway.invalid", port: 443)) resolverResults.continuation.finish() let message = await connectTask.value #expect(message == nil) #expect(appModel.activeGatewayConnectConfig == nil) #expect(controller.pendingTrustPrompt == nil) } @Test @MainActor func `clear all TLS fingerprints removes stored pins`() { 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 `TLS fingerprints preserve exact unicode gateway owners`() { let suffix = UUID().uuidString let composedOwner = "gateway-\u{00E9}-\(suffix)" let decomposedOwner = "gateway-e\u{0301}-\(suffix)" defer { GatewayTLSStore.clearFingerprint(stableID: composedOwner) GatewayTLSStore.clearFingerprint(stableID: decomposedOwner) } #expect(composedOwner == decomposedOwner) GatewayTLSStore.saveFingerprint("composed-pin", stableID: composedOwner) GatewayTLSStore.saveFingerprint("decomposed-pin", stableID: decomposedOwner) #expect(GatewayTLSStore.loadFingerprint(stableID: composedOwner) == "composed-pin") #expect(GatewayTLSStore.loadFingerprint(stableID: decomposedOwner) == "decomposed-pin") #expect(GatewayTLSStore.clearFingerprint(stableID: decomposedOwner)) #expect(GatewayTLSStore.loadFingerprint(stableID: composedOwner) == "composed-pin") #expect(GatewayTLSStore.loadFingerprint(stableID: decomposedOwner) == nil) } @Test func `ASCII legacy TLS fingerprint migrates to encoded account`() { let stableID = "legacy-tls-owner-\(UUID().uuidString)" let service = "ai.openclaw.tls-pinning" defer { GatewayTLSStore.clearFingerprint(stableID: stableID) GenericPasswordKeychainStore.delete(service: service, account: stableID) } GatewayTLSStore.clearFingerprint(stableID: stableID) #expect(GenericPasswordKeychainStore.saveString( "legacy-pin", service: service, account: stableID)) #expect(GatewayTLSStore.loadFingerprint(stableID: stableID) == "legacy-pin") #expect(GenericPasswordKeychainStore.loadString(service: service, account: stableID) == nil) } @Test func `ambiguous unicode legacy TLS fingerprint fails closed`() { let suffix = UUID().uuidString let composedOwner = "legacy-gateway-\u{00E9}-\(suffix)" let decomposedOwner = "legacy-gateway-e\u{0301}-\(suffix)" let service = "ai.openclaw.tls-pinning" defer { GatewayTLSStore.clearFingerprint(stableID: composedOwner) GatewayTLSStore.clearFingerprint(stableID: decomposedOwner) GenericPasswordKeychainStore.delete(service: service, account: composedOwner) } GatewayTLSStore.clearFingerprint(stableID: composedOwner) GatewayTLSStore.clearFingerprint(stableID: decomposedOwner) #expect(GenericPasswordKeychainStore.saveString( "ambiguous-legacy-pin", service: service, account: composedOwner)) #expect(GatewayTLSStore.loadFingerprint(stableID: composedOwner) == nil) #expect(GatewayTLSStore.loadFingerprint(stableID: decomposedOwner) == nil) } @Test func `legacy TLS account cannot alias encoded owner account`() { let exactOwner = "gateway-\(UUID().uuidString)" let component = Data(exactOwner.utf8).base64EncodedString() .replacingOccurrences(of: "+", with: "-") .replacingOccurrences(of: "/", with: "_") .replacingOccurrences(of: "=", with: "") let collidingLegacyOwner = "fingerprint.v2.\(component)" defer { GatewayTLSStore.clearFingerprint(stableID: exactOwner) GatewayTLSStore.clearFingerprint(stableID: collidingLegacyOwner) } GatewayTLSStore.saveFingerprint("exact-owner-pin", stableID: exactOwner) #expect(GatewayTLSStore.loadFingerprint(stableID: collidingLegacyOwner) == nil) #expect(GatewayTLSStore.clearFingerprint(stableID: collidingLegacyOwner)) #expect(GatewayTLSStore.loadFingerprint(stableID: exactOwner) == "exact-owner-pin") } @Test func `trusted pin mismatch can be recovered by replacing stored pin`() { 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 `untrusted pin mismatch cannot be recovered in app`() { 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) } }