- 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.
189 lines
8.0 KiB
Swift
189 lines
8.0 KiB
Swift
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)
|
|
}
|
|
}
|