From 5eb18c1387d0b756d99e9ccf1637962800d6dcc0 Mon Sep 17 00:00:00 2001 From: Pavan Kumar Gondhi Date: Wed, 12 Aug 2026 15:18:19 +0530 Subject: [PATCH] fix(ios): keep authenticated Control UI pages bound to the trusted Gateway [AI] (#119906) * fix(ios): enforce gateway TLS pins in control pages * test(ios): run control UI trust regressions in CI * test(ios): avoid nested Testing macros * fix(ios): preserve control page navigation * fix(ios): keep authenticated control pages on origin * fix(ios): canonicalize control page IPv6 hosts * chore(ios): refresh native i18n inventory * fix(ios): normalize default TLS challenge ports * fix(apps): share gateway TLS authority matching * test(apps): fix authority CI validation * chore(ci): drop control UI test routing --- apps/.i18n/native-source.json | 42 ++++---- .../Sources/Chat/SessionDashboardScreen.swift | 3 +- .../Sources/Terminal/TerminalHubScreen.swift | 3 +- .../Web/AuthenticatedControlUIWebView.swift | 98 +++++++++++++++++-- apps/ios/Tests/TerminalHubScreenTests.swift | 91 ++++++++++++++++- .../DashboardWindowController+Gateways.swift | 5 +- .../DashboardGatewaysTests.swift | 8 ++ .../OpenClawChatUI/ChatInlineWidgetView.swift | 8 +- .../OpenClawKit/GatewayTLSPinning.swift | 49 +++++++--- .../GatewayTLSPinningTests.swift | 30 +++++- 10 files changed, 283 insertions(+), 54 deletions(-) diff --git a/apps/.i18n/native-source.json b/apps/.i18n/native-source.json index 5c6df64503fa..b8aa7b60ffaa 100644 --- a/apps/.i18n/native-source.json +++ b/apps/.i18n/native-source.json @@ -15971,7 +15971,7 @@ }, { "kind": "ui-modifier", - "line": 30, + "line": 31, "path": "apps/ios/Sources/Chat/SessionDashboardScreen.swift", "source": "Dashboard", "surface": "apple", @@ -15979,7 +15979,7 @@ }, { "kind": "ui-call", - "line": 37, + "line": 38, "path": "apps/ios/Sources/Chat/SessionDashboardScreen.swift", "source": "Done", "surface": "apple", @@ -15987,7 +15987,7 @@ }, { "kind": "ui-call", - "line": 47, + "line": 48, "path": "apps/ios/Sources/Chat/SessionDashboardScreen.swift", "source": "Dashboard needs a connected gateway", "surface": "apple", @@ -15995,7 +15995,7 @@ }, { "kind": "ui-call", - "line": 49, + "line": 50, "path": "apps/ios/Sources/Chat/SessionDashboardScreen.swift", "source": "Connect to your gateway to open this session dashboard.", "surface": "apple", @@ -27755,7 +27755,7 @@ }, { "kind": "ui-modifier", - "line": 44, + "line": 45, "path": "apps/ios/Sources/Terminal/TerminalHubScreen.swift", "source": "Terminal", "surface": "apple", @@ -27763,7 +27763,7 @@ }, { "kind": "ui-modifier", - "line": 56, + "line": 57, "path": "apps/ios/Sources/Terminal/TerminalHubScreen.swift", "source": "Gateway settings", "surface": "apple", @@ -27771,7 +27771,7 @@ }, { "kind": "ui-call", - "line": 70, + "line": 71, "path": "apps/ios/Sources/Terminal/TerminalHubScreen.swift", "source": "Terminal needs a connected gateway", "surface": "apple", @@ -27779,7 +27779,7 @@ }, { "kind": "ui-call", - "line": 72, + "line": 73, "path": "apps/ios/Sources/Terminal/TerminalHubScreen.swift", "source": "Connect to your gateway to open a shell in the agent workspace.", "surface": "apple", @@ -27787,7 +27787,7 @@ }, { "kind": "ui-call", - "line": 78, + "line": 79, "path": "apps/ios/Sources/Terminal/TerminalHubScreen.swift", "source": "Open Gateway Settings", "surface": "apple", @@ -28529,14 +28529,6 @@ "surface": "apple", "id": "native.apple.00887489a998411e" }, - { - "kind": "conditional-branch", - "line": 108, - "path": "apps/ios/Sources/Web/AuthenticatedControlUIWebView.swift", - "source": "[\\(host)]", - "surface": "apple", - "id": "native.apple.944004de80cfc8b7" - }, { "kind": "plist-string", "line": 24, @@ -43065,6 +43057,22 @@ "surface": "apple", "id": "native.apple.081a07c7306b223e" }, + { + "kind": "conditional-branch", + "line": 711, + "path": "apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayTLSPinning.swift", + "source": "[\\(self.host)]", + "surface": "apple", + "id": "native.apple.1dba7d27dc80088a" + }, + { + "kind": "conditional-branch", + "line": 712, + "path": "apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayTLSPinning.swift", + "source": ":\\(self.port)", + "surface": "apple", + "id": "native.apple.c25ea221748a03ba" + }, { "kind": "conditional-branch", "line": 116, diff --git a/apps/ios/Sources/Chat/SessionDashboardScreen.swift b/apps/ios/Sources/Chat/SessionDashboardScreen.swift index 20b096247c3f..9ecfc85014c3 100644 --- a/apps/ios/Sources/Chat/SessionDashboardScreen.swift +++ b/apps/ios/Sources/Chat/SessionDashboardScreen.swift @@ -18,7 +18,8 @@ struct SessionDashboardScreen: View { authScript: AuthenticatedControlUI.authUserScript( config: config, pageURL: url, - storedOperatorToken: storedOperatorToken)) + storedOperatorToken: storedOperatorToken), + tls: config?.tls) .id(AuthenticatedControlUI.webContentIdentity( config: config, storedOperatorToken: storedOperatorToken)) diff --git a/apps/ios/Sources/Terminal/TerminalHubScreen.swift b/apps/ios/Sources/Terminal/TerminalHubScreen.swift index d4581f26eea5..5195752f6ca8 100644 --- a/apps/ios/Sources/Terminal/TerminalHubScreen.swift +++ b/apps/ios/Sources/Terminal/TerminalHubScreen.swift @@ -30,7 +30,8 @@ struct TerminalHubScreen: View { url: url, authScript: Self.terminalAuthUserScript( config: config, - storedOperatorToken: storedOperatorToken)) + storedOperatorToken: storedOperatorToken), + tls: config?.tls) // Recreate the web view only when the connection inputs // change; SwiftUI update passes must not restart live shells. .id(Self.webContentIdentity( diff --git a/apps/ios/Sources/Web/AuthenticatedControlUIWebView.swift b/apps/ios/Sources/Web/AuthenticatedControlUIWebView.swift index b32e63666092..df3f1a7ff83c 100644 --- a/apps/ios/Sources/Web/AuthenticatedControlUIWebView.swift +++ b/apps/ios/Sources/Web/AuthenticatedControlUIWebView.swift @@ -93,6 +93,10 @@ enum AuthenticatedControlUI { static func webContentIdentity(config: GatewayConnectConfig?, storedOperatorToken: String?) -> Int { var hasher = Hasher() hasher.combine(config?.url) + hasher.combine(config?.tls?.required) + hasher.combine(config?.tls?.expectedFingerprint) + hasher.combine(config?.tls?.allowTOFU) + hasher.combine(config?.tls?.storeKey) hasher.combine(config?.token) hasher.combine(config?.password) hasher.combine(storedOperatorToken?.trimmingCharacters(in: .whitespacesAndNewlines)) @@ -104,13 +108,7 @@ enum AuthenticatedControlUI { } private static func originString(for url: URL) -> String { - guard let scheme = url.scheme, let host = url.host else { return "" } - let hostPart = host.contains(":") && !host.hasPrefix("[") ? "[\(host)]" : host - var origin = "\(scheme)://\(hostPart)" - if let port = url.port { - origin += ":\(port)" - } - return origin + GatewayTLSAuthority(url: url)?.serialized ?? "" } private static func jsStringLiteral(_ value: String) -> String { @@ -134,12 +132,89 @@ enum AuthenticatedControlUI { } } +@MainActor +final class AuthenticatedControlUIWebViewCoordinator: NSObject, WKNavigationDelegate { + private let expectedOrigin: GatewayTLSAuthority? + private let tls: GatewayTLSParams? + + init(url: URL, tls: GatewayTLSParams?) { + self.expectedOrigin = GatewayTLSAuthority(url: url) + self.tls = tls + } + + func webView( + _: WKWebView, + decidePolicyFor navigationAction: WKNavigationAction, + decisionHandler: @escaping @MainActor @Sendable (WKNavigationActionPolicy) -> Void) + { + decisionHandler(self.allowsNavigation( + to: navigationAction.request.url, + isMainFrame: navigationAction.targetFrame?.isMainFrame) ? .allow : .cancel) + } + + func webView( + _: WKWebView, + didReceive challenge: URLAuthenticationChallenge, + completionHandler: @escaping @MainActor @Sendable ( + URLSession.AuthChallengeDisposition, + URLCredential?) -> Void) + { + guard challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust, + let tls + else { + completionHandler(.performDefaultHandling, nil) + return + } + guard self.matchesExpectedAuthority( + host: challenge.protectionSpace.host, + port: challenge.protectionSpace.port) + else { + // Cross-origin main-frame loads are already cancelled by navigation policy. + // Other authorities may belong to embedded content and do not inherit the Gateway pin. + completionHandler(.performDefaultHandling, nil) + return + } + guard let trust = challenge.protectionSpace.serverTrust else { + completionHandler(.cancelAuthenticationChallenge, nil) + return + } + switch GatewayTLSServerTrust.evaluate( + trust: trust, + host: challenge.protectionSpace.host, + port: challenge.protectionSpace.port, + params: tls) + { + case .accept: + completionHandler(.useCredential, URLCredential(trust: trust)) + case .reject: + completionHandler(.cancelAuthenticationChallenge, nil) + } + } + + func allowsNavigation(to candidateURL: URL?, isMainFrame: Bool?) -> Bool { + if isMainFrame == false { + return true + } + guard isMainFrame == true, let candidateURL else { return false } + return GatewayTLSAuthority(url: candidateURL) == self.expectedOrigin + } + + func matchesExpectedAuthority(host: String, port: Int) -> Bool { + self.expectedOrigin?.matches(host: host, port: port) == true + } +} + /// Ephemeral, script-hardened WKWebView for a self-contained Control UI page. struct AuthenticatedControlUIWebView: UIViewRepresentable { let url: URL let authScript: String? + let tls: GatewayTLSParams? - func makeUIView(context _: Context) -> WKWebView { + func makeCoordinator() -> AuthenticatedControlUIWebViewCoordinator { + AuthenticatedControlUIWebViewCoordinator(url: self.url, tls: self.tls) + } + + func makeUIView(context: Context) -> WKWebView { let configuration = WKWebViewConfiguration() configuration.websiteDataStore = .nonPersistent() configuration.defaultWebpagePreferences.allowsContentJavaScript = true @@ -152,6 +227,7 @@ struct AuthenticatedControlUIWebView: UIViewRepresentable { } let webView = WKWebView(frame: .zero, configuration: configuration) + webView.navigationDelegate = context.coordinator webView.isOpaque = true webView.backgroundColor = .black webView.allowsLinkPreview = false @@ -173,7 +249,11 @@ struct AuthenticatedControlUIWebView: UIViewRepresentable { // Connection changes recreate the view via `.id`; unrelated SwiftUI passes must not reload it. } - static func dismantleUIView(_ webView: WKWebView, coordinator _: Void) { + static func dismantleUIView( + _ webView: WKWebView, + coordinator _: AuthenticatedControlUIWebViewCoordinator) + { webView.stopLoading() + webView.navigationDelegate = nil } } diff --git a/apps/ios/Tests/TerminalHubScreenTests.swift b/apps/ios/Tests/TerminalHubScreenTests.swift index 7c6ba1f96256..7b5658742672 100644 --- a/apps/ios/Tests/TerminalHubScreenTests.swift +++ b/apps/ios/Tests/TerminalHubScreenTests.swift @@ -9,13 +9,14 @@ struct TerminalHubScreenTests { url: URL, token: String? = nil, password: String? = nil, + tls: GatewayTLSParams? = nil, allowStoredDeviceAuth: Bool = true, deviceAuthGatewayID: String? = nil) -> GatewayConnectConfig { GatewayConnectConfig( url: url, stableID: "manual|gateway.example.com|443", - tls: nil, + tls: tls, token: token, bootstrapToken: nil, password: password, @@ -69,6 +70,17 @@ struct TerminalHubScreenTests { #expect(script?.contains("\"gatewayUrl\":\"wss:\\/\\/gateway.example.com:8443\"") == true) } + @Test func `auth user script canonicalizes an explicit default port`() throws { + let config = try Self.makeConfig( + url: #require(URL(string: "wss://gateway.example.com:443")), + token: "secret-token") + + let script = TerminalHubScreen.terminalAuthUserScript(config: config) + + #expect(script?.contains("\"https:\\/\\/gateway.example.com\"") == true) + #expect(script?.contains("\"https:\\/\\/gateway.example.com:443\"") == false) + } + @Test func `auth user script falls back to stored operator token`() throws { let config = try Self.makeConfig( url: #require(URL(string: "wss://gateway.example.com:8443")), @@ -139,6 +151,83 @@ struct TerminalHubScreenTests { TerminalHubScreen.webContentIdentity(config: config, storedOperatorToken: "token-b")) } + @Test func `web content identity changes with the accepted TLS pin`() throws { + let url = try #require(URL(string: "wss://gateway.example.com")) + let first = Self.makeConfig( + url: url, + tls: GatewayTLSParams( + required: true, + expectedFingerprint: "first", + allowTOFU: false, + storeKey: "gateway")) + let second = Self.makeConfig( + url: url, + tls: GatewayTLSParams( + required: true, + expectedFingerprint: "second", + allowTOFU: false, + storeKey: "gateway")) + + #expect( + TerminalHubScreen.webContentIdentity(config: first, storedOperatorToken: nil) != + TerminalHubScreen.webContentIdentity(config: second, storedOperatorToken: nil)) + } + + @Test func `authenticated Control UI origin rejects authority changes`() throws { + let controlURL = try #require(URL(string: "https://gateway.example.com/control")) + let defaultPortURL = try #require(URL(string: "https://GATEWAY.example.com:443/chat")) + let alternatePortURL = try #require(URL(string: "https://gateway.example.com:8443/chat")) + let alternateHostURL = try #require(URL(string: "https://replacement.example.com/chat")) + let insecureURL = try #require(URL(string: "http://gateway.example.com/chat")) + let expected = try #require(GatewayTLSAuthority(url: controlURL)) + + #expect(expected == GatewayTLSAuthority(url: defaultPortURL)) + #expect(expected != GatewayTLSAuthority(url: alternatePortURL)) + #expect(expected != GatewayTLSAuthority(url: alternateHostURL)) + #expect(expected != GatewayTLSAuthority(url: insecureURL)) + } + + @Test func `authenticated Control UI canonicalizes IPv6 authorities`() throws { + let controlURL = try #require(URL(string: "https://[2001:db8::1]:8443/control")) + let expected = try #require(GatewayTLSAuthority(url: controlURL)) + + #expect(expected.serialized == "https://[2001:db8::1]:8443") + #expect(expected.matches(host: "2001:DB8::1", port: 8443)) + #expect(expected.matches(host: "[2001:db8::1]", port: 8443)) + #expect(!expected.matches(host: "2001:db8::2", port: 8443)) + #expect(!expected.matches(host: "2001:db8::1", port: 443)) + } + + @Test func `authenticated Control UI navigation keeps the main frame on its origin`() throws { + let controlURL = try #require(URL(string: "https://gateway.example.com/control")) + let sameOriginURL = try #require(URL(string: "https://gateway.example.com/chat?session=main")) + let alternateHostURL = try #require(URL(string: "https://replacement.example.com/chat")) + let alternatePortURL = try #require(URL(string: "https://gateway.example.com:8443/chat")) + let embeddedURL = try #require(URL(string: "https://discussion.example.com/embed/thread/a/b")) + let unknownFrameURL = try #require(URL(string: "https://gateway.example.com/chat")) + let coordinator = try AuthenticatedControlUIWebViewCoordinator( + url: controlURL, + tls: nil) + + #expect(coordinator.allowsNavigation(to: sameOriginURL, isMainFrame: true)) + #expect(!coordinator.allowsNavigation(to: alternateHostURL, isMainFrame: true)) + #expect(!coordinator.allowsNavigation(to: alternatePortURL, isMainFrame: true)) + #expect(coordinator.allowsNavigation(to: embeddedURL, isMainFrame: false)) + #expect(!coordinator.allowsNavigation(to: unknownFrameURL, isMainFrame: nil)) + } + + @Test func `authenticated Control UI TLS authority uses the normalized page authority`() throws { + let controlURL = try #require(URL(string: "https://Gateway.Example.com/control")) + let coordinator = try AuthenticatedControlUIWebViewCoordinator( + url: controlURL, + tls: nil) + + #expect(coordinator.matchesExpectedAuthority(host: "gateway.example.com", port: 0)) + #expect(coordinator.matchesExpectedAuthority(host: "gateway.example.com", port: 443)) + #expect(!coordinator.matchesExpectedAuthority(host: "gateway.example.com", port: 8443)) + #expect(!coordinator.matchesExpectedAuthority(host: "replacement.example.com", port: 443)) + } + @Test func `auth user script is omitted without credentials`() throws { let config = try Self.makeConfig(url: #require(URL(string: "wss://gateway.example.com")), token: " ") diff --git a/apps/macos/Sources/OpenClaw/DashboardWindowController+Gateways.swift b/apps/macos/Sources/OpenClaw/DashboardWindowController+Gateways.swift index 1fa7d7a07437..a78f2b942ad6 100644 --- a/apps/macos/Sources/OpenClaw/DashboardWindowController+Gateways.swift +++ b/apps/macos/Sources/OpenClaw/DashboardWindowController+Gateways.swift @@ -69,10 +69,7 @@ extension DashboardWindowController { } static func isExpectedTLSAuthority(host: String, port: Int, dashboardURL: URL) -> Bool { - let expectedHost = dashboardURL.host?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - let challengedHost = host.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - let expectedPort = dashboardURL.port ?? (dashboardURL.scheme?.lowercased() == "https" ? 443 : 80) - return expectedHost?.isEmpty == false && challengedHost == expectedHost && port == expectedPort + GatewayTLSAuthority(url: dashboardURL)?.matches(host: host, port: port) == true } static func gatewaysRequest(from body: Any) -> DashboardGatewaysRequest? { diff --git a/apps/macos/Tests/OpenClawIPCTests/DashboardGatewaysTests.swift b/apps/macos/Tests/OpenClawIPCTests/DashboardGatewaysTests.swift index cff0020e308f..fb8beb365c13 100644 --- a/apps/macos/Tests/OpenClawIPCTests/DashboardGatewaysTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/DashboardGatewaysTests.swift @@ -140,10 +140,18 @@ struct DashboardGatewaysBridgeTests { windowAutosaveName: "OpenClawDashboardWindow-Test-\(UUID().uuidString)") #expect(controller._testTLSParams == params) + #expect(DashboardWindowController.isExpectedTLSAuthority( + host: "gateway.example", + port: 0, + dashboardURL: url)) #expect(DashboardWindowController.isExpectedTLSAuthority( host: "gateway.example", port: 443, dashboardURL: url)) + #expect(!DashboardWindowController.isExpectedTLSAuthority( + host: "gateway.example", + port: 8443, + dashboardURL: url)) #expect(!DashboardWindowController.isExpectedTLSAuthority( host: "other.example", port: 443, diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatInlineWidgetView.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatInlineWidgetView.swift index d81f4eb0a8e1..3ceaca84c780 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatInlineWidgetView.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatInlineWidgetView.swift @@ -740,11 +740,9 @@ private final class ChatInlineWidgetNavigationDelegate: NSObject, WKNavigationDe } private func matchesExpectedProtectionSpace(_ protectionSpace: URLProtectionSpace) -> Bool { - guard let expectedHost = self.resource.url.host, - protectionSpace.host.caseInsensitiveCompare(expectedHost) == .orderedSame - else { return false } - let expectedPort = self.resource.url.port ?? (self.resource.url.scheme?.lowercased() == "https" ? 443 : 80) - return protectionSpace.port == expectedPort + GatewayTLSAuthority(url: self.resource.url)?.matches( + host: protectionSpace.host, + port: protectionSpace.port) == true } } diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayTLSPinning.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayTLSPinning.swift index ee8a2976804d..d99a9f186e32 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayTLSPinning.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayTLSPinning.swift @@ -683,25 +683,49 @@ public protocol GatewayTLSRouteMetadataProviding: AnyObject { var effectiveTLSFingerprintSHA256: String? { get } } -struct GatewayTLSAuthority: Equatable, Sendable { - let host: String - let port: Int +public struct GatewayTLSAuthority: Equatable, Sendable { + public let scheme: String + public let host: String + public let port: Int + private let defaultPort: Int - init?(url: URL) { - guard let host = Self.normalizedHost(url.host) else { return nil } + public init?(url: URL) { + guard let scheme = url.scheme?.lowercased(), + let defaultPort = Self.defaultPort(for: scheme), + let host = Self.normalizedHost(url.host) + else { return nil } + self.scheme = scheme self.host = host - self.port = url.port ?? (url.scheme?.lowercased() == "wss" ? 443 : 80) + self.port = url.port ?? defaultPort + self.defaultPort = defaultPort } - init?(host: String, port: Int) { - guard let host = Self.normalizedHost(host) else { return nil } - self.host = host - self.port = port + public func matches(host: String, port: Int) -> Bool { + // URLProtectionSpace uses 0 for the protocol's default port. Normalize it here so + // every pinned Apple transport reaches the same authority decision. + let challengePort = port == 0 ? self.defaultPort : port + return Self.normalizedHost(host) == self.host && challengePort == self.port + } + + public var serialized: String { + let hostPart = self.host.contains(":") ? "[\(self.host)]" : self.host + return "\(self.scheme)://\(hostPart)" + (self.port == self.defaultPort ? "" : ":\(self.port)") + } + + private static func defaultPort(for scheme: String) -> Int? { + switch scheme { + case "http", "ws": 80 + case "https", "wss": 443 + default: nil + } } private static func normalizedHost(_ host: String?) -> String? { let value = host?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() ?? "" - return value.isEmpty ? nil : value + guard !value.isEmpty else { return nil } + return value.hasPrefix("[") && value.hasSuffix("]") + ? String(value.dropFirst().dropLast()) + : value } } @@ -871,9 +895,8 @@ public final class GatewayTLSPinningSession: NSObject, WebSocketSessioning, URLS let host = challenge.protectionSpace.host let port = challenge.protectionSpace.port let expected = self.currentEnforcedFingerprint() - let challengedAuthority = GatewayTLSAuthority(host: host, port: port) guard let expectedAuthority = self.currentExpectedAuthority(), - challengedAuthority == expectedAuthority + expectedAuthority.matches(host: host, port: port) else { self.recordTLSFailure(GatewayTLSValidationFailure( kind: .authorityMismatch, diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayTLSPinningTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayTLSPinningTests.swift index 80bdb6fafeb1..378fe1766919 100644 --- a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayTLSPinningTests.swift +++ b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayTLSPinningTests.swift @@ -153,10 +153,17 @@ struct GatewayTLSPinningTests { @Test func `TLS authority includes normalized host and effective port`() throws { let url = try #require(URL(string: "wss://Gateway.Example.com/path")) let route = try #require(GatewayTLSAuthority(url: url)) + let explicitPortURL = try #require(URL(string: "wss://gateway.example.com:8443/path")) + let explicitPort = try #require(GatewayTLSAuthority(url: explicitPortURL)) - #expect(route == GatewayTLSAuthority(host: "gateway.example.com", port: 443)) - #expect(route != GatewayTLSAuthority(host: "redirect.example.com", port: 443)) - #expect(route != GatewayTLSAuthority(host: "gateway.example.com", port: 8443)) + #expect(route.host == "gateway.example.com") + #expect(route.port == 443) + #expect(route.matches(host: "gateway.example.com", port: 0)) + #expect(route.matches(host: "gateway.example.com", port: 443)) + #expect(!route.matches(host: "redirect.example.com", port: 443)) + #expect(!route.matches(host: "gateway.example.com", port: 8443)) + #expect(!explicitPort.matches(host: "gateway.example.com", port: 0)) + #expect(explicitPort.matches(host: "gateway.example.com", port: 8443)) } @Test func `matching explicit pin overrides system trust`() { @@ -200,6 +207,23 @@ struct GatewayTLSPinningTests { params: mismatch) == .reject) } + @Test func `server trust evaluator rejects a different system-trusted certificate after pinning`() throws { + let trust = try gatewayTLSTestTrust(systemTrusted: true) + let pinnedFingerprint = SHA256.hash(data: Data("previous certificate".utf8)) + .map { String(format: "%02x", $0) }.joined() + let params = GatewayTLSParams( + required: true, + expectedFingerprint: pinnedFingerprint, + allowTOFU: false, + storeKey: "profile:pinned") + + #expect(GatewayTLSServerTrust.evaluate( + trust: trust, + host: "gateway.example", + port: 443, + params: params) == .reject) + } + @Test func `server trust evaluator claims trusted first use`() throws { try self.withFakeKeychain { _ in let trust = try gatewayTLSTestTrust(systemTrusted: true)