Files
web-terminal/ios/Packages/ClientTLS/Sources/ClientTLS/MutualTLSChallengeResponder.swift
Yaojia Wang e38e6d1689 feat(ios): device client-cert mTLS — ClientTLS package, transport wiring, install UX (C-iOS)
- ios/Packages/ClientTLS: SecIdentity wrapper, PKCS12 importer (typed errors), keychain store
  (AfterFirstUnlockThisDeviceOnly), pure MutualTLSChallengeResponder truth table, cross-platform
  X.509 DER summary. 14/14 tests.
- Both transports (SessionCore URLSessionTermTransport, App URLSessionHTTPTransport) + SessionThumbnail
  take a lazy @Sendable ()->ClientIdentity? provider: WS resolves per-connect, HTTP per client-cert
  challenge, so a freshly-imported cert applies without an app relaunch. AppEnvironment injects
  { store.loadedIdentityOrNil() }.
- ClientCertScreen (.fileImporter([.pkcs12]) + passphrase -> import -> keychain), reachable via a
  设备证书 entry in SessionListScreen.hostMenu. PairingViewModel gates tunnel-host probes on cert
  presence and re-maps mTLS-reject to a clientCertRejected message.
Verified: ClientTLS 14/14, SessionCore 93/93, xcodegen + xcodebuild BUILD SUCCEEDED.
2026-07-07 09:42:12 +02:00

74 lines
3.2 KiB
Swift

import Foundation
/// C-iOS-1 · The pure, synchronous decision at the heart of mutual TLS.
///
/// It maps a URLSession auth challenge (+ the optionally-installed device
/// identity) to a disposition and credential. Deliberately free of any
/// URLSession / socket state so the full truth table is unit-testable without a
/// live connection the transports (WS task delegate, HTTP session delegate)
/// are thin adapters that call `resolve` and forward its result to the
/// completion handler.
///
/// Truth table (plan §C-iOS-1):
/// a. `ClientCertificate` + identity present `.useCredential` with the
/// identity's `URLCredential`.
/// b. `ClientCertificate` + no identity `.cancelAuthenticationChallenge`
/// (a clean, classifiable failure never a silent no-cert continue).
/// c. `ServerTrust` `.performDefaultHandling`
/// (the LE wildcard is validated by the system trust store).
/// d. anything else `.performDefaultHandling`.
public struct MutualTLSChallengeResponder: Sendable {
/// The responder's decision. Not `Sendable` (it may carry a `URLCredential`,
/// which isn't) it is produced and consumed synchronously on the delegate
/// queue, never sent across isolation domains.
public struct Resolution {
public let disposition: URLSession.AuthChallengeDisposition
public let credential: URLCredential?
public init(
disposition: URLSession.AuthChallengeDisposition,
credential: URLCredential? = nil
) {
self.disposition = disposition
self.credential = credential
}
}
public init() {}
/// Resolve a full `URLAuthenticationChallenge` by dispatching on its
/// authentication method.
public func resolve(
_ challenge: URLAuthenticationChallenge, identity: ClientIdentity?
) -> Resolution {
resolve(
authenticationMethod: challenge.protectionSpace.authenticationMethod,
identity: identity
)
}
/// Method-keyed core (challenge-free) the directly-tested pure function.
public func resolve(
authenticationMethod: String, identity: ClientIdentity?
) -> Resolution {
switch authenticationMethod {
case NSURLAuthenticationMethodClientCertificate:
guard let identity else {
// (b) No installed identity: cancel so the failure surfaces as a
// classifiable client-cert error rather than a bare TLS reset.
return Resolution(disposition: .cancelAuthenticationChallenge)
}
// (a) Present the device certificate for this session.
return Resolution(
disposition: .useCredential, credential: identity.urlCredential()
)
case NSURLAuthenticationMethodServerTrust:
// (c) System-trusted LE wildcard default evaluation.
return Resolution(disposition: .performDefaultHandling)
default:
// (d) Basic/Digest/NTLM/etc. not used by this app; default.
return Resolution(disposition: .performDefaultHandling)
}
}
}