Files
web-terminal/ios/Packages/ClientTLS/Sources/ClientTLS/ClientIdentity.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

56 lines
2.5 KiB
Swift

import Foundation
import Security
/// C-iOS-1 · An imported device client identity: the `SecIdentity` (leaf
/// certificate + its private key) plus the issuer chain that accompanies it in
/// the TLS handshake.
///
/// `@unchecked Sendable`: `SecIdentity` / `SecCertificate` are CoreFoundation
/// handles that are immutable once imported and thread-safe to read; this value
/// only ever holds finished imports and never mutates them. Marking it lets the
/// identity flow into the `@unchecked Sendable` WS connection and the URLSession
/// delegates that answer client-certificate challenges.
public struct ClientIdentity: @unchecked Sendable {
/// The leaf certificate + private key used to authenticate to the server.
public let secIdentity: SecIdentity
/// Issuer certificates to present alongside the leaf (the CA chain, leaf
/// excluded). May be empty when the trust anchor is already pinned server
/// side (nginx `ssl_client_certificate` = the device-CA) the leaf alone
/// then verifies at `ssl_verify_depth 1`.
public let issuerCertificates: [SecCertificate]
public init(secIdentity: SecIdentity, issuerCertificates: [SecCertificate] = []) {
self.secIdentity = secIdentity
self.issuerCertificates = issuerCertificates
}
/// The `URLCredential` handed back to a `ClientCertificate` auth challenge.
///
/// `.forSession` (not `.permanent`) per plan §C-iOS-1: the identity already
/// lives in the app's keychain item persisting the credential in the
/// shared URL credential store would be a second, unmanaged copy.
public func urlCredential(
persistence: URLCredential.Persistence = .forSession
) -> URLCredential {
URLCredential(
identity: secIdentity,
certificates: issuerCertificates.isEmpty ? nil : issuerCertificates,
persistence: persistence
)
}
/// The leaf `SecCertificate` backing this identity (for display / summary).
public func leafCertificate() -> SecCertificate? {
var certificate: SecCertificate?
let status = SecIdentityCopyCertificate(secIdentity, &certificate)
return status == errSecSuccess ? certificate : nil
}
/// Human-readable summary of the leaf certificate (subject CN, issuer CN,
/// expiry) for the install/rotation UI. `nil` only if the leaf can't be
/// read (should never happen for a valid import).
public func summary() -> ClientCertificateSummary? {
leafCertificate().map(CertificateInspector.summary(of:))
}
}