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:
22
ios/Packages/ClientTLS/Package.swift
Normal file
22
ios/Packages/ClientTLS/Package.swift
Normal file
@@ -0,0 +1,22 @@
|
||||
// swift-tools-version: 6.0
|
||||
// C-iOS-1 · Device client-certificate (mutual-TLS) leaf package.
|
||||
//
|
||||
// Standalone, no local-package dependencies — imports ONLY Security/Foundation.
|
||||
// It is a downward leaf: SessionCore and the App target may depend on it, it
|
||||
// depends on nothing in this workspace. This keeps the pure, unit-testable
|
||||
// mTLS logic (identity wrapper, PKCS#12 import, challenge responder) isolated
|
||||
// from the WS/HTTP transport plumbing that consumes it.
|
||||
import PackageDescription
|
||||
|
||||
let package = Package(
|
||||
name: "ClientTLS",
|
||||
platforms: [.iOS(.v17), .macOS(.v14)],
|
||||
products: [
|
||||
.library(name: "ClientTLS", targets: ["ClientTLS"]),
|
||||
],
|
||||
targets: [
|
||||
.target(name: "ClientTLS"),
|
||||
.testTarget(name: "ClientTLSTests", dependencies: ["ClientTLS"]),
|
||||
],
|
||||
swiftLanguageModes: [.v6]
|
||||
)
|
||||
@@ -0,0 +1,188 @@
|
||||
import Foundation
|
||||
import Security
|
||||
|
||||
/// C-iOS-3 · Display summary of a device certificate, shown on the install /
|
||||
/// rotation screen so the user can confirm *which* cert is active and *when*
|
||||
/// it expires before relying on it.
|
||||
public struct ClientCertificateSummary: Equatable, Sendable {
|
||||
/// Subject common name — the device/leaf CN (e.g. `t1-iphone`).
|
||||
public let subjectCommonName: String?
|
||||
/// Issuer common name — the device-CA CN (e.g. `webterm-device-ca`).
|
||||
public let issuerCommonName: String?
|
||||
/// Not-after date; `nil` if it could not be parsed.
|
||||
public let notAfter: Date?
|
||||
|
||||
public init(subjectCommonName: String?, issuerCommonName: String?, notAfter: Date?) {
|
||||
self.subjectCommonName = subjectCommonName
|
||||
self.issuerCommonName = issuerCommonName
|
||||
self.notAfter = notAfter
|
||||
}
|
||||
|
||||
/// Expired relative to `now` (defaults to the current instant). Unknown
|
||||
/// expiry is treated as NOT expired (fail-open for display only — the TLS
|
||||
/// stack, not this label, is the real gate).
|
||||
public func isExpired(asOf now: Date = Date()) -> Bool {
|
||||
guard let notAfter else { return false }
|
||||
return notAfter < now
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads the display fields off a `SecCertificate`.
|
||||
///
|
||||
/// Implemented as a minimal X.509 DER walk over `SecCertificateCopyData` rather
|
||||
/// than `SecCertificateCopyValues`, because that API and its `kSecOID*` /
|
||||
/// `kSecPropertyKey*` constants are **macOS-only** — unavailable on iOS, which
|
||||
/// is the real deployment target. The DER walk is identical on both platforms
|
||||
/// and unit-testable against the fixture. Any parse miss degrades to `nil`
|
||||
/// fields (the summary is display-only; the TLS stack is the gate).
|
||||
enum CertificateInspector {
|
||||
static func summary(of certificate: SecCertificate) -> ClientCertificateSummary {
|
||||
let der = [UInt8](SecCertificateCopyData(certificate) as Data)
|
||||
let fields = X509Fields.parse(der: der)
|
||||
return ClientCertificateSummary(
|
||||
subjectCommonName: fields?.subjectCommonName,
|
||||
issuerCommonName: fields?.issuerCommonName,
|
||||
notAfter: fields?.notAfter
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Minimal ASN.1 DER reader
|
||||
|
||||
/// One tag-length-value element, as byte ranges into the source buffer.
|
||||
private struct DERElement {
|
||||
let tag: UInt8
|
||||
let valueStart: Int
|
||||
let valueEnd: Int
|
||||
}
|
||||
|
||||
private enum DER {
|
||||
/// DER tags used here.
|
||||
static let sequence: UInt8 = 0x30
|
||||
static let set: UInt8 = 0x31
|
||||
static let oid: UInt8 = 0x06
|
||||
static let contextTag0: UInt8 = 0xA0 // [0] EXPLICIT (X.509 version)
|
||||
static let utcTime: UInt8 = 0x17
|
||||
static let generalizedTime: UInt8 = 0x18
|
||||
|
||||
/// Read a single TLV at `start`. Returns the element and the index just
|
||||
/// past it, or `nil` on any malformed length/overrun (defensive: the cert
|
||||
/// bytes come from the system but are still parsed as untrusted input).
|
||||
static func read(_ bytes: [UInt8], at start: Int) -> DERElement? {
|
||||
guard start >= 0, start + 1 < bytes.count else { return nil }
|
||||
let tag = bytes[start]
|
||||
var index = start + 1
|
||||
let firstLengthByte = bytes[index]
|
||||
index += 1
|
||||
var length = 0
|
||||
if firstLengthByte & 0x80 == 0 {
|
||||
length = Int(firstLengthByte)
|
||||
} else {
|
||||
let byteCount = Int(firstLengthByte & 0x7F)
|
||||
guard byteCount > 0, byteCount <= 4, index + byteCount <= bytes.count else {
|
||||
return nil
|
||||
}
|
||||
for _ in 0..<byteCount {
|
||||
length = (length << 8) | Int(bytes[index])
|
||||
index += 1
|
||||
}
|
||||
}
|
||||
let valueEnd = index + length
|
||||
guard length >= 0, valueEnd <= bytes.count else { return nil }
|
||||
return DERElement(tag: tag, valueStart: index, valueEnd: valueEnd)
|
||||
}
|
||||
|
||||
/// Split a constructed value `[start, end)` into its immediate children.
|
||||
static func children(_ bytes: [UInt8], from start: Int, to end: Int) -> [DERElement] {
|
||||
var elements: [DERElement] = []
|
||||
var index = start
|
||||
while index < end, let element = read(bytes, at: index) {
|
||||
elements.append(element)
|
||||
index = element.valueEnd
|
||||
}
|
||||
return elements
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - X.509 field extraction
|
||||
|
||||
private struct X509Fields {
|
||||
let subjectCommonName: String?
|
||||
let issuerCommonName: String?
|
||||
let notAfter: Date?
|
||||
|
||||
/// CommonName attribute OID `2.5.4.3` → DER content bytes `55 04 03`.
|
||||
private static let commonNameOID: [UInt8] = [0x55, 0x04, 0x03]
|
||||
|
||||
/// Certificate ::= SEQUENCE { tbsCertificate, signatureAlgorithm, signature }
|
||||
/// tbsCertificate ::= SEQUENCE { [0] version?, serialNumber, signature,
|
||||
/// issuer Name, validity, subject Name, subjectPublicKeyInfo, ... }
|
||||
static func parse(der bytes: [UInt8]) -> X509Fields? {
|
||||
guard let certificate = DER.read(bytes, at: 0), certificate.tag == DER.sequence else {
|
||||
return nil
|
||||
}
|
||||
let certChildren = DER.children(
|
||||
bytes, from: certificate.valueStart, to: certificate.valueEnd
|
||||
)
|
||||
guard let tbs = certChildren.first, tbs.tag == DER.sequence else { return nil }
|
||||
|
||||
var tbsChildren = DER.children(bytes, from: tbs.valueStart, to: tbs.valueEnd)
|
||||
// Optional EXPLICIT [0] version — drop it so the fixed fields align.
|
||||
if let first = tbsChildren.first, first.tag == DER.contextTag0 {
|
||||
tbsChildren.removeFirst()
|
||||
}
|
||||
// Fixed order after (optional) version: serialNumber, signature,
|
||||
// issuer, validity, subject, ...
|
||||
guard tbsChildren.count >= 5 else { return nil }
|
||||
let issuer = tbsChildren[2]
|
||||
let validity = tbsChildren[3]
|
||||
let subject = tbsChildren[4]
|
||||
|
||||
return X509Fields(
|
||||
subjectCommonName: commonName(bytes, in: subject),
|
||||
issuerCommonName: commonName(bytes, in: issuer),
|
||||
notAfter: notAfterDate(bytes, in: validity)
|
||||
)
|
||||
}
|
||||
|
||||
/// Name ::= SEQUENCE OF RDN(SET) OF AttributeTypeAndValue(SEQ{OID, value}).
|
||||
/// Returns the first CN value found.
|
||||
private static func commonName(_ bytes: [UInt8], in name: DERElement) -> String? {
|
||||
for rdn in DER.children(bytes, from: name.valueStart, to: name.valueEnd)
|
||||
where rdn.tag == DER.set {
|
||||
for atv in DER.children(bytes, from: rdn.valueStart, to: rdn.valueEnd)
|
||||
where atv.tag == DER.sequence {
|
||||
let parts = DER.children(bytes, from: atv.valueStart, to: atv.valueEnd)
|
||||
guard parts.count >= 2, parts[0].tag == DER.oid else { continue }
|
||||
let oid = Array(bytes[parts[0].valueStart..<parts[0].valueEnd])
|
||||
if oid == commonNameOID {
|
||||
let value = Array(bytes[parts[1].valueStart..<parts[1].valueEnd])
|
||||
// PrintableString / UTF8String both decode as UTF-8.
|
||||
return String(bytes: value, encoding: .utf8)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/// Validity ::= SEQUENCE { notBefore Time, notAfter Time } — the 2nd child.
|
||||
private static func notAfterDate(_ bytes: [UInt8], in validity: DERElement) -> Date? {
|
||||
let times = DER.children(bytes, from: validity.valueStart, to: validity.valueEnd)
|
||||
guard times.count >= 2 else { return nil }
|
||||
let notAfter = times[1]
|
||||
let value = Array(bytes[notAfter.valueStart..<notAfter.valueEnd])
|
||||
guard let text = String(bytes: value, encoding: .ascii) else { return nil }
|
||||
return parseTime(text, tag: notAfter.tag)
|
||||
}
|
||||
|
||||
private static func parseTime(_ text: String, tag: UInt8) -> Date? {
|
||||
let formatter = DateFormatter()
|
||||
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||||
formatter.timeZone = TimeZone(identifier: "UTC")
|
||||
// UTCTime: YYMMDDHHMMSSZ (used for years < 2050); GeneralizedTime:
|
||||
// YYYYMMDDHHMMSSZ. The 2-digit-year window covers the relevant range
|
||||
// (device certs live in the 2020s-2040s).
|
||||
formatter.dateFormat = tag == DER.utcTime ? "yyMMddHHmmss'Z'" : "yyyyMMddHHmmss'Z'"
|
||||
return formatter.date(from: text)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
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:))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import Foundation
|
||||
|
||||
/// C-iOS-2 · Reusable `NSObject` URLSession delegate that answers
|
||||
/// **session-level** auth challenges by delegating to `MutualTLSChallengeResponder`.
|
||||
///
|
||||
/// This is the seam for the HTTP transport (`session.data(for:)` surfaces
|
||||
/// challenges through `urlSession(_:didReceive:completionHandler:)`). The WS
|
||||
/// transport can't reuse it — its connection object is already the session's
|
||||
/// delegate and needs the **task-level** callback — so that one implements the
|
||||
/// task-level method itself against the same responder.
|
||||
///
|
||||
/// `@unchecked Sendable`: URLSession retains its delegate and invokes it from
|
||||
/// arbitrary queues; every stored field is an immutable `let` over thread-safe
|
||||
/// values (`ClientIdentity` is `@unchecked Sendable`, the responder is stateless).
|
||||
public final class ClientTLSSessionDelegate: NSObject, URLSessionDelegate, @unchecked Sendable {
|
||||
private let identity: ClientIdentity?
|
||||
private let responder: MutualTLSChallengeResponder
|
||||
|
||||
public init(
|
||||
identity: ClientIdentity?,
|
||||
responder: MutualTLSChallengeResponder = MutualTLSChallengeResponder()
|
||||
) {
|
||||
self.identity = identity
|
||||
self.responder = responder
|
||||
super.init()
|
||||
}
|
||||
|
||||
public func urlSession(
|
||||
_ session: URLSession,
|
||||
didReceive challenge: URLAuthenticationChallenge,
|
||||
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
|
||||
) {
|
||||
let resolution = responder.resolve(challenge, identity: identity)
|
||||
completionHandler(resolution.disposition, resolution.credential)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
import Foundation
|
||||
import os
|
||||
import Security
|
||||
|
||||
/// Persists the device identity so it survives relaunch. The raw `.p12` bytes
|
||||
/// **and** its passphrase are stored together (the passphrase is required to
|
||||
/// re-import via `SecPKCS12Import` at every launch), then re-imported on load.
|
||||
public protocol ClientIdentityStore: Sendable {
|
||||
/// Validate (`SecPKCS12Import`) then persist the `.p12` + passphrase.
|
||||
/// Throws `PKCS12ImportError` on a bad passphrase / corrupt file (nothing is
|
||||
/// persisted in that case) and `ClientIdentityStoreError` on a storage fault.
|
||||
func save(p12Data: Data, passphrase: String) throws
|
||||
/// Re-import and return the stored identity; `nil` if none is installed.
|
||||
func loadIdentity() throws -> ClientIdentity?
|
||||
/// Display summary of the stored certificate; `nil` if none is installed.
|
||||
func loadSummary() throws -> ClientCertificateSummary?
|
||||
/// Delete the stored identity (rotation / removal). Idempotent.
|
||||
func remove() throws
|
||||
/// Cheap existence check for gating (does NOT re-import).
|
||||
func hasInstalledIdentity() -> Bool
|
||||
}
|
||||
|
||||
public enum ClientIdentityStoreError: Error, Equatable, Sendable {
|
||||
/// A Keychain `SecItem*` call failed with this `OSStatus`.
|
||||
case keychain(OSStatus)
|
||||
/// The stored blob was present but could not be decoded.
|
||||
case corruptStoredBlob
|
||||
}
|
||||
|
||||
public extension ClientIdentityStore {
|
||||
/// Convenience for composition roots: load the identity, logging and
|
||||
/// swallowing errors into `nil`. A missing cert is the normal pre-install
|
||||
/// state; a genuine fault must not crash launch, but is logged (never
|
||||
/// silently dropped).
|
||||
func loadedIdentityOrNil() -> ClientIdentity? {
|
||||
do {
|
||||
return try loadIdentity()
|
||||
} catch {
|
||||
ClientTLSLog.identity.error(
|
||||
"loadIdentity failed: \(String(describing: error), privacy: .public)"
|
||||
)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The stored payload — `.p12` bytes plus the passphrase needed to re-import.
|
||||
private struct StoredP12Blob: Codable {
|
||||
let p12: Data
|
||||
let passphrase: String
|
||||
}
|
||||
|
||||
/// Keychain-backed store: one `kSecClassGenericPassword` item holding the
|
||||
/// JSON-encoded `StoredP12Blob` in `kSecValueData`, protected with
|
||||
/// `kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly` (available after the first
|
||||
/// unlock post-boot, never migrates off this device).
|
||||
public struct KeychainClientIdentityStore: ClientIdentityStore {
|
||||
public static let defaultService = "com.yaojia.webterm.clienttls"
|
||||
public static let defaultAccount = "device-identity"
|
||||
|
||||
private let service: String
|
||||
private let account: String
|
||||
|
||||
public init(
|
||||
service: String = defaultService, account: String = defaultAccount
|
||||
) {
|
||||
self.service = service
|
||||
self.account = account
|
||||
}
|
||||
|
||||
public func save(p12Data: Data, passphrase: String) throws {
|
||||
// Validate BEFORE persisting — a wrong passphrase / corrupt file must
|
||||
// surface to the install UI and leave any prior identity untouched.
|
||||
_ = try PKCS12Importer.importIdentity(data: p12Data, passphrase: passphrase)
|
||||
let blob = try encode(StoredP12Blob(p12: p12Data, passphrase: passphrase))
|
||||
try writeItem(blob)
|
||||
}
|
||||
|
||||
public func loadIdentity() throws -> ClientIdentity? {
|
||||
guard let blob = try readBlob() else { return nil }
|
||||
return try PKCS12Importer.importIdentity(
|
||||
data: blob.p12, passphrase: blob.passphrase
|
||||
)
|
||||
}
|
||||
|
||||
public func loadSummary() throws -> ClientCertificateSummary? {
|
||||
try loadIdentity()?.summary()
|
||||
}
|
||||
|
||||
public func remove() throws {
|
||||
let status = SecItemDelete(baseQuery() as CFDictionary)
|
||||
guard status == errSecSuccess || status == errSecItemNotFound else {
|
||||
throw ClientIdentityStoreError.keychain(status)
|
||||
}
|
||||
}
|
||||
|
||||
public func hasInstalledIdentity() -> Bool {
|
||||
var query = baseQuery()
|
||||
query[kSecReturnData as String] = false
|
||||
query[kSecMatchLimit as String] = kSecMatchLimitOne
|
||||
return SecItemCopyMatching(query as CFDictionary, nil) == errSecSuccess
|
||||
}
|
||||
|
||||
// MARK: - Keychain plumbing
|
||||
|
||||
private func baseQuery() -> [String: Any] {
|
||||
[
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: account,
|
||||
]
|
||||
}
|
||||
|
||||
private func writeItem(_ data: Data) throws {
|
||||
// Delete-then-add keeps the item's protection class deterministic
|
||||
// (SecItemUpdate can't change kSecAttrAccessible in place).
|
||||
let deleteStatus = SecItemDelete(baseQuery() as CFDictionary)
|
||||
guard deleteStatus == errSecSuccess || deleteStatus == errSecItemNotFound else {
|
||||
throw ClientIdentityStoreError.keychain(deleteStatus)
|
||||
}
|
||||
var attributes = baseQuery()
|
||||
attributes[kSecValueData as String] = data
|
||||
attributes[kSecAttrAccessible as String] =
|
||||
kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
|
||||
let addStatus = SecItemAdd(attributes as CFDictionary, nil)
|
||||
guard addStatus == errSecSuccess else {
|
||||
throw ClientIdentityStoreError.keychain(addStatus)
|
||||
}
|
||||
}
|
||||
|
||||
private func readBlob() throws -> StoredP12Blob? {
|
||||
var query = baseQuery()
|
||||
query[kSecReturnData as String] = true
|
||||
query[kSecMatchLimit as String] = kSecMatchLimitOne
|
||||
var result: CFTypeRef?
|
||||
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
||||
if status == errSecItemNotFound { return nil }
|
||||
guard status == errSecSuccess, let data = result as? Data else {
|
||||
throw ClientIdentityStoreError.keychain(status)
|
||||
}
|
||||
do {
|
||||
return try JSONDecoder().decode(StoredP12Blob.self, from: data)
|
||||
} catch {
|
||||
throw ClientIdentityStoreError.corruptStoredBlob
|
||||
}
|
||||
}
|
||||
|
||||
private func encode(_ blob: StoredP12Blob) throws -> Data {
|
||||
do {
|
||||
return try JSONEncoder().encode(blob)
|
||||
} catch {
|
||||
throw ClientIdentityStoreError.corruptStoredBlob
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// In-memory store for previews and unit tests: same import/summary code path as
|
||||
/// the keychain store (so the roundtrip is exercised) without any keychain
|
||||
/// entitlement. `@unchecked Sendable` — mutable blob guarded by a lock.
|
||||
public final class InMemoryClientIdentityStore: ClientIdentityStore, @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var blob: StoredP12BlobBox?
|
||||
|
||||
/// Boxed so the private `StoredP12Blob` type stays file-private above; this
|
||||
/// mirror keeps the two bytes+passphrase without exposing the Codable type.
|
||||
private struct StoredP12BlobBox {
|
||||
let p12: Data
|
||||
let passphrase: String
|
||||
}
|
||||
|
||||
public init() {}
|
||||
|
||||
public func save(p12Data: Data, passphrase: String) throws {
|
||||
_ = try PKCS12Importer.importIdentity(data: p12Data, passphrase: passphrase)
|
||||
lock.withLock { blob = StoredP12BlobBox(p12: p12Data, passphrase: passphrase) }
|
||||
}
|
||||
|
||||
public func loadIdentity() throws -> ClientIdentity? {
|
||||
guard let stored = lock.withLock({ blob }) else { return nil }
|
||||
return try PKCS12Importer.importIdentity(
|
||||
data: stored.p12, passphrase: stored.passphrase
|
||||
)
|
||||
}
|
||||
|
||||
public func loadSummary() throws -> ClientCertificateSummary? {
|
||||
try loadIdentity()?.summary()
|
||||
}
|
||||
|
||||
public func remove() throws {
|
||||
lock.withLock { blob = nil }
|
||||
}
|
||||
|
||||
public func hasInstalledIdentity() -> Bool {
|
||||
lock.withLock { blob != nil }
|
||||
}
|
||||
}
|
||||
|
||||
enum ClientTLSLog {
|
||||
static let identity = Logger(subsystem: "com.yaojia.webterm", category: "client-tls")
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import Foundation
|
||||
|
||||
/// C-iOS-1 · The pure, synchronous decision at the heart of mutual TLS.
|
||||
///
|
||||
/// It maps a URLSession auth challenge (+ the optionally-installed device
|
||||
/// identity) to a disposition and credential. Deliberately free of any
|
||||
/// URLSession / socket state so the full truth table is unit-testable without a
|
||||
/// live connection — the transports (WS task delegate, HTTP session delegate)
|
||||
/// are thin adapters that call `resolve` and forward its result to the
|
||||
/// completion handler.
|
||||
///
|
||||
/// Truth table (plan §C-iOS-1):
|
||||
/// a. `ClientCertificate` + identity present → `.useCredential` with the
|
||||
/// identity's `URLCredential`.
|
||||
/// b. `ClientCertificate` + no identity → `.cancelAuthenticationChallenge`
|
||||
/// (a clean, classifiable failure — never a silent no-cert continue).
|
||||
/// c. `ServerTrust` → `.performDefaultHandling`
|
||||
/// (the LE wildcard is validated by the system trust store).
|
||||
/// d. anything else → `.performDefaultHandling`.
|
||||
public struct MutualTLSChallengeResponder: Sendable {
|
||||
/// The responder's decision. Not `Sendable` (it may carry a `URLCredential`,
|
||||
/// which isn't) — it is produced and consumed synchronously on the delegate
|
||||
/// queue, never sent across isolation domains.
|
||||
public struct Resolution {
|
||||
public let disposition: URLSession.AuthChallengeDisposition
|
||||
public let credential: URLCredential?
|
||||
|
||||
public init(
|
||||
disposition: URLSession.AuthChallengeDisposition,
|
||||
credential: URLCredential? = nil
|
||||
) {
|
||||
self.disposition = disposition
|
||||
self.credential = credential
|
||||
}
|
||||
}
|
||||
|
||||
public init() {}
|
||||
|
||||
/// Resolve a full `URLAuthenticationChallenge` by dispatching on its
|
||||
/// authentication method.
|
||||
public func resolve(
|
||||
_ challenge: URLAuthenticationChallenge, identity: ClientIdentity?
|
||||
) -> Resolution {
|
||||
resolve(
|
||||
authenticationMethod: challenge.protectionSpace.authenticationMethod,
|
||||
identity: identity
|
||||
)
|
||||
}
|
||||
|
||||
/// Method-keyed core (challenge-free) — the directly-tested pure function.
|
||||
public func resolve(
|
||||
authenticationMethod: String, identity: ClientIdentity?
|
||||
) -> Resolution {
|
||||
switch authenticationMethod {
|
||||
case NSURLAuthenticationMethodClientCertificate:
|
||||
guard let identity else {
|
||||
// (b) No installed identity: cancel so the failure surfaces as a
|
||||
// classifiable client-cert error rather than a bare TLS reset.
|
||||
return Resolution(disposition: .cancelAuthenticationChallenge)
|
||||
}
|
||||
// (a) Present the device certificate for this session.
|
||||
return Resolution(
|
||||
disposition: .useCredential, credential: identity.urlCredential()
|
||||
)
|
||||
case NSURLAuthenticationMethodServerTrust:
|
||||
// (c) System-trusted LE wildcard — default evaluation.
|
||||
return Resolution(disposition: .performDefaultHandling)
|
||||
default:
|
||||
// (d) Basic/Digest/NTLM/etc. — not used by this app; default.
|
||||
return Resolution(disposition: .performDefaultHandling)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import Foundation
|
||||
import Security
|
||||
|
||||
/// Failure modes of a PKCS#12 import, each mapped to actionable install-UX copy
|
||||
/// (C-iOS-3). The three plan-named `OSStatus` mappings are honored:
|
||||
/// `errSecAuthFailed → .wrongPassphrase`, `errSecDecode → .corruptFile`, and any
|
||||
/// other non-success status (unsupported/unknown format — the plan's "errSecPkg"
|
||||
/// bucket) → `.unsupported`.
|
||||
public enum PKCS12ImportError: Error, Equatable, Sendable {
|
||||
/// Wrong passphrase (`errSecAuthFailed`).
|
||||
case wrongPassphrase
|
||||
/// Not a decodable PKCS#12 blob (`errSecDecode`) — truncated / not a `.p12`.
|
||||
case corruptFile
|
||||
/// Decoded, but the format/algorithms aren't importable on this OS
|
||||
/// (any other non-success `OSStatus`). Retains the raw status for logs.
|
||||
case unsupported(OSStatus)
|
||||
/// Import succeeded but carried no `SecIdentity` (e.g. a certs-only `.p12`).
|
||||
case noIdentity
|
||||
|
||||
public static func == (lhs: PKCS12ImportError, rhs: PKCS12ImportError) -> Bool {
|
||||
switch (lhs, rhs) {
|
||||
case (.wrongPassphrase, .wrongPassphrase),
|
||||
(.corruptFile, .corruptFile),
|
||||
(.noIdentity, .noIdentity):
|
||||
return true
|
||||
case let (.unsupported(a), .unsupported(b)):
|
||||
return a == b
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Imports a `.p12` into a `ClientIdentity` via `SecPKCS12Import`.
|
||||
///
|
||||
/// Memory-only: `SecPKCS12Import` returns the identity/chain as CoreFoundation
|
||||
/// handles without writing to the keychain, so callers control persistence
|
||||
/// (see `KeychainClientIdentityStore`).
|
||||
public enum PKCS12Importer {
|
||||
public static func importIdentity(
|
||||
data: Data, passphrase: String
|
||||
) throws -> ClientIdentity {
|
||||
let options = [kSecImportExportPassphrase as String: passphrase] as CFDictionary
|
||||
var rawItems: CFArray?
|
||||
let status = SecPKCS12Import(data as CFData, options, &rawItems)
|
||||
|
||||
switch status {
|
||||
case errSecSuccess:
|
||||
break
|
||||
case errSecAuthFailed:
|
||||
throw PKCS12ImportError.wrongPassphrase
|
||||
case errSecDecode:
|
||||
throw PKCS12ImportError.corruptFile
|
||||
default:
|
||||
// Covers unsupported formats/algorithms and any other failure —
|
||||
// the plan's `errSecPkg → .unsupported` mapping (that symbol does
|
||||
// not exist in the SDK; the status is preserved for diagnostics).
|
||||
throw PKCS12ImportError.unsupported(status)
|
||||
}
|
||||
|
||||
guard let items = rawItems as? [[String: Any]], let first = items.first,
|
||||
let identityValue = first[kSecImportItemIdentity as String]
|
||||
else {
|
||||
throw PKCS12ImportError.noIdentity
|
||||
}
|
||||
// Force-cast is safe: `kSecImportItemIdentity` is always a SecIdentity.
|
||||
let secIdentity = identityValue as! SecIdentity
|
||||
let chain = (first[kSecImportItemCertChain as String] as? [SecCertificate]) ?? []
|
||||
return ClientIdentity(
|
||||
secIdentity: secIdentity,
|
||||
issuerCertificates: issuerChain(from: chain, identity: secIdentity)
|
||||
)
|
||||
}
|
||||
|
||||
/// The chain from `SecPKCS12Import` includes the leaf at index 0; the
|
||||
/// `URLCredential` must carry only the *issuer* certs (Apple: do not repeat
|
||||
/// the identity's own cert). Drops whichever chain entry DER-matches the
|
||||
/// identity's leaf.
|
||||
private static func issuerChain(
|
||||
from chain: [SecCertificate], identity: SecIdentity
|
||||
) -> [SecCertificate] {
|
||||
var leaf: SecCertificate?
|
||||
SecIdentityCopyCertificate(identity, &leaf)
|
||||
guard let leafData = leaf.map({ SecCertificateCopyData($0) as Data }) else {
|
||||
return chain
|
||||
}
|
||||
return chain.filter { SecCertificateCopyData($0) as Data != leafData }
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
33
ios/Packages/ClientTLS/Tests/ClientTLSTests/Fixtures.swift
Normal file
33
ios/Packages/ClientTLS/Tests/ClientTLSTests/Fixtures.swift
Normal 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 = "MIIF8wIBAzCCBbkGCSqGSIb3DQEHAaCCBaoEggWmMIIFojCCBGcGCSqGSIb3DQEHBqCCBFgwggRUAgEAMIIETQYJKoZIhvcNAQcBMBwGCiqGSIb3DQEMAQYwDgQIKBUqQZQxk0wCAggAgIIEIBIkpttDOWO6edQjmr9V3ASfsuc0m+Wdl8//Yrt5FPgz5HOhYZKhOUXhBLWyGXPnTx8fBrC1Yk4YmXwQisHT/4PsfvwIAgF9PBFo3UNwsQLATWHrplUF1rTO9uB5Luju306Ox9QYB+VP64BOFxdixtvhjZF8LemfJ6bV/9DWmXOk5UoGdd0c5/6WeYUTfr1aFG/TJo+GOHa5CD5fW5Mr4SejYKNcvvT2Fki5kCKAQRbhJb8W7+RzcmYoF6ECzCNeSnDNenpZm2xowpdGP/6d3U7MbrXj4bmAtQYljAnA391atw/bm2T9d8Q6CPx5QD+WqMNdKN6tYV9OvDB6XB+VPimVMd0GCNYaIKazHj0ohNYqvEYDjSaYgbBvLHJyJRqnumyjwMz4o26NoTGm1m/U28pUGUJL3njYhuyxKilat85oXT3YO4x16bCeF7fmuGVkakeZjrxvxAKap8rD5yPaDd533Va9+xg1byIj7h01Q5tiOXO3sqkEYB1XI9/n78Et5eZItMYEH53v4uE9NRWZmetA7TQ5uRon4EK1ajKIJaQi/6lGdCAzLf5tysZ7A8vfJPJeBRFI2a9QYQzcyL8sZWEfAhJszRo59g6LWZn87DXL6EFEP/UvV0tDy6kPz/OzUmDGMrWNbeCX/5zI/xaFaf+2OpiXkJYwMUybi9JkTmCfNeOLdpPjHI0lJ47iG2cmiiMNYWJ5hXKqmdxigQt5W/5WShjD4iYh6PcjzdD6IBD5UT8d3fTKWa4cvJs2bnILjElXOWo+s8o5NGAL359+QbCQfiWYha1P4SdSNhX98SXy0K6Dla0+Ny+miEVIf5N0jXvNlsJnnjjGgK+9gPGlU9mEbpF+4EqV2XgGp+Tg8ZGO489kK2S37gJTKMUhU+haMhAF0eoj5FSMN30LcXnHTjMjsps4hpeGeUu8gb0dm96+vvmAAibt9wnCULgMFKIEpP10IY5l4D8puLTi+smvf0MMvvrGRC3+aAq+jfemQEmJZgfhlhtr1O5DdUoTvcWazmNdNScwoJ68M/D/45pplBUglECub/Ib34HKNGmEMX5+tbDEHxe589A2Jwxvq1meycCnj3IoL/lXSprB7jWt3ZInURTJItaS5GSw9D4vLboe4OISAHZftdgGJea2rXJYPZk6N5L0EbLfftEh+zzOTb3Zr3MsIiCdxpWLshaDfiijRgVYsAd0rsx0LG+8+Icb33KvN3MK/ol8PjavM+T7F2qUIiKyaA5eZ9B5CAkm7YGvrE1TBYHeEpsgvaGnRyl3PcPYSCoOQ5ckZ67briJlf6OpZM+5P6u/MP/BWhQa8Ksox2SO4aZEtDzceWAHI4ccJSbD8h/jpoLZbSm2Z+CqNUmqr1atZnZgpgArKdcnxErljkCOfkp+k6nRkhg5RkbOCjCCATMGCSqGSIb3DQEHAaCCASQEggEgMIIBHDCCARgGCyqGSIb3DQEMCgECoIG0MIGxMBwGCiqGSIb3DQEMAQMwDgQIybmfLdxQ2ZUCAggABIGQQm1iiwDNnPOMO1rBboRmVPjVzV8OLeEVLNz2qhOhSR7nEBMSw289dZzdgbY/PZh2GrO0duJSHIzjBj7W9ShHVB6zdl0kZx1JRh5aJie3eEZ76l9zVOZadp2T0wCd7HD7c4rOYJFjuTwPVB4/GWzlA3r2HVGiyuQR8N8Z855M97/KrUCByR6HG1Nl8e1+EYyrMVIwIwYJKoZIhvcNAQkVMRYEFBZc8x6cgn9EB/NImL993PHArfe6MCsGCSqGSIb3DQEJFDEeHhwAZABlAHYAaQBjAGUALQBmAGkAeAB0AHUAcgBlMDEwITAJBgUrDgMCGgUABBQGw7bWopAP93FLjXc4LIiReHWd5AQISUwQzLqUrPECAggA"
|
||||
}
|
||||
@@ -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()
|
||||
)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
// swift-tools-version: 6.0
|
||||
// T-iOS-1 scaffold shell. SessionEngine / ReconnectMachine / PingScheduler /
|
||||
// GateState / AwayDigest / URLSessionTermTransport land in W1–W2 tasks.
|
||||
// Dependency direction is strictly downward: SessionCore → WireProtocol only.
|
||||
// Dependency direction is strictly downward: SessionCore → WireProtocol, plus
|
||||
// ClientTLS (C-iOS-2) for the device client-cert mTLS responder the WS transport
|
||||
// answers connection-level challenges with. Both are downward leaves.
|
||||
import PackageDescription
|
||||
|
||||
let package = Package(
|
||||
@@ -12,12 +14,16 @@ let package = Package(
|
||||
],
|
||||
dependencies: [
|
||||
.package(path: "../WireProtocol"),
|
||||
.package(path: "../ClientTLS"),
|
||||
.package(path: "../TestSupport"),
|
||||
],
|
||||
targets: [
|
||||
.target(
|
||||
name: "SessionCore",
|
||||
dependencies: [.product(name: "WireProtocol", package: "WireProtocol")]
|
||||
dependencies: [
|
||||
.product(name: "WireProtocol", package: "WireProtocol"),
|
||||
.product(name: "ClientTLS", package: "ClientTLS"),
|
||||
]
|
||||
),
|
||||
.testTarget(
|
||||
name: "SessionCoreTests",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import ClientTLS
|
||||
import Darwin
|
||||
import Foundation
|
||||
import WireProtocol
|
||||
@@ -76,16 +77,45 @@ protocol ConnectionPinger: Sendable {
|
||||
public struct URLSessionTermTransport: TermTransport {
|
||||
/// `URLSessionWebSocketTask.maximumMessageSize` applied to every task.
|
||||
private let maxMessageBytes: Int
|
||||
/// C-iOS-2 (MEDIUM no-relaunch fix) · Resolve the optional device client
|
||||
/// identity LAZILY, once per `connect`, so a certificate imported mid-run
|
||||
/// takes effect on the NEXT connection without an app relaunch (a snapshot
|
||||
/// captured at composition would stay stale). When the resolved identity is
|
||||
/// present, the connection answers a `ClientCertificate` challenge (mTLS to a
|
||||
/// tunneled host) with its credential; when `nil`, the challenge is cancelled
|
||||
/// cleanly. Local (non-mTLS) hosts never issue the challenge, so a `nil`
|
||||
/// result is inert there.
|
||||
private let identityProvider: @Sendable () -> ClientIdentity?
|
||||
|
||||
public init() {
|
||||
self.init(maxMessageBytes: Tunables.maxWSMessageBytes)
|
||||
/// Fixed-identity convenience (snapshot callers / tests): wraps a constant
|
||||
/// provider, so behaviour is identical to capturing the identity directly.
|
||||
public init(identity: ClientIdentity? = nil) {
|
||||
self.init(identityProvider: { identity })
|
||||
}
|
||||
|
||||
/// C-iOS-2 (MEDIUM no-relaunch fix) · per-connect identity resolution: the
|
||||
/// provider is re-consulted on every `connect`, so a nil→installed
|
||||
/// transition is picked up without relaunch.
|
||||
public init(identityProvider: @escaping @Sendable () -> ClientIdentity?) {
|
||||
self.init(
|
||||
maxMessageBytes: Tunables.maxWSMessageBytes, identityProvider: identityProvider
|
||||
)
|
||||
}
|
||||
|
||||
/// Internal TEST seam: a shrunken cap makes the oversize→EMSGSIZE path
|
||||
/// deterministic without 16 MiB fixtures. Production code paths always go
|
||||
/// through `init()` and the frozen `Tunables.maxWSMessageBytes`.
|
||||
init(maxMessageBytes: Int) {
|
||||
/// through `init()` / `init(identityProvider:)` and the frozen
|
||||
/// `Tunables.maxWSMessageBytes`.
|
||||
init(maxMessageBytes: Int, identity: ClientIdentity? = nil) {
|
||||
self.init(maxMessageBytes: maxMessageBytes, identityProvider: { identity })
|
||||
}
|
||||
|
||||
init(
|
||||
maxMessageBytes: Int,
|
||||
identityProvider: @escaping @Sendable () -> ClientIdentity?
|
||||
) {
|
||||
self.maxMessageBytes = maxMessageBytes
|
||||
self.identityProvider = identityProvider
|
||||
}
|
||||
|
||||
public func connect(to endpoint: HostEndpoint) async throws -> TransportConnection {
|
||||
@@ -93,9 +123,13 @@ public struct URLSessionTermTransport: TermTransport {
|
||||
}
|
||||
|
||||
/// Internal: concrete-typed connect (tests assert task configuration;
|
||||
/// `connectPingable` builds on it).
|
||||
/// `connectPingable` builds on it). The identity is resolved HERE, per
|
||||
/// connect, from `identityProvider` (no-relaunch pickup).
|
||||
func openConnection(to endpoint: HostEndpoint) async throws -> WSConnection {
|
||||
try await WSConnection.open(endpoint: endpoint, maxMessageBytes: maxMessageBytes)
|
||||
try await WSConnection.open(
|
||||
endpoint: endpoint, maxMessageBytes: maxMessageBytes,
|
||||
identity: identityProvider()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,6 +169,13 @@ final class WSConnection: NSObject, @unchecked Sendable {
|
||||
private let frames: AsyncThrowingStream<String, any Error>
|
||||
private let framesContinuation: AsyncThrowingStream<String, any Error>.Continuation
|
||||
|
||||
/// C-iOS-2 mTLS. Set once in `configure` BEFORE `task.resume()`, so the
|
||||
/// connection-level challenge (which can only arrive after resume) always
|
||||
/// reads a stable value without locking — same set-once discipline as
|
||||
/// `task`/`session`.
|
||||
private var identity: ClientIdentity?
|
||||
private let challengeResponder = MutualTLSChallengeResponder()
|
||||
|
||||
private override init() {
|
||||
(frames, framesContinuation) = AsyncThrowingStream<String, any Error>.makeStream()
|
||||
super.init()
|
||||
@@ -143,9 +184,13 @@ final class WSConnection: NSObject, @unchecked Sendable {
|
||||
/// Connect: build session+task, resume, await the delegate-driven
|
||||
/// handshake (didOpen / didCompleteWithError — no receive-error guessing),
|
||||
/// then start the re-arming receive loop.
|
||||
static func open(endpoint: HostEndpoint, maxMessageBytes: Int) async throws -> WSConnection {
|
||||
static func open(
|
||||
endpoint: HostEndpoint, maxMessageBytes: Int, identity: ClientIdentity? = nil
|
||||
) async throws -> WSConnection {
|
||||
let connection = WSConnection()
|
||||
connection.configure(endpoint: endpoint, maxMessageBytes: maxMessageBytes)
|
||||
connection.configure(
|
||||
endpoint: endpoint, maxMessageBytes: maxMessageBytes, identity: identity
|
||||
)
|
||||
try await connection.performHandshake()
|
||||
connection.startReceiveLoop()
|
||||
return connection
|
||||
@@ -163,7 +208,12 @@ final class WSConnection: NSObject, @unchecked Sendable {
|
||||
|
||||
// MARK: Setup
|
||||
|
||||
private func configure(endpoint: HostEndpoint, maxMessageBytes: Int) {
|
||||
private func configure(
|
||||
endpoint: HostEndpoint, maxMessageBytes: Int, identity: ClientIdentity?
|
||||
) {
|
||||
// Set BEFORE building the task/resume: the mTLS challenge can only fire
|
||||
// after `task.resume()`, so this write happens-before any read of it.
|
||||
self.identity = identity
|
||||
// Origin: SINGLE source of truth = endpoint.originHeader (plan §5.1).
|
||||
var request = URLRequest(url: endpoint.wsURL)
|
||||
request.setValue(endpoint.originHeader, forHTTPHeaderField: "Origin")
|
||||
@@ -311,6 +361,22 @@ extension WSConnection: URLSessionWebSocketDelegate {
|
||||
takeHandshakeContinuation()?.resume()
|
||||
}
|
||||
|
||||
/// C-iOS-2 · Connection-level auth challenge (server trust + client cert)
|
||||
/// arrives task-level for a WS task. Delegate the decision to the pure
|
||||
/// `MutualTLSChallengeResponder`: present the device identity for an mTLS
|
||||
/// tunnel host, default-handle the LE server trust, cancel cleanly if a
|
||||
/// client cert is demanded but none is installed. `identity` is set-once
|
||||
/// before `resume()` (see `configure`), so this read needs no lock.
|
||||
func urlSession(
|
||||
_ session: URLSession,
|
||||
task: URLSessionTask,
|
||||
didReceive challenge: URLAuthenticationChallenge,
|
||||
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
|
||||
) {
|
||||
let resolution = challengeResponder.resolve(challenge, identity: identity)
|
||||
completionHandler(resolution.disposition, resolution.credential)
|
||||
}
|
||||
|
||||
/// Server close frame processed → CLEAN close: the stream FINISHES
|
||||
/// (distinguishable from the transport-error THROW path).
|
||||
func urlSession(
|
||||
|
||||
@@ -243,6 +243,29 @@ struct URLSessionTermTransportTests {
|
||||
await connection.close()
|
||||
}
|
||||
|
||||
@Test("identity provider is resolved PER CONNECT (no-relaunch pickup, not captured once)")
|
||||
func identityProviderResolvedPerConnect() async throws {
|
||||
// A cert imported mid-run must take effect on the NEXT connection. The
|
||||
// transport therefore re-consults its provider on every `connect` rather
|
||||
// than capturing a snapshot — proven here by a provider whose invocation
|
||||
// count grows once per connect (nil identity: ScriptedWSServer is plain
|
||||
// ws, so the connection succeeds regardless).
|
||||
let (server, endpoint) = try await Self.startServer()
|
||||
defer { server.stop() }
|
||||
let calls = CallCounter()
|
||||
let transport = URLSessionTermTransport(identityProvider: {
|
||||
calls.increment()
|
||||
return nil
|
||||
})
|
||||
|
||||
let first = try await transport.connect(to: endpoint)
|
||||
await first.close()
|
||||
let second = try await transport.connect(to: endpoint)
|
||||
await second.close()
|
||||
|
||||
#expect(calls.value == 2)
|
||||
}
|
||||
|
||||
@Test("connect to a dead port throws (handshake failure path, no hang)")
|
||||
func connectToDeadPortThrows() async throws {
|
||||
let server = ScriptedWSServer()
|
||||
@@ -254,5 +277,14 @@ struct URLSessionTermTransportTests {
|
||||
_ = try await URLSessionTermTransport().connect(to: endpoint)
|
||||
}
|
||||
}
|
||||
|
||||
/// Thread-safe invocation counter for the `@Sendable` identity provider
|
||||
/// (URLSession may consult it off the test's isolation domain).
|
||||
private final class CallCounter: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var count = 0
|
||||
func increment() { lock.withLock { count += 1 } }
|
||||
var value: Int { lock.withLock { count } }
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
Reference in New Issue
Block a user