Files
openclaw/apps/ios/Sources/Gateway/GatewayTLSFingerprintProbe.swift
Peter Steinberger 62bd760c0e chore(swift): enforce current formatting and lint rules (#103313)
* style: apply SwiftFormat 0.62.1 rules

Refs #103202

* ci: enforce deterministic Swift lint

Refs #103202

* refactor: keep gateway connect lint-clean

Refs #103202

* style: keep iOS typography checks warning-free

* ci: route MLX Swift changes through pre-push

* fix: preserve native i18n extraction after Swift cleanup

* refactor: keep rebased Swift surfaces lint-clean

* style: format latest Swift additions

* chore: refresh native i18n inventory

* style: keep generated Swift formatter-clean

* fix: preserve node route invalidation callbacks

* fix: keep native translation IDs stable

* fix: retain native translation identifiers

* fix: preserve translations across Swift source moves
2026-07-10 11:54:08 +01:00

162 lines
5.4 KiB
Swift

import CryptoKit
import Foundation
import os
import Security
enum GatewayTLSFingerprintProbeFailure: Equatable {
case endpointUnreachable
case tlsHandshakeTimeout
case tlsUnavailable
case certificateUnavailable
}
enum GatewayTLSFingerprintProbeResult: Equatable {
case fingerprint(String)
case failure(GatewayTLSFingerprintProbeFailure)
}
typealias GatewayTLSFingerprintProbeFunction = @Sendable (URL) async -> GatewayTLSFingerprintProbeResult
enum GatewayTLSFingerprintProbeBudget {
static let tcpConnectTimeoutSeconds = 3.0
fileprivate static let tlsHandshakeTimeoutSeconds = 10.0
}
func defaultGatewayTLSFingerprintProbe(url: URL) async -> GatewayTLSFingerprintProbeResult {
await withCheckedContinuation { continuation in
let probe = GatewayTLSFingerprintProbe(
url: url,
timeoutSeconds: GatewayTLSFingerprintProbeBudget.tlsHandshakeTimeoutSeconds)
{ result in
continuation.resume(returning: result)
}
probe.start()
}
}
private final class GatewayTLSFingerprintProbe: NSObject, URLSessionDelegate, URLSessionTaskDelegate,
@unchecked Sendable
{
private struct ProbeState {
var didFinish = false
var session: URLSession?
var task: URLSessionWebSocketTask?
}
private let url: URL
private let timeoutSeconds: Double
private let onComplete: (GatewayTLSFingerprintProbeResult) -> Void
private let state = OSAllocatedUnfairLock(initialState: ProbeState())
init(
url: URL,
timeoutSeconds: Double,
onComplete: @escaping (GatewayTLSFingerprintProbeResult) -> Void)
{
self.url = url
self.timeoutSeconds = timeoutSeconds
self.onComplete = onComplete
}
func start() {
let config = URLSessionConfiguration.ephemeral
config.timeoutIntervalForRequest = self.timeoutSeconds
config.timeoutIntervalForResource = self.timeoutSeconds
let session = URLSession(configuration: config, delegate: self, delegateQueue: nil)
let task = session.webSocketTask(with: self.url)
self.state.withLock { s in
s.session = session
s.task = task
}
task.resume()
DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + self.timeoutSeconds) { [weak self] in
self?.finish(.failure(.tlsHandshakeTimeout))
}
}
func urlSession(
_ session: URLSession,
didReceive challenge: URLAuthenticationChallenge,
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void)
{
guard challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust,
let trust = challenge.protectionSpace.serverTrust
else {
completionHandler(.performDefaultHandling, nil)
return
}
let fp = GatewayTLSFingerprintProbe.certificateFingerprint(trust)
completionHandler(.cancelAuthenticationChallenge, nil)
if let fp {
self.finish(.fingerprint(fp))
} else {
self.finish(.failure(.certificateUnavailable))
}
}
func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
guard let error else {
self.finish(.failure(.tlsUnavailable))
return
}
self.finish(.failure(Self.failure(for: error)))
}
private func finish(_ result: GatewayTLSFingerprintProbeResult) {
typealias FinishState = (Bool, URLSessionWebSocketTask?, URLSession?)
let (shouldComplete, taskToCancel, sessionToInvalidate) = self.state.withLock { s -> FinishState in
guard !s.didFinish else { return (false, nil, nil) }
s.didFinish = true
let task = s.task
let session = s.session
s.task = nil
s.session = nil
return (true, task, session)
}
guard shouldComplete else { return }
taskToCancel?.cancel(with: .goingAway, reason: nil)
sessionToInvalidate?.invalidateAndCancel()
self.onComplete(result)
}
private static func failure(for error: Error) -> GatewayTLSFingerprintProbeFailure {
let nsError = error as NSError
guard nsError.domain == URLError.errorDomain else {
return .tlsUnavailable
}
switch URLError.Code(rawValue: nsError.code) {
case .timedOut:
return .tlsHandshakeTimeout
case .cannotFindHost,
.dnsLookupFailed,
.cannotConnectToHost,
.notConnectedToInternet,
.internationalRoamingOff,
.callIsActive,
.dataNotAllowed:
return .endpointUnreachable
case .networkConnectionLost,
.secureConnectionFailed,
.cannotParseResponse,
.badServerResponse:
return .tlsUnavailable
default:
return .tlsUnavailable
}
}
private static func certificateFingerprint(_ trust: SecTrust) -> String? {
guard let chain = SecTrustCopyCertificateChain(trust) as? [SecCertificate],
let cert = chain.first
else {
return nil
}
let data = SecCertificateCopyData(cert) as Data
let digest = SHA256.hash(data: data)
return digest.map { String(format: "%02x", $0) }.joined()
}
}