import ClientTLS import Foundation import Observation import SwiftUI import UIKit import WireProtocol /// B3 · Zero-`.p12` device-enrollment screen — the phone half of zero-touch. /// /// Flow: one operator login → a short-lived `device:enroll` bearer → a /// NON-EXPORTABLE Secure-Enclave key + PKCS#10 CSR → `POST /device/enroll` → /// the returned leaf is stored against the SE key and presents AUTOMATICALLY on /// the existing mTLS path (no manual certificate import, ever). After this one /// login the cert renews itself silently (`CertificateRotationScheduler`). /// /// Pure presentation over `EnrollmentViewModel`; validation, the flow, and error /// mapping live in the VM. Presented from the host-menu ("自动获取证书"). struct EnrollmentScreen: View { @State private var model: EnrollmentViewModel init(model: EnrollmentViewModel) { _model = State(initialValue: model) } var body: some View { Form { installedSection enrollSection } .navigationTitle(EnrollmentCopy.title) .task { model.refresh() } } // MARK: - Sections @ViewBuilder private var installedSection: some View { Section(EnrollmentCopy.installedHeader) { if let summary = model.summary { LabeledContent(EnrollmentCopy.subject, value: summary.subjectCommonName ?? "—") LabeledContent(EnrollmentCopy.issuer, value: summary.issuerCommonName ?? "—") LabeledContent(EnrollmentCopy.expiry, value: model.expiryText(summary)) if summary.isExpired() { Label(EnrollmentCopy.expiredWarning, systemImage: "exclamationmark.triangle") .foregroundStyle(.orange) } } else { Text(EnrollmentCopy.noneInstalled).foregroundStyle(.secondary) } } } @ViewBuilder private var enrollSection: some View { Section { TextField(EnrollmentCopy.controlPlaneURL, text: $model.controlPlaneURLText) .textContentType(.URL) .keyboardType(.URL) .textInputAutocapitalization(.never) .autocorrectionDisabled() TextField(EnrollmentCopy.subdomain, text: $model.subdomain) .textInputAutocapitalization(.never) .autocorrectionDisabled() TextField(EnrollmentCopy.deviceName, text: $model.deviceName) SecureField(EnrollmentCopy.password, text: $model.password) .textContentType(.password) .autocorrectionDisabled() Button { Task { await model.enroll() } } label: { if model.isEnrolling { ProgressView() } else { Label( model.summary == nil ? EnrollmentCopy.enrollAction : EnrollmentCopy.rotateAction, systemImage: "checkmark.shield" ) } } .disabled(!model.canEnroll) .accessibilityIdentifier("enroll.submit") if let error = model.errorMessage { Text(error).foregroundStyle(.red).font(.footnote) } if model.didSucceed { Label(EnrollmentCopy.success, systemImage: "checkmark.circle.fill") .foregroundStyle(.green).font(.footnote) } } header: { Text(model.summary == nil ? EnrollmentCopy.enrollHeader : EnrollmentCopy.rotateHeader) } footer: { Text(EnrollmentCopy.footer) } } } /// B3 · Enrollment state machine. Validates inputs at the boundary, drives /// `DeviceEnrollmentFlow`, and maps every failure to actionable copy — never /// swallowed, never leaking the password or bearer. @MainActor @Observable final class EnrollmentViewModel { /// The enroll operation: login → SE keygen+CSR → enroll → store. Injected so /// the VM is unit-testable without a keychain / network (production wires it /// to `DeviceEnrollmentFlow`). typealias EnrollOperation = @Sendable ( _ password: String, _ subdomain: String, _ deviceName: String, _ controlPlaneURL: URL ) async throws -> ClientCertificateSummary? var controlPlaneURLText: String var subdomain: String var deviceName: String var password = "" private(set) var summary: ClientCertificateSummary? private(set) var isEnrolling = false private(set) var didSucceed = false private(set) var errorMessage: String? @ObservationIgnored private let enrollOperation: EnrollOperation @ObservationIgnored private let loadSummary: @Sendable () -> ClientCertificateSummary? @ObservationIgnored private let persistSettings: @Sendable (_ url: String, _ subdomain: String) -> Void init( controlPlaneURLText: String = EnrollmentSettings.controlPlaneURL ?? EnrollmentCopy.defaultControlPlaneURL, subdomain: String = EnrollmentSettings.subdomain ?? "", deviceName: String = UIDevice.current.name, enrollOperation: @escaping EnrollOperation, loadSummary: @escaping @Sendable () -> ClientCertificateSummary?, persistSettings: @escaping @Sendable (_ url: String, _ subdomain: String) -> Void = { url, sub in EnrollmentSettings.controlPlaneURL = url EnrollmentSettings.subdomain = sub } ) { self.controlPlaneURLText = controlPlaneURLText self.subdomain = subdomain self.deviceName = deviceName self.enrollOperation = enrollOperation self.loadSummary = loadSummary self.persistSettings = persistSettings } /// Fields all present → the button is enabled (deeper URL validation on tap). var canEnroll: Bool { !isEnrolling && !controlPlaneURLText.trimmed.isEmpty && !subdomain.trimmed.isEmpty && !deviceName.trimmed.isEmpty && !password.isEmpty } func refresh() { summary = loadSummary() } func expiryText(_ summary: ClientCertificateSummary) -> String { guard let notAfter = summary.notAfter else { return "—" } return notAfter.formatted(date: .abbreviated, time: .omitted) } /// Run enrollment. Validates the control-plane URL (must be https with a /// host) before any network, then drives the flow. The password is cleared /// after every attempt (success OR failure) so it never lingers in memory. func enroll() async { errorMessage = nil didSucceed = false let trimmedURL = controlPlaneURLText.trimmed let trimmedSubdomain = subdomain.trimmed let trimmedName = deviceName.trimmed guard let url = URL(string: trimmedURL), url.scheme?.lowercased() == "https", let host = url.host, !host.isEmpty else { errorMessage = EnrollmentCopy.errInvalidURL return } guard !trimmedSubdomain.isEmpty, !trimmedName.isEmpty, !password.isEmpty else { errorMessage = EnrollmentCopy.errMissingFields return } isEnrolling = true defer { isEnrolling = false } let secret = password do { summary = try await enrollOperation(secret, trimmedSubdomain, trimmedName, url) persistSettings(trimmedURL, trimmedSubdomain) password = "" // never linger after success didSucceed = true refresh() } catch { password = "" // never linger after failure either errorMessage = Self.copy(for: error) } } /// Map a login/enroll/keychain error to enrollment-UX copy (never swallowed, /// never containing the password/token). private static func copy(for error: any Error) -> String { switch error { case ControlPlaneLoginError.emptyPassword: return EnrollmentCopy.errEmptyPassword case ControlPlaneLoginError.http(let status, _) where status == 401: return EnrollmentCopy.errBadCredential case ControlPlaneLoginError.http: return EnrollmentCopy.errLoginFailed case ControlPlaneLoginError.malformedResponse: return EnrollmentCopy.errServer case DeviceEnrollmentError.http(let status, _) where status == 403: return EnrollmentCopy.errSubdomainNotOwned case DeviceEnrollmentError.http(let status, _) where status == 429: return EnrollmentCopy.errRateLimited case DeviceEnrollmentError.http(let status, _) where status == 400: return EnrollmentCopy.errRejected case DeviceEnrollmentError.http: return EnrollmentCopy.errEnrollFailed case DeviceEnrollmentError.malformedResponse: return EnrollmentCopy.errServer case SecureEnclaveKeyError.secureEnclaveUnavailable: return EnrollmentCopy.errNoSecureEnclave case is SecureEnclaveKeyError: return EnrollmentCopy.errKeygen case ClientIdentityStoreError.keychain, ClientIdentityStoreError.corruptStoredBlob: return EnrollmentCopy.errKeychain case DeviceEnrollmentWiringError.missingControlPlaneURL, DeviceEnrollmentWiringError.invalidControlPlaneURL: return EnrollmentCopy.errInvalidURL default: return EnrollmentCopy.errUnknown } } } private extension String { var trimmed: String { trimmingCharacters(in: .whitespacesAndNewlines) } } /// User-facing copy (Chinese, matching the rest of the app). enum EnrollmentCopy { static let defaultControlPlaneURL = "https://cp.terminal.yaojia.wang" 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 enrollHeader = "注册本设备" static let rotateHeader = "重新注册" static let controlPlaneURL = "控制面地址" static let subdomain = "子域名 (你拥有的隧道名)" static let deviceName = "设备名称" static let password = "操作口令" static let enrollAction = "注册本设备" static let rotateAction = "重新注册" static let success = "已注册,证书已保存到本设备安全区。" static let footer = "首次登录一次即可:本设备在安全区 (Secure Enclave) 生成不可导出的私钥,向控制面申请证书并自动保存;之后连接隧道主机时自动出示,证书到期前会自动续期,无需再手动导入 .p12。" static let errInvalidURL = "控制面地址无效,请输入 https:// 开头的完整地址。" static let errMissingFields = "请填写子域名、设备名称和操作口令。" static let errEmptyPassword = "请输入操作口令。" static let errBadCredential = "操作口令错误,请重试。" static let errLoginFailed = "登录失败,请稍后重试。" static let errSubdomainNotOwned = "该账号未拥有此子域名,无法签发证书。" static let errRateLimited = "请求过于频繁,请稍后再试。" static let errRejected = "请求被拒绝:子域名或证书请求无效。" static let errEnrollFailed = "注册失败,请稍后重试。" static let errNoSecureEnclave = "本设备不支持安全区 (Secure Enclave)(模拟器?),无法生成硬件密钥。" static let errKeygen = "生成硬件密钥失败,请重试。" static let errKeychain = "保存到钥匙串失败,请重试。" static let errServer = "服务器返回异常,请稍后重试。" static let errUnknown = "注册失败,请重试。" }