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.
This commit is contained in:
@@ -0,0 +1,114 @@
|
||||
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()
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user