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

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