feat(ios): device enrollment flow + silent cert rotation (B3)

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.
This commit is contained in:
Yaojia Wang
2026-07-18 13:32:05 +02:00
parent 9a5909f672
commit 07bcbf0c08
19 changed files with 1549 additions and 32 deletions

View File

@@ -0,0 +1,275 @@
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 = "注册失败,请重试。"
}