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,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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user