mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
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
This commit is contained in:
committed by
GitHub
parent
cb58073a90
commit
5eb18c1387
@@ -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,
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: " ")
|
||||
|
||||
|
||||
@@ -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? {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user