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 = "移除证书失败,请重试。" }