Wire the SecureEnclave enroll library into a real flow (login->bearer->CSR->
/device/enroll->keychain identity), presented on the existing mTLS path; add a
rotation scheduler. Atomic keychain replace (add-before-delete); renew body is
{csr}-only; renewal-failing surfaced in the UI. ClientTLS 48 tests pass.
276 lines
12 KiB
Swift
276 lines
12 KiB
Swift
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 = "注册失败,请重试。"
|
|
}
|