Files
web-terminal/ios/Packages/ClientTLS/Tests/ClientTLSTests/MutualTLSChallengeResponderTests.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

115 lines
4.1 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import Foundation
import Testing
@testable import ClientTLS
// C-iOS-1 · The responder truth table: 3 challenge types × identity present /
// absent. Exercised through the pure method-keyed core AND through a real
// `URLAuthenticationChallenge` (proving the method is extracted correctly),
// with no live socket.
private let responder = MutualTLSChallengeResponder()
private func makeIdentity() throws -> ClientIdentity {
try PKCS12Importer.importIdentity(
data: ClientTLSFixtures.deviceP12Data, passphrase: ClientTLSFixtures.passphrase
)
}
// MARK: - ClientCertificate
@Test("ClientCertificate + identity → .useCredential with a credential")
func clientCertWithIdentityUsesCredential() throws {
// Arrange
let identity = try makeIdentity()
// Act
let resolution = responder.resolve(
authenticationMethod: NSURLAuthenticationMethodClientCertificate, identity: identity
)
// Assert
#expect(resolution.disposition == .useCredential)
#expect(resolution.credential != nil)
#expect(resolution.credential?.identity != nil)
}
@Test("ClientCertificate + no identity → .cancelAuthenticationChallenge, no credential")
func clientCertWithoutIdentityCancels() {
// Act
let resolution = responder.resolve(
authenticationMethod: NSURLAuthenticationMethodClientCertificate, identity: nil
)
// Assert
#expect(resolution.disposition == .cancelAuthenticationChallenge)
#expect(resolution.credential == nil)
}
// MARK: - ServerTrust
@Test("ServerTrust → .performDefaultHandling regardless of identity")
func serverTrustDefaultHandling() throws {
let identity = try makeIdentity()
for id in [identity, nil] as [ClientIdentity?] {
let resolution = responder.resolve(
authenticationMethod: NSURLAuthenticationMethodServerTrust, identity: id
)
#expect(resolution.disposition == .performDefaultHandling)
#expect(resolution.credential == nil)
}
}
// MARK: - Other method (e.g. Basic)
@Test("an unrelated method → .performDefaultHandling regardless of identity")
func otherMethodDefaultHandling() throws {
let identity = try makeIdentity()
for id in [identity, nil] as [ClientIdentity?] {
let resolution = responder.resolve(
authenticationMethod: NSURLAuthenticationMethodHTTPBasic, identity: id
)
#expect(resolution.disposition == .performDefaultHandling)
#expect(resolution.credential == nil)
}
}
// MARK: - Full URLAuthenticationChallenge extraction
@Test("resolve(challenge:) extracts the method from the protection space")
func resolveFromRealChallenge() throws {
// Arrange a real challenge for the ClientCertificate method.
let identity = try makeIdentity()
let challenge = makeChallenge(method: NSURLAuthenticationMethodClientCertificate)
// Act
let withIdentity = responder.resolve(challenge, identity: identity)
let withoutIdentity = responder.resolve(challenge, identity: nil)
// Assert
#expect(withIdentity.disposition == .useCredential)
#expect(withIdentity.credential != nil)
#expect(withoutIdentity.disposition == .cancelAuthenticationChallenge)
}
// MARK: - Helpers
/// Minimal sender so `URLAuthenticationChallenge` can be constructed in-process
/// (its designated init requires a non-optional sender). The responder never
/// calls back into the sender it only reads `protectionSpace`.
private final class NoopChallengeSender: NSObject, URLAuthenticationChallengeSender {
func use(_ credential: URLCredential, for challenge: URLAuthenticationChallenge) {}
func continueWithoutCredential(for challenge: URLAuthenticationChallenge) {}
func cancel(_ challenge: URLAuthenticationChallenge) {}
}
private func makeChallenge(method: String) -> URLAuthenticationChallenge {
let space = URLProtectionSpace(
host: "t1.terminal.yaojia.wang", port: 443, protocol: "https",
realm: nil, authenticationMethod: method
)
return URLAuthenticationChallenge(
protectionSpace: space, proposedCredential: nil, previousFailureCount: 0,
failureResponse: nil, error: nil, sender: NoopChallengeSender()
)
}