- 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.
244 lines
8.7 KiB
Swift
244 lines
8.7 KiB
Swift
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 = "移除证书失败,请重试。"
|
|
}
|