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:
243
ios/App/WebTerm/Screens/ClientCertScreen.swift
Normal file
243
ios/App/WebTerm/Screens/ClientCertScreen.swift
Normal 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 = "移除证书失败,请重试。"
|
||||
}
|
||||
@@ -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(扫码或手输地址),会话会出现在这里。"
|
||||
|
||||
Reference in New Issue
Block a user