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:
Yaojia Wang
2026-07-07 09:42:12 +02:00
parent 5337281e85
commit e38e6d1689
25 changed files with 1510 additions and 30 deletions

View File

@@ -0,0 +1,62 @@
import Foundation
import Testing
@testable import ClientTLS
// C-iOS-1 · Store roundtrip via the InMemory store (same import/summary code
// path as the keychain store, minus the entitlement the keychain variant is
// covered by on-device/simulator tests, mirroring KeychainHostStoreLiveTests).
@Test("save → loadIdentity roundtrips and hasInstalledIdentity flips")
func storeRoundtrip() throws {
// Arrange
let store = InMemoryClientIdentityStore()
#expect(store.hasInstalledIdentity() == false)
#expect(try store.loadIdentity() == nil)
// Act
try store.save(
p12Data: ClientTLSFixtures.deviceP12Data, passphrase: ClientTLSFixtures.passphrase
)
// Assert
#expect(store.hasInstalledIdentity() == true)
let identity = try #require(try store.loadIdentity())
#expect(identity.summary()?.subjectCommonName == ClientTLSFixtures.leafCommonName)
let summary = try #require(try store.loadSummary())
#expect(summary.issuerCommonName == ClientTLSFixtures.issuerCommonName)
}
@Test("save validates the passphrase and leaves prior state untouched on failure")
func storeSaveValidatesBeforePersisting() {
// Arrange
let store = InMemoryClientIdentityStore()
// Act / Assert a wrong passphrase must surface, not persist.
#expect(throws: PKCS12ImportError.wrongPassphrase) {
try store.save(p12Data: ClientTLSFixtures.deviceP12Data, passphrase: "wrong")
}
#expect(store.hasInstalledIdentity() == false)
}
@Test("remove clears the stored identity")
func storeRemove() throws {
// Arrange
let store = InMemoryClientIdentityStore()
try store.save(
p12Data: ClientTLSFixtures.deviceP12Data, passphrase: ClientTLSFixtures.passphrase
)
#expect(store.hasInstalledIdentity() == true)
// Act
try store.remove()
// Assert
#expect(store.hasInstalledIdentity() == false)
#expect(try store.loadIdentity() == nil)
}
@Test("loadedIdentityOrNil returns nil (not a throw) when nothing is installed")
func loadedIdentityOrNilEmpty() {
let store = InMemoryClientIdentityStore()
#expect(store.loadedIdentityOrNil() == nil)
}

View File

@@ -0,0 +1,33 @@
import Foundation
/// Embedded test fixture: a real PKCS#12 generated at authoring time with
/// OpenSSL (EC P-256 self-signed device-CA EC P-256 leaf, `EKU=clientAuth`,
/// exported `-legacy` for iOS/Android import parity mirrors
/// `deploy/scripts/gen-device-ca.sh` / `issue-device-cert.sh`).
///
/// Embedded as base64 rather than an SPM resource so the MUST-PASS package test
/// runs headless with no `Bundle.module` resource wiring. Regenerate with:
/// openssl ecparam -name prime256v1 -genkey -noout -out ca.key.pem
/// openssl req -x509 -new -key ca.key.pem -sha256 -days 3650 \
/// -subj "/CN=webterm-device-ca-fixture" \
/// -addext "basicConstraints=critical,CA:TRUE" \
/// -addext "keyUsage=critical,keyCertSign,cRLSign" -out ca.cert.pem
/// openssl ecparam -name prime256v1 -genkey -noout -out leaf.key.pem
/// openssl req -new -key leaf.key.pem -subj "/CN=device-fixture" -out leaf.csr.pem
/// openssl x509 -req -in leaf.csr.pem -CA ca.cert.pem -CAkey ca.key.pem \
/// -CAcreateserial -days 825 -sha256 -extfile leaf.ext -out leaf.cert.pem
/// openssl pkcs12 -export -legacy -inkey leaf.key.pem -in leaf.cert.pem \
/// -certfile ca.cert.pem -passout pass:clienttls-test-pass \
/// -name device-fixture -out device.p12 ; base64 device.p12
enum ClientTLSFixtures {
static let passphrase = "clienttls-test-pass"
static let leafCommonName = "device-fixture"
static let issuerCommonName = "webterm-device-ca-fixture"
static var deviceP12Data: Data {
Data(base64Encoded: deviceP12Base64)!
}
// swiftlint:disable:next line_length
static let deviceP12Base64 = "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"
}

View File

@@ -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()
)
}

View File

@@ -0,0 +1,69 @@
import Foundation
import Security
import Testing
@testable import ClientTLS
// C-iOS-1 · PKCS#12 import against a real OpenSSL-generated fixture `.p12`,
// including the wrong-passphrase and corrupt-file error mappings.
@Test("import with the correct passphrase yields an identity whose leaf CN matches")
func importSucceedsAndExposesLeaf() throws {
// Arrange
let data = ClientTLSFixtures.deviceP12Data
// Act
let identity = try PKCS12Importer.importIdentity(
data: data, passphrase: ClientTLSFixtures.passphrase
)
// Assert
let summary = try #require(identity.summary())
#expect(summary.subjectCommonName == ClientTLSFixtures.leafCommonName)
#expect(summary.issuerCommonName == ClientTLSFixtures.issuerCommonName)
// 825-day leaf minted at authoring time not yet expired.
#expect(summary.isExpired() == false)
}
@Test("import drops the leaf from the issuer chain (credential must not repeat it)")
func importIssuerChainExcludesLeaf() throws {
// Arrange / Act
let identity = try PKCS12Importer.importIdentity(
data: ClientTLSFixtures.deviceP12Data, passphrase: ClientTLSFixtures.passphrase
)
// Assert fixture chain is [leaf, CA]; issuerCertificates keeps only the CA.
let leaf = try #require(identity.leafCertificate())
let leafData = SecCertificateCopyData(leaf) as Data
#expect(identity.issuerCertificates.count == 1)
for issuer in identity.issuerCertificates {
#expect((SecCertificateCopyData(issuer) as Data) != leafData)
}
}
@Test("wrong passphrase maps errSecAuthFailed → .wrongPassphrase")
func importWrongPassphrase() {
// Arrange / Act / Assert
#expect(throws: PKCS12ImportError.wrongPassphrase) {
_ = try PKCS12Importer.importIdentity(
data: ClientTLSFixtures.deviceP12Data, passphrase: "not-the-passphrase"
)
}
}
@Test("garbage bytes map errSecDecode → .corruptFile")
func importCorruptFile() {
// Arrange
let garbage = Data([0x00, 0x01, 0x02, 0x03, 0x99, 0xAB, 0xCD, 0xEF])
// Act / Assert
#expect(throws: PKCS12ImportError.corruptFile) {
_ = try PKCS12Importer.importIdentity(data: garbage, passphrase: "x")
}
}
@Test("empty data is treated as a corrupt file, not a crash")
func importEmptyData() {
#expect(throws: PKCS12ImportError.corruptFile) {
_ = try PKCS12Importer.importIdentity(data: Data(), passphrase: "x")
}
}