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:
Yaojia Wang
2026-07-07 09:42:12 +02:00
parent 5337281e85
commit e38e6d1689
25 changed files with 1510 additions and 30 deletions

View File

@@ -0,0 +1,243 @@
import ClientTLS
import Foundation
import Observation
import SwiftUI
import UniformTypeIdentifiers
/// C-iOS-3 · Device-certificate install / rotation screen.
///
/// Flow: `.fileImporter([.pkcs12])` secure passphrase import (validates via
/// `SecPKCS12Import`) persist in the keychain show issuer CN + expiry, with
/// replace/rotate + remove. This is the in-app, app-sandboxed install path (a
/// `.p12` delivered via AirDrop/Files), NOT a configuration profile.
///
/// INTEGRATION NOTE: presenting this screen needs a one-line nav/Settings entry
/// ("Device certificate") in the app chrome (RootView / PairingScreen), which
/// live outside this task's ownership. The screen is fully self-contained so
/// that wiring is a single `NavigationLink { ClientCertScreen() }`.
struct ClientCertScreen: View {
@State private var model: ClientCertViewModel
init(model: ClientCertViewModel = ClientCertViewModel()) {
_model = State(initialValue: model)
}
var body: some View {
Form {
installedSection
importSection
if model.summary != nil {
removeSection
}
}
.navigationTitle(ClientCertCopy.title)
.fileImporter(
isPresented: $model.isPickingFile,
allowedContentTypes: [.pkcs12],
allowsMultipleSelection: false
) { result in
model.handleFileSelection(result)
}
.task { model.refresh() }
}
// MARK: - Sections
@ViewBuilder private var installedSection: some View {
Section(ClientCertCopy.installedHeader) {
if let summary = model.summary {
LabeledContent(ClientCertCopy.subject, value: summary.subjectCommonName ?? "")
LabeledContent(ClientCertCopy.issuer, value: summary.issuerCommonName ?? "")
LabeledContent(ClientCertCopy.expiry, value: model.expiryText(summary))
if summary.isExpired() {
Label(ClientCertCopy.expiredWarning, systemImage: "exclamationmark.triangle")
.foregroundStyle(.orange)
}
} else {
Text(ClientCertCopy.noneInstalled).foregroundStyle(.secondary)
}
}
}
@ViewBuilder private var importSection: some View {
Section {
if let name = model.pendingFileName {
LabeledContent(ClientCertCopy.selectedFile, value: name)
SecureField(ClientCertCopy.passphrase, text: $model.passphrase)
.textContentType(.password)
.autocorrectionDisabled()
Button(ClientCertCopy.importAction) { model.importPending() }
.disabled(!model.canImport)
Button(ClientCertCopy.cancelAction, role: .cancel) { model.clearPending() }
} else {
Button {
model.beginPicking()
} label: {
Label(
model.summary == nil
? ClientCertCopy.chooseFile
: ClientCertCopy.replaceFile,
systemImage: "doc.badge.plus"
)
}
}
if let error = model.errorMessage {
Text(error).foregroundStyle(.red).font(.footnote)
}
} header: {
Text(model.summary == nil ? ClientCertCopy.importHeader : ClientCertCopy.rotateHeader)
} footer: {
Text(ClientCertCopy.importFooter)
}
}
@ViewBuilder private var removeSection: some View {
Section {
Button(ClientCertCopy.removeAction, role: .destructive) { model.remove() }
}
}
}
/// State machine for the install screen. Errors are mapped to actionable copy
/// and never swallowed; a failed import leaves any prior identity intact.
@MainActor
@Observable
final class ClientCertViewModel {
private(set) var summary: ClientCertificateSummary?
var isPickingFile = false
var passphrase = ""
private(set) var pendingData: Data?
private(set) var pendingFileName: String?
private(set) var errorMessage: String?
@ObservationIgnored private let store: any ClientIdentityStore
init(store: any ClientIdentityStore = KeychainClientIdentityStore()) {
self.store = store
}
var canImport: Bool { pendingData != nil && !passphrase.isEmpty }
func refresh() {
do {
summary = try store.loadSummary()
} catch {
summary = nil
errorMessage = ClientCertCopy.loadFailed
}
}
func beginPicking() {
errorMessage = nil
isPickingFile = true
}
func handleFileSelection(_ result: Result<[URL], Error>) {
switch result {
case .failure:
errorMessage = ClientCertCopy.fileReadFailed
case .success(let urls):
guard let url = urls.first else { return }
readFile(at: url)
}
}
private func readFile(at url: URL) {
let didAccess = url.startAccessingSecurityScopedResource()
defer { if didAccess { url.stopAccessingSecurityScopedResource() } }
do {
pendingData = try Data(contentsOf: url)
pendingFileName = url.lastPathComponent
passphrase = ""
errorMessage = nil
} catch {
pendingData = nil
pendingFileName = nil
errorMessage = ClientCertCopy.fileReadFailed
}
}
func importPending() {
guard let data = pendingData else { return }
do {
try store.save(p12Data: data, passphrase: passphrase)
clearPending()
refresh()
} catch {
errorMessage = Self.copy(for: error)
}
}
func clearPending() {
pendingData = nil
pendingFileName = nil
passphrase = ""
errorMessage = nil
}
func remove() {
do {
try store.remove()
summary = nil
errorMessage = nil
} catch {
errorMessage = ClientCertCopy.removeFailed
}
}
func expiryText(_ summary: ClientCertificateSummary) -> String {
guard let notAfter = summary.notAfter else { return "" }
return notAfter.formatted(date: .abbreviated, time: .omitted)
}
/// Map an import/storage error to install-UX copy (never swallowed).
private static func copy(for error: any Error) -> String {
switch error {
case PKCS12ImportError.wrongPassphrase:
return ClientCertCopy.errWrongPassphrase
case PKCS12ImportError.corruptFile:
return ClientCertCopy.errCorruptFile
case PKCS12ImportError.unsupported:
return ClientCertCopy.errUnsupported
case PKCS12ImportError.noIdentity:
return ClientCertCopy.errNoIdentity
case ClientIdentityStoreError.keychain, ClientIdentityStoreError.corruptStoredBlob:
return ClientCertCopy.errKeychain
default:
return ClientCertCopy.errUnknown
}
}
}
/// User-facing copy (Chinese, matching the rest of the app).
enum ClientCertCopy {
static let title = "设备证书"
static let installedHeader = "已安装证书"
static let subject = "设备 (CN)"
static let issuer = "签发 CA"
static let expiry = "有效期至"
static let expiredWarning = "证书已过期,请重新导入。"
static let noneInstalled = "尚未安装设备证书。"
static let importHeader = "导入证书"
static let rotateHeader = "替换证书"
static let chooseFile = "选择 .p12 文件"
static let replaceFile = "选择新的 .p12 文件"
static let selectedFile = "已选文件"
static let passphrase = "证书密码"
static let importAction = "导入"
static let cancelAction = "取消"
static let removeAction = "移除证书"
static let importFooter =
"证书用于连接你自己的隧道主机 (mTLS)。通过 AirDrop / 文件 把 .p12 传到本机后在此导入;证书与密码只保存在本设备钥匙串。"
static let errWrongPassphrase = "密码错误,请重试。"
static let errCorruptFile = "文件无法识别,请确认是有效的 .p12 证书。"
static let errUnsupported = "证书格式不受支持(请用 openssl pkcs12 -export -legacy 生成)。"
static let errNoIdentity = "该 .p12 不含身份(缺少私钥),无法用于客户端认证。"
static let errKeychain = "保存到钥匙串失败,请重试。"
static let errUnknown = "导入失败,请重试。"
static let loadFailed = "读取已安装证书失败。"
static let fileReadFailed = "无法读取所选文件。"
static let removeFailed = "移除证书失败,请重试。"
}

View File

@@ -23,6 +23,12 @@ struct SessionListScreen: View {
var onOpen: (SessionListViewModel.OpenRequest) -> Void = { _ in }
/// Host-switch header hook: "" entry (pairing sheet, T-iOS-15).
var onAddHost: () -> Void = {}
/// C-iOS-3 (HIGH reachability fix) · "" entry presents
/// `ClientCertScreen` (import / rotate the mTLS device cert). The device
/// cert is a global concern, but the toolbar host-menu is the app's only
/// settings-like surface and is present in every empty state, so this is the
/// nav idiom that makes the orphaned screen reachable.
var onDeviceCert: () -> Void = {}
/// T-iOS-28 (additive slot) · shared thumbnail pipeline: one cache + one
/// render-concurrency gate across ALL rows (scrolling must never spawn
/// unbounded offscreen terminals). `@State` keeps it stable across body
@@ -213,6 +219,12 @@ struct SessionListScreen: View {
} label: {
Label(ScreenCopy.addHost, systemImage: "plus")
}
Button {
onDeviceCert()
} label: {
Label(ScreenCopy.deviceCert, systemImage: "lock.shield")
}
.accessibilityIdentifier("sessions.deviceCert")
} label: {
Label(
viewModel.activeHost?.name ?? ScreenCopy.hostMenuFallback,
@@ -342,6 +354,7 @@ private enum ScreenCopy {
static let newSession = "新建会话"
static let kill = "结束"
static let addHost = "配对新主机"
static let deviceCert = "设备证书"
static let hostMenuFallback = "主机"
static let notPairedTitle = "还没有配对的主机"
static let notPairedHint = "先配对你电脑上的 web-terminal扫码或手输地址会话会出现在这里。"