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:
275
ios/App/WebTerm/Screens/EnrollmentScreen.swift
Normal file
275
ios/App/WebTerm/Screens/EnrollmentScreen.swift
Normal 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 = "注册失败,请重试。"
|
||||||
|
}
|
||||||
@@ -29,6 +29,11 @@ struct SessionListScreen: View {
|
|||||||
/// settings-like surface and is present in every empty state, so this is the
|
/// settings-like surface and is present in every empty state, so this is the
|
||||||
/// nav idiom that makes the orphaned screen reachable.
|
/// nav idiom that makes the orphaned screen reachable.
|
||||||
var onDeviceCert: () -> Void = {}
|
var onDeviceCert: () -> Void = {}
|
||||||
|
/// B3 · "自动获取证书" entry — presents `EnrollmentScreen` (the zero-`.p12`
|
||||||
|
/// enrollment flow: login → Secure-Enclave key + CSR → device cert). Same
|
||||||
|
/// host-menu surface as `onDeviceCert`; that screen imports a `.p12`, this
|
||||||
|
/// one obtains a hardware-bound cert with no file at all.
|
||||||
|
var onEnroll: () -> Void = {}
|
||||||
/// T-iOS-28 (additive slot) · shared thumbnail pipeline: one cache + one
|
/// T-iOS-28 (additive slot) · shared thumbnail pipeline: one cache + one
|
||||||
/// render-concurrency gate across ALL rows (scrolling must never spawn
|
/// render-concurrency gate across ALL rows (scrolling must never spawn
|
||||||
/// unbounded offscreen terminals). `@State` keeps it stable across body
|
/// unbounded offscreen terminals). `@State` keeps it stable across body
|
||||||
@@ -219,6 +224,12 @@ struct SessionListScreen: View {
|
|||||||
} label: {
|
} label: {
|
||||||
Label(ScreenCopy.addHost, systemImage: "plus")
|
Label(ScreenCopy.addHost, systemImage: "plus")
|
||||||
}
|
}
|
||||||
|
Button {
|
||||||
|
onEnroll()
|
||||||
|
} label: {
|
||||||
|
Label(ScreenCopy.enroll, systemImage: "checkmark.shield")
|
||||||
|
}
|
||||||
|
.accessibilityIdentifier("sessions.enroll")
|
||||||
Button {
|
Button {
|
||||||
onDeviceCert()
|
onDeviceCert()
|
||||||
} label: {
|
} label: {
|
||||||
@@ -355,6 +366,7 @@ private enum ScreenCopy {
|
|||||||
static let kill = "结束"
|
static let kill = "结束"
|
||||||
static let addHost = "配对新主机"
|
static let addHost = "配对新主机"
|
||||||
static let deviceCert = "设备证书"
|
static let deviceCert = "设备证书"
|
||||||
|
static let enroll = "自动获取证书"
|
||||||
static let hostMenuFallback = "主机"
|
static let hostMenuFallback = "主机"
|
||||||
static let notPairedTitle = "还没有配对的主机"
|
static let notPairedTitle = "还没有配对的主机"
|
||||||
static let notPairedHint = "先配对你电脑上的 web-terminal(扫码或手输地址),会话会出现在这里。"
|
static let notPairedHint = "先配对你电脑上的 web-terminal(扫码或手输地址),会话会出现在这里。"
|
||||||
|
|||||||
@@ -54,6 +54,12 @@ struct AdaptiveRootView: View {
|
|||||||
.sheet(isPresented: $coordinator.isDeviceCertPresented) {
|
.sheet(isPresented: $coordinator.isDeviceCertPresented) {
|
||||||
deviceCertSheet
|
deviceCertSheet
|
||||||
}
|
}
|
||||||
|
.sheet(
|
||||||
|
isPresented: $coordinator.isEnrollmentPresented,
|
||||||
|
onDismiss: { coordinator.enrollmentDismissed() }
|
||||||
|
) {
|
||||||
|
enrollmentSheet
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Layout branch (the SOLE size-class consumer)
|
// MARK: - Layout branch (the SOLE size-class consumer)
|
||||||
@@ -126,4 +132,21 @@ struct AdaptiveRootView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Device enrollment sheet (B3, zero-.p12 auto-cert)
|
||||||
|
|
||||||
|
/// 自带 NavigationStack + 右上「完成」。VM 由 coordinator 每次进入构建
|
||||||
|
/// (`presentEnrollment`);关闭后 `enrollmentDismissed` 丢弃 VM 并跑一次轮换。
|
||||||
|
@ViewBuilder private var enrollmentSheet: some View {
|
||||||
|
if let viewModel = coordinator.enrollmentViewModel {
|
||||||
|
NavigationStack {
|
||||||
|
EnrollmentScreen(model: viewModel)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
|
Button(RootCopy.done) { coordinator.isEnrollmentPresented = false }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import ClientTLS
|
||||||
import Foundation
|
import Foundation
|
||||||
import HostRegistry
|
import HostRegistry
|
||||||
import Observation
|
import Observation
|
||||||
@@ -36,6 +37,20 @@ final class AppCoordinator {
|
|||||||
/// its own `ClientCertViewModel` over the keychain store; dismissal needs no
|
/// its own `ClientCertViewModel` over the keychain store; dismissal needs no
|
||||||
/// refresh because the transports resolve the identity lazily per connection.
|
/// refresh because the transports resolve the identity lazily per connection.
|
||||||
var isDeviceCertPresented = false
|
var isDeviceCertPresented = false
|
||||||
|
/// B3 · "自动获取证书" sheet — the zero-`.p12` enrollment flow
|
||||||
|
/// (`EnrollmentScreen`): one login → Secure-Enclave key + CSR → device cert,
|
||||||
|
/// presented automatically on the existing mTLS path. VM built per entry.
|
||||||
|
var isEnrollmentPresented = false
|
||||||
|
private(set) var enrollmentViewModel: EnrollmentViewModel?
|
||||||
|
/// B3 · Re-entrancy guard so overlapping foregrounds never launch two silent
|
||||||
|
/// renews at once (a shared PTY-style single-flight for the rotation pass).
|
||||||
|
@ObservationIgnored private var isRotating = false
|
||||||
|
/// B3 (HIGH observability fix) · Persistent, OBSERVABLE flag: the last silent
|
||||||
|
/// rotation pass ended in `.failed` (keychain read fault or renew error). Set
|
||||||
|
/// alongside the os.Logger line so a silently-failing renewal surfaces to the
|
||||||
|
/// UI (a warning banner) instead of vanishing into the log. Cleared by the
|
||||||
|
/// next non-failed pass — a recovered renewal drops the warning automatically.
|
||||||
|
private(set) var isCertificateRenewalFailing = false
|
||||||
|
|
||||||
let sessionList: SessionListViewModel
|
let sessionList: SessionListViewModel
|
||||||
@ObservationIgnored let environment: AppEnvironment
|
@ObservationIgnored let environment: AppEnvironment
|
||||||
@@ -69,6 +84,7 @@ final class AppCoordinator {
|
|||||||
if route == .pairing {
|
if route == .pairing {
|
||||||
rootPairingViewModel = makePairingViewModel()
|
rootPairingViewModel = makePairingViewModel()
|
||||||
}
|
}
|
||||||
|
runCertificateRotationIfDue() // B3 · renew a due device cert on cold launch
|
||||||
await deepLink.markReady() // flush a cold-launch deep link (T-iOS-22)
|
await deepLink.markReady() // flush a cold-launch deep link (T-iOS-22)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,6 +136,50 @@ final class AppCoordinator {
|
|||||||
isDeviceCertPresented = true
|
isDeviceCertPresented = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Device enrollment (B3, zero-.p12 auto-cert)
|
||||||
|
|
||||||
|
/// Toolbar host-menu 入口:呈现「自动获取证书」注册 sheet。VM 每次进入重建。
|
||||||
|
func presentEnrollment() {
|
||||||
|
enrollmentViewModel = makeEnrollmentViewModel()
|
||||||
|
isEnrollmentPresented = true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sheet 消失:丢弃 VM,并立刻跑一次轮换检查(刚注册完的证书据此登记续期)。
|
||||||
|
func enrollmentDismissed() {
|
||||||
|
enrollmentViewModel = nil
|
||||||
|
runCertificateRotationIfDue()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 生产 VM:登录 → 安全区生成密钥+CSR → `/device/enroll` → 存钥匙串,全部经
|
||||||
|
/// `DeviceEnrollmentFlow`(复用 ClientTLS 库,本层零加密/钥匙串逻辑)。
|
||||||
|
private func makeEnrollmentViewModel() -> EnrollmentViewModel {
|
||||||
|
let http = environment.http
|
||||||
|
return EnrollmentViewModel(
|
||||||
|
enrollOperation: { password, subdomain, deviceName, url in
|
||||||
|
try await makeDeviceEnrollmentFlow(controlPlaneURL: url, http: http)
|
||||||
|
.run(password: password, subdomain: subdomain, deviceName: deviceName)
|
||||||
|
},
|
||||||
|
loadSummary: { (try? KeychainClientIdentityStore().loadSummary()) ?? nil }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// B3 · Silent rotation: check the installed leaf's timing and, if the renew
|
||||||
|
/// window has opened, renew over mTLS against the SAME Secure-Enclave key.
|
||||||
|
/// Fire-and-forget on launch + every foreground; single-flight via
|
||||||
|
/// `isRotating`. A `.notEnrolled` / `.notDue` pass is cheap and common.
|
||||||
|
func runCertificateRotationIfDue() {
|
||||||
|
guard !isRotating else { return }
|
||||||
|
isRotating = true
|
||||||
|
let scheduler = makeCertificateRotationScheduler(http: environment.http)
|
||||||
|
Task { [weak self] in
|
||||||
|
let outcome = await scheduler.runIfDue()
|
||||||
|
self?.isRotating = false
|
||||||
|
// Surface a silently-failing renewal as observable state (not only a
|
||||||
|
// log line); a later successful/not-due pass clears it.
|
||||||
|
self?.isCertificateRenewalFailing = (outcome == .failed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// "在此仓库开新会话":关 sheet → fresh spawn(`attach(null, cwd)`)+
|
/// "在此仓库开新会话":关 sheet → fresh spawn(`attach(null, cwd)`)+
|
||||||
/// attach 后注入 `claude\r`(帧序由 engine 的 attach-first 队列保证)。
|
/// attach 后注入 `claude\r`(帧序由 engine 的 attach-first 队列保证)。
|
||||||
func openProject(_ request: ProjectOpenRequest) {
|
func openProject(_ request: ProjectOpenRequest) {
|
||||||
@@ -290,6 +350,7 @@ final class AppCoordinator {
|
|||||||
terminalController?.suspend()
|
terminalController?.suspend()
|
||||||
case .active:
|
case .active:
|
||||||
terminalController?.resumeIfNeeded()
|
terminalController?.resumeIfNeeded()
|
||||||
|
runCertificateRotationIfDue() // B3 · check for a due renewal each foreground
|
||||||
case .inactive:
|
case .inactive:
|
||||||
break // transient; shade covers it at the view layer
|
break // transient; shade covers it at the view layer
|
||||||
@unknown default:
|
@unknown default:
|
||||||
|
|||||||
90
ios/App/WebTerm/Wiring/DeviceEnrollmentWiring.swift
Normal file
90
ios/App/WebTerm/Wiring/DeviceEnrollmentWiring.swift
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import ClientTLS
|
||||||
|
import Foundation
|
||||||
|
import WireProtocol
|
||||||
|
|
||||||
|
/// B3 · Production wiring that connects the ClientTLS enrollment library to the
|
||||||
|
/// app's real transports. Kept in the assembly layer (not the leaf package) so
|
||||||
|
/// `ClientTLS` stays dependency-free and unit-testable; here we only adapt types
|
||||||
|
/// and read persisted config — no crypto, no keychain of our own.
|
||||||
|
|
||||||
|
/// Adapts the app's `HTTPTransport` (WireProtocol) to `ClientTLS`'s
|
||||||
|
/// `EnrollmentTransport`. The two protocols have an identical `send` shape; this
|
||||||
|
/// one-line bridge lets the SAME `URLSessionHTTPTransport` — which already
|
||||||
|
/// presents the device identity on a client-cert challenge (mTLS) — serve login,
|
||||||
|
/// enroll AND the silent renew, so renew authenticates with the current cert.
|
||||||
|
struct EnrollmentTransportAdapter: EnrollmentTransport {
|
||||||
|
private let http: any HTTPTransport
|
||||||
|
|
||||||
|
init(http: any HTTPTransport) {
|
||||||
|
self.http = http
|
||||||
|
}
|
||||||
|
|
||||||
|
func send(_ request: URLRequest) async throws -> (Data, HTTPURLResponse) {
|
||||||
|
try await http.send(request)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// B3 · Non-secret enrollment config persisted for the silent rotation scheduler
|
||||||
|
/// (the control-plane URL to renew against) and to prefill the re-enroll form.
|
||||||
|
/// Namespaced statics over `UserDefaults.standard` — nothing secret is stored
|
||||||
|
/// here (the bearer is never persisted; the private key stays in the Secure
|
||||||
|
/// Enclave; the leaf/chain live in the keychain via `KeychainClientIdentityStore`).
|
||||||
|
enum EnrollmentSettings {
|
||||||
|
private static let controlPlaneURLKey = "webterm.enroll.controlPlaneURL"
|
||||||
|
private static let subdomainKey = "webterm.enroll.subdomain"
|
||||||
|
|
||||||
|
static var controlPlaneURL: String? {
|
||||||
|
get { UserDefaults.standard.string(forKey: controlPlaneURLKey) }
|
||||||
|
set { UserDefaults.standard.set(newValue, forKey: controlPlaneURLKey) }
|
||||||
|
}
|
||||||
|
|
||||||
|
static var subdomain: String? {
|
||||||
|
get { UserDefaults.standard.string(forKey: subdomainKey) }
|
||||||
|
set { UserDefaults.standard.set(newValue, forKey: subdomainKey) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum DeviceEnrollmentWiringError: Error, Equatable, Sendable {
|
||||||
|
/// The control-plane URL is missing/invalid — cannot build a renew client.
|
||||||
|
case missingControlPlaneURL
|
||||||
|
/// The typed control-plane URL is not a valid absolute URL.
|
||||||
|
case invalidControlPlaneURL
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build the enrollment flow (login → SE keygen+CSR → enroll → store) against the
|
||||||
|
/// given control-plane URL, reusing the app's mTLS-capable transport.
|
||||||
|
func makeDeviceEnrollmentFlow(
|
||||||
|
controlPlaneURL: URL,
|
||||||
|
http: any HTTPTransport,
|
||||||
|
store: KeychainClientIdentityStore = KeychainClientIdentityStore()
|
||||||
|
) -> DeviceEnrollmentFlow {
|
||||||
|
DeviceEnrollmentFlow(
|
||||||
|
baseURL: controlPlaneURL,
|
||||||
|
transport: EnrollmentTransportAdapter(http: http),
|
||||||
|
store: store
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build the silent rotation scheduler. `renewalState` reads the installed leaf's
|
||||||
|
/// timing; `performRenew` re-CSRs from the SAME Secure-Enclave key and POSTs
|
||||||
|
/// `/device/:id/renew` over mTLS (nil bearer — the current cert authenticates).
|
||||||
|
/// The control-plane URL is read fresh from `EnrollmentSettings` at renew time.
|
||||||
|
func makeCertificateRotationScheduler(
|
||||||
|
http: any HTTPTransport,
|
||||||
|
store: KeychainClientIdentityStore = KeychainClientIdentityStore()
|
||||||
|
) -> CertificateRotationScheduler {
|
||||||
|
let transport = EnrollmentTransportAdapter(http: http)
|
||||||
|
return CertificateRotationScheduler(
|
||||||
|
renewalState: { try store.renewalState() },
|
||||||
|
performRenew: {
|
||||||
|
guard let urlString = EnrollmentSettings.controlPlaneURL,
|
||||||
|
let url = URL(string: urlString) else {
|
||||||
|
throw DeviceEnrollmentWiringError.missingControlPlaneURL
|
||||||
|
}
|
||||||
|
// nil bearer: /device/:id/renew authenticates by the presented
|
||||||
|
// client cert (mTLS), which the adapter's transport supplies.
|
||||||
|
let client = DeviceEnrollmentClient(baseURL: url, bearerToken: nil, transport: transport)
|
||||||
|
return try await store.renew(using: client)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -75,9 +75,13 @@ struct StackRootView: View {
|
|||||||
viewModel: coordinator.sessionList,
|
viewModel: coordinator.sessionList,
|
||||||
onOpen: { coordinator.open($0) },
|
onOpen: { coordinator.open($0) },
|
||||||
onAddHost: { coordinator.presentAddHost() },
|
onAddHost: { coordinator.presentAddHost() },
|
||||||
onDeviceCert: { coordinator.presentDeviceCert() }
|
onDeviceCert: { coordinator.presentDeviceCert() },
|
||||||
|
onEnroll: { coordinator.presentEnrollment() }
|
||||||
)
|
)
|
||||||
.safeAreaInset(edge: .bottom) { continueLastBanner }
|
.safeAreaInset(edge: .bottom) { continueLastBanner }
|
||||||
|
// B3 (HIGH) · A silently-failing device-cert renewal is surfaced here
|
||||||
|
// (top inset), so it is observable instead of buried in os.Logger.
|
||||||
|
.safeAreaInset(edge: .top) { certRenewalWarningBanner }
|
||||||
// 横幅出现/消失走 DS 动效(reduceMotion 时塌成瞬切,无位移)。
|
// 横幅出现/消失走 DS 动效(reduceMotion 时塌成瞬切,无位移)。
|
||||||
.animation(
|
.animation(
|
||||||
DS.Motion.gated(DS.Motion.base, reduceMotion: reduceMotion),
|
DS.Motion.gated(DS.Motion.base, reduceMotion: reduceMotion),
|
||||||
@@ -98,6 +102,15 @@ struct StackRootView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Device-cert renewal warning (B3 HIGH observability fix)
|
||||||
|
|
||||||
|
@ViewBuilder private var certRenewalWarningBanner: some View {
|
||||||
|
if coordinator.isCertificateRenewalFailing {
|
||||||
|
CertRenewalWarningBanner()
|
||||||
|
.transition(.move(edge: .top).combined(with: .opacity))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Terminal push
|
// MARK: - Terminal push
|
||||||
|
|
||||||
private var terminalBinding: Binding<Bool> {
|
private var terminalBinding: Binding<Bool> {
|
||||||
@@ -155,6 +168,32 @@ struct ContinueLastBanner: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Device-cert renewal warning (B3 HIGH observability fix)
|
||||||
|
|
||||||
|
/// A quiet, non-blocking warning that the silent device-certificate renewal is
|
||||||
|
/// failing — surfaced from `AppCoordinator.isCertificateRenewalFailing` so a
|
||||||
|
/// failing renewal is observable in the UI instead of only in os.Logger. Amber
|
||||||
|
/// (the `waiting`/needs-me status color) + an SF Symbol so meaning is never
|
||||||
|
/// carried by color alone. Non-interactive: the existing cert stays valid until
|
||||||
|
/// expiry, and the next foreground retries automatically.
|
||||||
|
struct CertRenewalWarningBanner: View {
|
||||||
|
var body: some View {
|
||||||
|
Label(RootCopy.certRenewalFailing, systemImage: "exclamationmark.shield")
|
||||||
|
.font(DS.Typography.caption)
|
||||||
|
.foregroundStyle(DS.Palette.statusWaiting)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.padding(.horizontal, DS.Space.lg16)
|
||||||
|
.padding(.vertical, DS.Space.sm8)
|
||||||
|
.background(.regularMaterial)
|
||||||
|
.overlay(alignment: .bottom) {
|
||||||
|
Rectangle()
|
||||||
|
.fill(DS.Palette.hairline)
|
||||||
|
.frame(height: DS.Stroke.hairline)
|
||||||
|
}
|
||||||
|
.accessibilityIdentifier("root.certRenewalWarning")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Projects toolbar item (shared stack + split, DRY)
|
// MARK: - Projects toolbar item (shared stack + split, DRY)
|
||||||
|
|
||||||
/// 「项目」leading 工具栏入口 —— stack 与 split 共用(同 disabled 条件、同
|
/// 「项目」leading 工具栏入口 —— stack 与 split 共用(同 disabled 条件、同
|
||||||
@@ -180,4 +219,6 @@ enum RootCopy {
|
|||||||
static let continueLast = "继续上次会话"
|
static let continueLast = "继续上次会话"
|
||||||
static let projects = "项目"
|
static let projects = "项目"
|
||||||
static let done = "完成"
|
static let done = "完成"
|
||||||
|
/// B3 (HIGH) · Shown when the silent device-cert renewal keeps failing.
|
||||||
|
static let certRenewalFailing = "设备证书自动续期失败,将在下次前台重试"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,7 +40,8 @@ struct SplitRootView: View {
|
|||||||
coordinator.selectSidebarItem(sidebarItem(for: request))
|
coordinator.selectSidebarItem(sidebarItem(for: request))
|
||||||
},
|
},
|
||||||
onAddHost: { coordinator.presentAddHost() },
|
onAddHost: { coordinator.presentAddHost() },
|
||||||
onDeviceCert: { coordinator.presentDeviceCert() }
|
onDeviceCert: { coordinator.presentDeviceCert() },
|
||||||
|
onEnroll: { coordinator.presentEnrollment() }
|
||||||
)
|
)
|
||||||
.safeAreaInset(edge: .bottom) { continueLastBanner }
|
.safeAreaInset(edge: .bottom) { continueLastBanner }
|
||||||
.animation(
|
.animation(
|
||||||
|
|||||||
142
ios/App/WebTermTests/EnrollmentViewModelTests.swift
Normal file
142
ios/App/WebTermTests/EnrollmentViewModelTests.swift
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
import ClientTLS
|
||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
@testable import WebTerm
|
||||||
|
|
||||||
|
/// B3 · EnrollmentViewModel — the zero-`.p12` enrollment flow's state machine.
|
||||||
|
/// The enroll operation is injected as a closure (the flow logic itself is
|
||||||
|
/// covered headlessly in ClientTLS's DeviceEnrollmentFlowTests); these cover the
|
||||||
|
/// VM's boundary validation, success bookkeeping, and error→copy mapping —
|
||||||
|
/// including that the password never lingers after an attempt.
|
||||||
|
@MainActor
|
||||||
|
@Suite("EnrollmentViewModel")
|
||||||
|
struct EnrollmentViewModelTests {
|
||||||
|
/// Records enroll invocations + replays a scripted result/error.
|
||||||
|
private actor EnrollScript {
|
||||||
|
private(set) var calls: [(password: String, subdomain: String, deviceName: String, url: URL)] = []
|
||||||
|
private let result: Result<ClientCertificateSummary?, any Error>
|
||||||
|
|
||||||
|
init(result: Result<ClientCertificateSummary?, any Error>) {
|
||||||
|
self.result = result
|
||||||
|
}
|
||||||
|
|
||||||
|
func run(_ p: String, _ s: String, _ d: String, _ u: URL) throws -> ClientCertificateSummary? {
|
||||||
|
calls.append((p, s, d, u))
|
||||||
|
return try result.get()
|
||||||
|
}
|
||||||
|
|
||||||
|
var callCount: Int { calls.count }
|
||||||
|
var lastPassword: String? { calls.last?.password }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Records persisted settings so a test can assert them without real defaults.
|
||||||
|
private final class PersistSpy: @unchecked Sendable {
|
||||||
|
private let lock = NSLock()
|
||||||
|
private(set) var url: String?
|
||||||
|
private(set) var subdomain: String?
|
||||||
|
func persist(_ url: String, _ subdomain: String) {
|
||||||
|
lock.withLock { self.url = url; self.subdomain = subdomain }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeVM(
|
||||||
|
script: EnrollScript,
|
||||||
|
persist: PersistSpy = PersistSpy(),
|
||||||
|
url: String = "https://cp.terminal.yaojia.wang",
|
||||||
|
subdomain: String = "alice",
|
||||||
|
summary: ClientCertificateSummary? = nil
|
||||||
|
) -> EnrollmentViewModel {
|
||||||
|
EnrollmentViewModel(
|
||||||
|
controlPlaneURLText: url,
|
||||||
|
subdomain: subdomain,
|
||||||
|
deviceName: "Test iPhone",
|
||||||
|
enrollOperation: { p, s, d, u in try await script.run(p, s, d, u) },
|
||||||
|
loadSummary: { summary },
|
||||||
|
persistSettings: { persist.persist($0, $1) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("a successful enroll sets the summary, flags success, clears the password, persists settings")
|
||||||
|
func successPath() async {
|
||||||
|
let sum = ClientCertificateSummary(
|
||||||
|
subjectCommonName: "Test iPhone", issuerCommonName: "webterm-device-ca", notAfter: nil
|
||||||
|
)
|
||||||
|
let script = EnrollScript(result: .success(sum))
|
||||||
|
let persist = PersistSpy()
|
||||||
|
let vm = makeVM(script: script, persist: persist, summary: sum)
|
||||||
|
vm.password = "operator-secret"
|
||||||
|
|
||||||
|
await vm.enroll()
|
||||||
|
|
||||||
|
#expect(await script.callCount == 1)
|
||||||
|
#expect(vm.didSucceed == true)
|
||||||
|
#expect(vm.errorMessage == nil)
|
||||||
|
#expect(vm.password == "") // never lingers
|
||||||
|
#expect(vm.summary?.subjectCommonName == "Test iPhone")
|
||||||
|
#expect(persist.url == "https://cp.terminal.yaojia.wang")
|
||||||
|
#expect(persist.subdomain == "alice")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("a non-https control-plane URL is rejected before any network")
|
||||||
|
func rejectsInsecureURL() async {
|
||||||
|
let script = EnrollScript(result: .success(nil))
|
||||||
|
let vm = makeVM(script: script, url: "http://cp.terminal.yaojia.wang")
|
||||||
|
vm.password = "pw"
|
||||||
|
|
||||||
|
await vm.enroll()
|
||||||
|
|
||||||
|
#expect(await script.callCount == 0) // zero network
|
||||||
|
#expect(vm.errorMessage == EnrollmentCopy.errInvalidURL)
|
||||||
|
#expect(vm.didSucceed == false)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("a 403 subdomain-not-owned maps to actionable copy; password still cleared")
|
||||||
|
func mapsSubdomainNotOwned() async {
|
||||||
|
let script = EnrollScript(
|
||||||
|
result: .failure(DeviceEnrollmentError.http(status: 403, code: "rejected"))
|
||||||
|
)
|
||||||
|
let vm = makeVM(script: script)
|
||||||
|
vm.password = "operator-secret"
|
||||||
|
|
||||||
|
await vm.enroll()
|
||||||
|
|
||||||
|
#expect(vm.errorMessage == EnrollmentCopy.errSubdomainNotOwned)
|
||||||
|
#expect(vm.didSucceed == false)
|
||||||
|
#expect(vm.password == "") // cleared even on failure
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("a 401 login maps to the bad-credential copy")
|
||||||
|
func mapsBadCredential() async {
|
||||||
|
let script = EnrollScript(
|
||||||
|
result: .failure(ControlPlaneLoginError.http(status: 401, code: "login rejected"))
|
||||||
|
)
|
||||||
|
let vm = makeVM(script: script)
|
||||||
|
vm.password = "wrong"
|
||||||
|
|
||||||
|
await vm.enroll()
|
||||||
|
#expect(vm.errorMessage == EnrollmentCopy.errBadCredential)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Secure Enclave unavailable (simulator) maps to a clear message")
|
||||||
|
func mapsNoSecureEnclave() async {
|
||||||
|
let script = EnrollScript(
|
||||||
|
result: .failure(SecureEnclaveKeyError.secureEnclaveUnavailable("no entitlement"))
|
||||||
|
)
|
||||||
|
let vm = makeVM(script: script)
|
||||||
|
vm.password = "operator-secret"
|
||||||
|
|
||||||
|
await vm.enroll()
|
||||||
|
#expect(vm.errorMessage == EnrollmentCopy.errNoSecureEnclave)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("canEnroll requires every field including a non-empty password")
|
||||||
|
func canEnrollGating() {
|
||||||
|
let script = EnrollScript(result: .success(nil))
|
||||||
|
let vm = makeVM(script: script)
|
||||||
|
#expect(vm.canEnroll == false) // password empty
|
||||||
|
vm.password = "pw"
|
||||||
|
#expect(vm.canEnroll == true)
|
||||||
|
vm.subdomain = " "
|
||||||
|
#expect(vm.canEnroll == false) // whitespace-only subdomain
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import Foundation
|
||||||
|
import os
|
||||||
|
|
||||||
|
/// B3 · Rotation timing of an installed enrolled (Secure-Enclave) identity. The
|
||||||
|
/// scheduler reads this to decide whether to pre-empt expiry. The private key
|
||||||
|
/// itself never leaves the Secure Enclave, so only advisory timing surfaces.
|
||||||
|
public struct DeviceRenewalState: Equatable, Sendable {
|
||||||
|
/// The enrolled device id — drives `POST /device/:id/renew`.
|
||||||
|
public let deviceId: String
|
||||||
|
/// Leaf `notAfter` (hard expiry — the TLS stack rejects past it).
|
||||||
|
public let notAfter: Date?
|
||||||
|
/// When to renew from the same hardware key (~2/3 of the lifetime).
|
||||||
|
public let renewAfter: Date?
|
||||||
|
|
||||||
|
public init(deviceId: String, notAfter: Date?, renewAfter: Date?) {
|
||||||
|
self.deviceId = deviceId
|
||||||
|
self.notAfter = notAfter
|
||||||
|
self.renewAfter = renewAfter
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Is the leaf due for renewal as of `now`? A missing `renewAfter` NEVER
|
||||||
|
/// triggers (fail-safe — the TLS stack is the real gate; the scheduler only
|
||||||
|
/// pre-empts expiry). Mirrors `EnrollmentResult.isRenewalDue`.
|
||||||
|
public func isRenewalDue(asOf now: Date) -> Bool {
|
||||||
|
guard let renewAfter else { return false }
|
||||||
|
return now >= renewAfter
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// B3 · The silent rotation scheduler: on a trigger (app foreground / launch) it
|
||||||
|
/// checks the installed identity's rotation timing and, if the renew window has
|
||||||
|
/// opened, drives an mTLS renew against the SAME Secure-Enclave key. This is the
|
||||||
|
/// "one bootstrap tap, then never again" half of zero-touch — after the first
|
||||||
|
/// login+enroll, the cert renews itself with no human action.
|
||||||
|
///
|
||||||
|
/// Deliberately pure and transport-free: `renewalState` and `performRenew` are
|
||||||
|
/// injected closures, so the whole decision table is unit-testable with fakes
|
||||||
|
/// (no keychain, no network). Production wires `renewalState` to the keychain
|
||||||
|
/// store and `performRenew` to an mTLS `DeviceEnrollmentClient` renew (nil
|
||||||
|
/// bearer — the current cert authenticates).
|
||||||
|
///
|
||||||
|
/// Failure posture: a keychain read fault or a renew error is surfaced as
|
||||||
|
/// `.failed` and logged (never a secret), NEVER a crash — the existing cert
|
||||||
|
/// stays valid until `notAfter`, so a transient renew failure is recoverable on
|
||||||
|
/// the next trigger.
|
||||||
|
public struct CertificateRotationScheduler: Sendable {
|
||||||
|
/// Current rotation timing; `nil` = nothing enrolled to renew.
|
||||||
|
private let renewalState: @Sendable () throws -> DeviceRenewalState?
|
||||||
|
/// The mTLS renew operation (re-CSR from the SE key → replace the leaf).
|
||||||
|
private let performRenew: @Sendable () async throws -> ClientCertificateSummary?
|
||||||
|
private let now: @Sendable () -> Date
|
||||||
|
|
||||||
|
public init(
|
||||||
|
renewalState: @escaping @Sendable () throws -> DeviceRenewalState?,
|
||||||
|
performRenew: @escaping @Sendable () async throws -> ClientCertificateSummary?,
|
||||||
|
now: @escaping @Sendable () -> Date = { Date() }
|
||||||
|
) {
|
||||||
|
self.renewalState = renewalState
|
||||||
|
self.performRenew = performRenew
|
||||||
|
self.now = now
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The outcome of a single scheduler pass — total over the decision table.
|
||||||
|
public enum Outcome: Equatable, Sendable {
|
||||||
|
/// No enrolled identity (fresh install / legacy `.p12`): nothing to do.
|
||||||
|
case notEnrolled
|
||||||
|
/// Enrolled but the renew window has not opened yet.
|
||||||
|
case notDue(renewAfter: Date?)
|
||||||
|
/// The renew succeeded; the new leaf is installed for the next handshake.
|
||||||
|
case renewed
|
||||||
|
/// A read/renew error occurred; logged, cert still valid until expiry.
|
||||||
|
case failed
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run one pass: read timing → decide → renew if due. Idempotent and safe to
|
||||||
|
/// call on every foreground; `.notDue` is the common (cheap) case.
|
||||||
|
public func runIfDue() async -> Outcome {
|
||||||
|
let state: DeviceRenewalState?
|
||||||
|
do {
|
||||||
|
state = try renewalState()
|
||||||
|
} catch {
|
||||||
|
RotationLog.log.error(
|
||||||
|
"rotation: renewal-state read failed: \(String(describing: error), privacy: .public)"
|
||||||
|
)
|
||||||
|
return .failed
|
||||||
|
}
|
||||||
|
guard let state else { return .notEnrolled }
|
||||||
|
guard state.isRenewalDue(asOf: now()) else {
|
||||||
|
return .notDue(renewAfter: state.renewAfter)
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
_ = try await performRenew()
|
||||||
|
RotationLog.log.info("rotation: device certificate renewed silently")
|
||||||
|
return .renewed
|
||||||
|
} catch {
|
||||||
|
// No secrets in the log — only the error shape (never token/cert bytes).
|
||||||
|
RotationLog.log.error(
|
||||||
|
"rotation: silent renew failed: \(String(describing: error), privacy: .public)"
|
||||||
|
)
|
||||||
|
return .failed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum RotationLog {
|
||||||
|
static let log = Logger(subsystem: "com.yaojia.webterm", category: "cert-rotation")
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// B3 · The phone-track bootstrap: exchanges the operator's one-time credential
|
||||||
|
/// for a short-lived `device:enroll` bearer at the control-plane.
|
||||||
|
///
|
||||||
|
/// PINNED login contract (all pieces MUST agree — iOS / Android / server B1):
|
||||||
|
///
|
||||||
|
/// `POST /auth/login { "password": "<operator secret>" }`
|
||||||
|
/// → 201 { "enrollToken": "<jwt/capability>", "accountId": "<uuid>", "expiresIn": <seconds> }
|
||||||
|
///
|
||||||
|
/// The returned `enrollToken` is then presented as `Authorization: Bearer …` to
|
||||||
|
/// `DeviceEnrollmentClient.enroll`. This client is deliberately logic-free about
|
||||||
|
/// TLS and reuses the SAME `EnrollmentTransport` seam as `DeviceEnrollmentClient`,
|
||||||
|
/// so production injects `URLSessionHTTPTransport` and tests inject a stub.
|
||||||
|
///
|
||||||
|
/// Security posture:
|
||||||
|
/// - The password is validated at the boundary (non-empty) and sent ONCE; it is
|
||||||
|
/// never persisted, never logged, and no other field rides along in the body.
|
||||||
|
/// - The `enrollToken` is a secret bearer: it is never logged here either. Only
|
||||||
|
/// the transport (which sees it in a header) and the caller hold it, briefly.
|
||||||
|
public struct ControlPlaneLoginClient: Sendable {
|
||||||
|
private let baseURL: URL
|
||||||
|
private let transport: any EnrollmentTransport
|
||||||
|
|
||||||
|
public init(baseURL: URL, transport: any EnrollmentTransport) {
|
||||||
|
self.baseURL = baseURL
|
||||||
|
self.transport = transport
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Exchange the operator credential for a `device:enroll` bearer.
|
||||||
|
///
|
||||||
|
/// - Throws: `ControlPlaneLoginError.emptyPassword` (before any network),
|
||||||
|
/// `.http(status:code:)` on a non-201 (401 = bad/missing credential),
|
||||||
|
/// `.malformedResponse` on a 201 body that does not decode.
|
||||||
|
public func login(password: String) async throws -> LoginResult {
|
||||||
|
// Boundary validation — fail fast, never send an empty credential.
|
||||||
|
guard !password.isEmpty else { throw ControlPlaneLoginError.emptyPassword }
|
||||||
|
|
||||||
|
guard let url = URL(string: "/auth/login", relativeTo: baseURL) else {
|
||||||
|
throw ControlPlaneLoginError.malformedResponse
|
||||||
|
}
|
||||||
|
var request = URLRequest(url: url)
|
||||||
|
request.httpMethod = "POST"
|
||||||
|
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||||
|
// Password ONLY — no accountId / device fields leak from the client.
|
||||||
|
request.httpBody = try JSONSerialization.data(withJSONObject: ["password": password])
|
||||||
|
|
||||||
|
let (data, response) = try await transport.send(request)
|
||||||
|
guard response.statusCode == 201 else {
|
||||||
|
throw ControlPlaneLoginError.http(status: response.statusCode, code: errorCode(in: data))
|
||||||
|
}
|
||||||
|
guard let dto = try? JSONDecoder().decode(LoginResponseDTO.self, from: data) else {
|
||||||
|
throw ControlPlaneLoginError.malformedResponse
|
||||||
|
}
|
||||||
|
return dto.toResult()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func errorCode(in data: Data) -> String? {
|
||||||
|
(try? JSONDecoder().decode(LoginErrorDTO.self, from: data))?.error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Result & errors
|
||||||
|
|
||||||
|
/// The login exchange result. The `enrollToken` is a short-lived secret bearer;
|
||||||
|
/// hold it only as long as needed to drive `DeviceEnrollmentClient.enroll`.
|
||||||
|
public struct LoginResult: Equatable, Sendable {
|
||||||
|
/// The `device:enroll` capability bearer to present to `/device/enroll`.
|
||||||
|
public let enrollToken: String
|
||||||
|
/// The authenticated account (the token `sub`); the device enrolls under a
|
||||||
|
/// subdomain this account owns (server enforces ownership, deny-by-default).
|
||||||
|
public let accountId: String
|
||||||
|
/// Token lifetime in seconds (minutes-scale). Advisory: the enrollment must
|
||||||
|
/// complete within this window or the bearer is re-minted via another login.
|
||||||
|
public let expiresIn: Int
|
||||||
|
|
||||||
|
public init(enrollToken: String, accountId: String, expiresIn: Int) {
|
||||||
|
self.enrollToken = enrollToken
|
||||||
|
self.accountId = accountId
|
||||||
|
self.expiresIn = expiresIn
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum ControlPlaneLoginError: Error, Equatable, Sendable {
|
||||||
|
/// The password was empty — rejected before any network call.
|
||||||
|
case emptyPassword
|
||||||
|
/// Non-success HTTP status with the server's uniform `{ error }` code, if any
|
||||||
|
/// (401 = bad/missing credential, 429 = rate_limited).
|
||||||
|
case http(status: Int, code: String?)
|
||||||
|
/// A 2xx body that did not decode to the expected shape.
|
||||||
|
case malformedResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Wire DTOs
|
||||||
|
|
||||||
|
private struct LoginResponseDTO: Decodable {
|
||||||
|
let enrollToken: String
|
||||||
|
let accountId: String
|
||||||
|
let expiresIn: Int
|
||||||
|
|
||||||
|
func toResult() -> LoginResult {
|
||||||
|
LoginResult(enrollToken: enrollToken, accountId: accountId, expiresIn: expiresIn)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct LoginErrorDTO: Decodable {
|
||||||
|
let error: String
|
||||||
|
}
|
||||||
@@ -16,11 +16,15 @@ import Foundation
|
|||||||
/// server's `decodeCsrWire` accepts directly.
|
/// server's `decodeCsrWire` accepts directly.
|
||||||
public struct DeviceEnrollmentClient: Sendable {
|
public struct DeviceEnrollmentClient: Sendable {
|
||||||
private let baseURL: URL
|
private let baseURL: URL
|
||||||
/// The one-time account `device:enroll` bearer obtained at login.
|
/// The one-time account `device:enroll` bearer obtained at login. `nil` on
|
||||||
private let bearerToken: String
|
/// the renew path, which authenticates by the CURRENT device client cert
|
||||||
|
/// (mTLS) — the silent rotation scheduler has no fresh bearer weeks later, so
|
||||||
|
/// it constructs the client with `nil` and relies on the transport presenting
|
||||||
|
/// the installed identity. When `nil`, no `Authorization` header is sent.
|
||||||
|
private let bearerToken: String?
|
||||||
private let transport: any EnrollmentTransport
|
private let transport: any EnrollmentTransport
|
||||||
|
|
||||||
public init(baseURL: URL, bearerToken: String, transport: any EnrollmentTransport) {
|
public init(baseURL: URL, bearerToken: String? = nil, transport: any EnrollmentTransport) {
|
||||||
self.baseURL = baseURL
|
self.baseURL = baseURL
|
||||||
self.bearerToken = bearerToken
|
self.bearerToken = bearerToken
|
||||||
self.transport = transport
|
self.transport = transport
|
||||||
@@ -42,10 +46,12 @@ public struct DeviceEnrollmentClient: Sendable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Renew against the SAME hardware key (A6 seam): a fresh CSR to
|
/// Renew against the SAME hardware key (A6 seam): a fresh CSR to
|
||||||
/// `/device/:id/renew`. Server support lands in A6; the client shape is here
|
/// `/device/:id/renew`. The endpoint authenticates by the presented mTLS cert
|
||||||
/// so the rotation scheduler has an endpoint to drive.
|
/// and its schema is strict — the body is `{ csr }` ONLY (NOT the enroll body).
|
||||||
|
/// A stray `keyAlg` (or any non-`csr` field) trips server validation and 400s
|
||||||
|
/// every silent renewal, so it is deliberately absent here.
|
||||||
public func renew(deviceId: String, csrDER: Data) async throws -> EnrollmentResult {
|
public func renew(deviceId: String, csrDER: Data) async throws -> EnrollmentResult {
|
||||||
let body = ["csr": csrDER.base64EncodedString(), "keyAlg": "ec-p256"]
|
let body = ["csr": csrDER.base64EncodedString()]
|
||||||
let path = "/device/\(deviceId)/renew"
|
let path = "/device/\(deviceId)/renew"
|
||||||
let request = try makeJSONRequest(path: path, jsonObject: body)
|
let request = try makeJSONRequest(path: path, jsonObject: body)
|
||||||
return try await sendExpectingLeaf(request)
|
return try await sendExpectingLeaf(request)
|
||||||
@@ -73,7 +79,11 @@ public struct DeviceEnrollmentClient: Sendable {
|
|||||||
}
|
}
|
||||||
var request = URLRequest(url: url)
|
var request = URLRequest(url: url)
|
||||||
request.httpMethod = "POST"
|
request.httpMethod = "POST"
|
||||||
|
// Bearer only when present (enroll). The renew path passes nil and
|
||||||
|
// authenticates via the presented client certificate (mTLS).
|
||||||
|
if let bearerToken {
|
||||||
request.setValue("Bearer \(bearerToken)", forHTTPHeaderField: "Authorization")
|
request.setValue("Bearer \(bearerToken)", forHTTPHeaderField: "Authorization")
|
||||||
|
}
|
||||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||||
request.httpBody = try JSONSerialization.data(withJSONObject: jsonObject)
|
request.httpBody = try JSONSerialization.data(withJSONObject: jsonObject)
|
||||||
return request
|
return request
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// B3 · The phone-track enrollment FLOW — the composition that turns the pinned
|
||||||
|
/// bootstrap contract into an installed, hardware-bound device identity:
|
||||||
|
///
|
||||||
|
/// 1. `POST /auth/login { password }` → short-lived `device:enroll` bearer.
|
||||||
|
/// 2. Build a `DeviceEnrollmentClient` authenticated with THAT bearer.
|
||||||
|
/// 3. `install` = generate a NON-EXPORTABLE Secure-Enclave key, self-sign a
|
||||||
|
/// PKCS#10 CSR, `POST /device/enroll`, and store the returned leaf against
|
||||||
|
/// the SE key — so it presents automatically on the existing mTLS path.
|
||||||
|
///
|
||||||
|
/// It reuses the enrollment library wholesale (`ControlPlaneLoginClient`,
|
||||||
|
/// `DeviceEnrollmentClient`, `KeychainClientIdentityStore.enroll`) and adds NO
|
||||||
|
/// crypto/keychain of its own — it is pure orchestration, so it is unit-testable
|
||||||
|
/// with a stub transport + a fake installer (no network, no keychain).
|
||||||
|
///
|
||||||
|
/// The `install` step is injected (rather than a hard dependency on the keychain
|
||||||
|
/// store) so the composition is testable off-device; production supplies the
|
||||||
|
/// `KeychainClientIdentityStore` convenience initializer below.
|
||||||
|
public struct DeviceEnrollmentFlow: Sendable {
|
||||||
|
/// The install step: given the bearer-authenticated client + the requested
|
||||||
|
/// subdomain/name, generate the SE key + CSR, enroll, and persist the leaf.
|
||||||
|
/// Returns the installed cert summary (nil only if the leaf can't be read).
|
||||||
|
public typealias Install = @Sendable (
|
||||||
|
_ client: DeviceEnrollmentClient, _ subdomain: String, _ deviceName: String
|
||||||
|
) async throws -> ClientCertificateSummary?
|
||||||
|
|
||||||
|
private let baseURL: URL
|
||||||
|
private let transport: any EnrollmentTransport
|
||||||
|
private let install: Install
|
||||||
|
|
||||||
|
public init(baseURL: URL, transport: any EnrollmentTransport, install: @escaping Install) {
|
||||||
|
self.baseURL = baseURL
|
||||||
|
self.transport = transport
|
||||||
|
self.install = install
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Production convenience: wire `install` to the keychain store's
|
||||||
|
/// Secure-Enclave enrollment (the `.p12`-free path). The SE key is generated
|
||||||
|
/// inside `store.enroll` and never leaves hardware.
|
||||||
|
public init(
|
||||||
|
baseURL: URL, transport: any EnrollmentTransport, store: KeychainClientIdentityStore
|
||||||
|
) {
|
||||||
|
self.init(baseURL: baseURL, transport: transport) { client, subdomain, deviceName in
|
||||||
|
try await store.enroll(using: client, subdomain: subdomain, deviceName: deviceName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run the full bootstrap. Errors propagate unchanged so the UI can map them:
|
||||||
|
/// `ControlPlaneLoginError` (login step), `DeviceEnrollmentError` (enroll
|
||||||
|
/// step, e.g. 403 subdomain-not-owned), or a keychain/CSR error from install.
|
||||||
|
/// A login failure short-circuits — the enroll step never runs.
|
||||||
|
public func run(
|
||||||
|
password: String, subdomain: String, deviceName: String
|
||||||
|
) async throws -> ClientCertificateSummary? {
|
||||||
|
let login = try await ControlPlaneLoginClient(baseURL: baseURL, transport: transport)
|
||||||
|
.login(password: password)
|
||||||
|
let client = DeviceEnrollmentClient(
|
||||||
|
baseURL: baseURL, bearerToken: login.enrollToken, transport: transport
|
||||||
|
)
|
||||||
|
return try await install(client, subdomain, deviceName)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -172,6 +172,20 @@ public struct KeychainClientIdentityStore: ClientIdentityStore {
|
|||||||
return try loadSummary()
|
return try loadSummary()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// B3 · Rotation timing of the installed enrolled (Secure-Enclave) identity,
|
||||||
|
/// read for the silent rotation scheduler. `nil` when there is no enrolled
|
||||||
|
/// identity (a fresh install, or a legacy `.p12`-only device — neither of
|
||||||
|
/// which the scheduler can silently renew). The private key never leaves the
|
||||||
|
/// Secure Enclave, so only the advisory timing + deviceId surface here.
|
||||||
|
public func renewalState() throws -> DeviceRenewalState? {
|
||||||
|
guard let record = try readEnrollment() else { return nil }
|
||||||
|
return DeviceRenewalState(
|
||||||
|
deviceId: record.deviceId,
|
||||||
|
notAfter: record.notAfter,
|
||||||
|
renewAfter: record.renewAfter
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/// The Secure-Enclave-backed identity: the assembled `SecIdentity` (leaf bound
|
/// The Secure-Enclave-backed identity: the assembled `SecIdentity` (leaf bound
|
||||||
/// to the SE key) plus the stored issuer chain. `nil` when not enrolled.
|
/// to the SE key) plus the stored issuer chain. `nil` when not enrolled.
|
||||||
func loadDeviceIdentity() throws -> ClientIdentity? {
|
func loadDeviceIdentity() throws -> ClientIdentity? {
|
||||||
@@ -205,31 +219,62 @@ public struct KeychainClientIdentityStore: ClientIdentityStore {
|
|||||||
return SecItemCopyMatching(query as CFDictionary, nil) == errSecSuccess
|
return SecItemCopyMatching(query as CFDictionary, nil) == errSecSuccess
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Persist the enrolled leaf (delete-then-add so rotation replaces the prior
|
/// Persist the enrolled leaf plus the enrollment record. The leaf binds to the
|
||||||
/// one) plus the enrollment record. The leaf binds to the permanent SE key,
|
/// permanent SE key, letting the keychain form the identity on load.
|
||||||
/// letting the keychain form the identity on load.
|
///
|
||||||
|
/// ATOMIC replace (reached automatically by the rotation scheduler): ADD the
|
||||||
|
/// new leaf BEFORE deleting the prior one — the inverse of delete-then-add,
|
||||||
|
/// whose window has zero leaves installed. An interrupted write therefore
|
||||||
|
/// never strands the device without an identity. The prior leaf is deleted by
|
||||||
|
/// its captured persistent ref (both share `leafLabel`, so a label-only delete
|
||||||
|
/// would also remove the just-added replacement).
|
||||||
private func storeEnrolledLeaf(_ result: EnrollmentResult, deviceName: String) throws {
|
private func storeEnrolledLeaf(_ result: EnrollmentResult, deviceName: String) throws {
|
||||||
guard let certificate = SecCertificateCreateWithData(nil, result.certificate as CFData)
|
guard let certificate = SecCertificateCreateWithData(nil, result.certificate as CFData)
|
||||||
else {
|
else {
|
||||||
throw ClientIdentityStoreError.corruptStoredBlob // not a valid DER cert
|
throw ClientIdentityStoreError.corruptStoredBlob // not a valid DER cert
|
||||||
}
|
}
|
||||||
let deleteLeaf: [String: Any] = [
|
// Capture the prior leaf BEFORE the add so it can be deleted by identity
|
||||||
kSecClass as String: kSecClassCertificate,
|
// afterwards. `nil` on first enroll (nothing to replace).
|
||||||
kSecAttrLabel as String: leafLabel,
|
let priorLeafRef = existingLeafPersistentRef()
|
||||||
]
|
var addedDistinctLeaf = false
|
||||||
let deleteStatus = SecItemDelete(deleteLeaf as CFDictionary)
|
try replaceKeychainItemAtomically(
|
||||||
guard deleteStatus == errSecSuccess || deleteStatus == errSecItemNotFound else {
|
addNew: {
|
||||||
throw ClientIdentityStoreError.keychain(deleteStatus)
|
|
||||||
}
|
|
||||||
let addLeaf: [String: Any] = [
|
let addLeaf: [String: Any] = [
|
||||||
kSecClass as String: kSecClassCertificate,
|
kSecClass as String: kSecClassCertificate,
|
||||||
kSecValueRef as String: certificate,
|
kSecValueRef as String: certificate,
|
||||||
kSecAttrLabel as String: leafLabel,
|
kSecAttrLabel as String: leafLabel,
|
||||||
]
|
]
|
||||||
let addStatus = SecItemAdd(addLeaf as CFDictionary, nil)
|
let addStatus = SecItemAdd(addLeaf as CFDictionary, nil)
|
||||||
guard addStatus == errSecSuccess else {
|
switch addStatus {
|
||||||
|
case errSecSuccess:
|
||||||
|
addedDistinctLeaf = true // a new, distinct leaf now coexists with the old
|
||||||
|
case errSecDuplicateItem:
|
||||||
|
// Byte-identical re-store (same serial): the leaf is already
|
||||||
|
// installed. Keep it and DO NOT delete the prior ref — it is
|
||||||
|
// the very item we just tried to add.
|
||||||
|
addedDistinctLeaf = false
|
||||||
|
default:
|
||||||
throw ClientIdentityStoreError.keychain(addStatus)
|
throw ClientIdentityStoreError.keychain(addStatus)
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
deleteOld: {
|
||||||
|
// Only sweep the prior leaf once a distinct replacement is in place.
|
||||||
|
guard addedDistinctLeaf, let priorLeafRef else { return }
|
||||||
|
let deleteStatus = SecItemDelete(
|
||||||
|
[kSecValuePersistentRef as String: priorLeafRef] as CFDictionary
|
||||||
|
)
|
||||||
|
guard deleteStatus == errSecSuccess || deleteStatus == errSecItemNotFound else {
|
||||||
|
throw ClientIdentityStoreError.keychain(deleteStatus)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
reportStaleDeleteFailure: { error in
|
||||||
|
// Non-fatal: the new leaf is installed (renewal succeeded). A stale
|
||||||
|
// leftover binds to the same SE key and is swept on the next write.
|
||||||
|
ClientTLSLog.identity.error(
|
||||||
|
"storeEnrolledLeaf: stale leaf delete failed (new leaf installed): \(String(describing: error), privacy: .public)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
try writeEnrollment(
|
try writeEnrollment(
|
||||||
StoredEnrollment(
|
StoredEnrollment(
|
||||||
deviceId: result.deviceId,
|
deviceId: result.deviceId,
|
||||||
@@ -241,6 +286,22 @@ public struct KeychainClientIdentityStore: ClientIdentityStore {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Persistent reference of the currently-installed enrolled leaf (by label),
|
||||||
|
/// captured before an atomic replace so the prior copy can be deleted by
|
||||||
|
/// identity. `nil` when none is installed or the lookup fails (first enroll).
|
||||||
|
private func existingLeafPersistentRef() -> Data? {
|
||||||
|
let query: [String: Any] = [
|
||||||
|
kSecClass as String: kSecClassCertificate,
|
||||||
|
kSecAttrLabel as String: leafLabel,
|
||||||
|
kSecReturnPersistentRef as String: true,
|
||||||
|
kSecMatchLimit as String: kSecMatchLimitOne,
|
||||||
|
]
|
||||||
|
var result: CFTypeRef?
|
||||||
|
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
||||||
|
guard status == errSecSuccess, let data = result as? Data else { return nil }
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
/// Delete the SE key, the enrolled leaf, and the enrollment record. Idempotent.
|
/// Delete the SE key, the enrolled leaf, and the enrollment record. Idempotent.
|
||||||
private func removeEnrolled() throws {
|
private func removeEnrolled() throws {
|
||||||
let deleteLeaf: [String: Any] = [
|
let deleteLeaf: [String: Any] = [
|
||||||
@@ -273,9 +334,17 @@ public struct KeychainClientIdentityStore: ClientIdentityStore {
|
|||||||
} catch {
|
} catch {
|
||||||
throw ClientIdentityStoreError.corruptStoredBlob
|
throw ClientIdentityStoreError.corruptStoredBlob
|
||||||
}
|
}
|
||||||
let deleteStatus = SecItemDelete(enrollmentQuery() as CFDictionary)
|
// ATOMIC upsert (reached automatically by the rotation scheduler): update
|
||||||
guard deleteStatus == errSecSuccess || deleteStatus == errSecItemNotFound else {
|
// the DATA in place — the (service, account) primary key and protection
|
||||||
throw ClientIdentityStoreError.keychain(deleteStatus)
|
// class are unchanged on renewal, so no delete-then-add window can strand
|
||||||
|
// the device without its enrollment record. Fall back to add on first write.
|
||||||
|
let updateStatus = SecItemUpdate(
|
||||||
|
enrollmentQuery() as CFDictionary,
|
||||||
|
[kSecValueData as String: data] as CFDictionary
|
||||||
|
)
|
||||||
|
if updateStatus == errSecSuccess { return }
|
||||||
|
guard updateStatus == errSecItemNotFound else {
|
||||||
|
throw ClientIdentityStoreError.keychain(updateStatus)
|
||||||
}
|
}
|
||||||
var attributes = enrollmentQuery()
|
var attributes = enrollmentQuery()
|
||||||
attributes[kSecValueData as String] = data
|
attributes[kSecValueData as String] = data
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Replace a keychain-backed item so a crash/failure mid-write can NEVER leave
|
||||||
|
/// zero installed copies — which, for the device identity, would lock the device
|
||||||
|
/// out of its own mTLS cert until a manual re-enroll. This is the inverse of a
|
||||||
|
/// delete-then-add sequence, whose window between the two calls has zero items:
|
||||||
|
///
|
||||||
|
/// 1. `addNew` FIRST. Once it succeeds the replacement is installed, so at
|
||||||
|
/// least one valid item exists from here on. If `addNew` throws, the prior
|
||||||
|
/// item is left untouched (the device keeps a working identity) and the
|
||||||
|
/// error propagates.
|
||||||
|
/// 2. `deleteOld` SECOND, removing the prior copy. A failure here is NON-fatal:
|
||||||
|
/// the new item is already installed, so the write is a success. The stale
|
||||||
|
/// leftover is reported via `reportStaleDeleteFailure` (never a lockout) and
|
||||||
|
/// swept on the next write — throwing here would strand a working new cert.
|
||||||
|
///
|
||||||
|
/// Kept pure (closures, no `Security` types) so the ordering invariant is unit-
|
||||||
|
/// testable with fakes; the keychain plumbing lives at the call sites.
|
||||||
|
func replaceKeychainItemAtomically(
|
||||||
|
addNew: () throws -> Void,
|
||||||
|
deleteOld: () throws -> Void,
|
||||||
|
reportStaleDeleteFailure: (Error) -> Void = { _ in }
|
||||||
|
) throws {
|
||||||
|
try addNew()
|
||||||
|
do {
|
||||||
|
try deleteOld()
|
||||||
|
} catch {
|
||||||
|
reportStaleDeleteFailure(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
@testable import ClientTLS
|
||||||
|
|
||||||
|
// B3 · The silent rotation scheduler decides — from the installed identity's
|
||||||
|
// rotation timing — whether to renew before expiry, then drives an injected
|
||||||
|
// mTLS renew. Pure and transport-free: fully unit-testable with fakes.
|
||||||
|
|
||||||
|
private let t0 = ISO8601DateFormatter().date(from: "2026-09-05T00:00:00Z")!
|
||||||
|
private let renewAfter = ISO8601DateFormatter().date(from: "2026-09-05T00:00:00Z")!
|
||||||
|
private let notAfter = ISO8601DateFormatter().date(from: "2026-10-06T00:00:00Z")!
|
||||||
|
private let before = ISO8601DateFormatter().date(from: "2026-09-04T00:00:00Z")!
|
||||||
|
private let after = ISO8601DateFormatter().date(from: "2026-09-06T00:00:00Z")!
|
||||||
|
|
||||||
|
private func state(renewAfter: Date? = renewAfter) -> DeviceRenewalState {
|
||||||
|
DeviceRenewalState(deviceId: "dev-1", notAfter: notAfter, renewAfter: renewAfter)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Records how many times the renew operation ran. `@unchecked Sendable`: guarded
|
||||||
|
/// by a lock.
|
||||||
|
private final class RenewSpy: @unchecked Sendable {
|
||||||
|
private let lock = NSLock()
|
||||||
|
private var _count = 0
|
||||||
|
var count: Int { lock.withLock { _count } }
|
||||||
|
func bump() { lock.withLock { _count += 1 } }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("no enrolled identity → notEnrolled, and renew never runs")
|
||||||
|
func schedulerNotEnrolled() async {
|
||||||
|
let spy = RenewSpy()
|
||||||
|
let scheduler = CertificateRotationScheduler(
|
||||||
|
renewalState: { nil },
|
||||||
|
performRenew: { spy.bump(); return nil },
|
||||||
|
now: { after }
|
||||||
|
)
|
||||||
|
let outcome = await scheduler.runIfDue()
|
||||||
|
#expect(outcome == .notEnrolled)
|
||||||
|
#expect(spy.count == 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("cert not yet due → notDue, and renew never runs")
|
||||||
|
func schedulerNotDue() async {
|
||||||
|
let spy = RenewSpy()
|
||||||
|
let scheduler = CertificateRotationScheduler(
|
||||||
|
renewalState: { state() },
|
||||||
|
performRenew: { spy.bump(); return nil },
|
||||||
|
now: { before }
|
||||||
|
)
|
||||||
|
let outcome = await scheduler.runIfDue()
|
||||||
|
#expect(outcome == .notDue(renewAfter: renewAfter))
|
||||||
|
#expect(spy.count == 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("past renewAfter → renews exactly once")
|
||||||
|
func schedulerRenews() async {
|
||||||
|
let spy = RenewSpy()
|
||||||
|
let scheduler = CertificateRotationScheduler(
|
||||||
|
renewalState: { state() },
|
||||||
|
performRenew: { spy.bump(); return nil },
|
||||||
|
now: { after }
|
||||||
|
)
|
||||||
|
let outcome = await scheduler.runIfDue()
|
||||||
|
#expect(outcome == .renewed)
|
||||||
|
#expect(spy.count == 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("due exactly at renewAfter (>= boundary) renews")
|
||||||
|
func schedulerRenewsAtBoundary() async {
|
||||||
|
let scheduler = CertificateRotationScheduler(
|
||||||
|
renewalState: { state() },
|
||||||
|
performRenew: { nil },
|
||||||
|
now: { renewAfter } // now == renewAfter
|
||||||
|
)
|
||||||
|
#expect(await scheduler.runIfDue() == .renewed)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("a missing renewAfter never triggers (fail-safe — TLS stack is the gate)")
|
||||||
|
func schedulerMissingRenewAfterNeverDue() async {
|
||||||
|
let scheduler = CertificateRotationScheduler(
|
||||||
|
renewalState: { state(renewAfter: nil) },
|
||||||
|
performRenew: { nil },
|
||||||
|
now: { after }
|
||||||
|
)
|
||||||
|
#expect(await scheduler.runIfDue() == .notDue(renewAfter: nil))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("a renew that throws is surfaced as .failed (never crashes, cert still valid)")
|
||||||
|
func schedulerRenewFailure() async {
|
||||||
|
struct Boom: Error {}
|
||||||
|
let scheduler = CertificateRotationScheduler(
|
||||||
|
renewalState: { state() },
|
||||||
|
performRenew: { throw Boom() },
|
||||||
|
now: { after }
|
||||||
|
)
|
||||||
|
#expect(await scheduler.runIfDue() == .failed)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("a keychain read fault is surfaced as .failed, not a crash")
|
||||||
|
func schedulerReadFailure() async {
|
||||||
|
struct Boom: Error {}
|
||||||
|
let scheduler = CertificateRotationScheduler(
|
||||||
|
renewalState: { throw Boom() },
|
||||||
|
performRenew: { nil },
|
||||||
|
now: { after }
|
||||||
|
)
|
||||||
|
#expect(await scheduler.runIfDue() == .failed)
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
@testable import ClientTLS
|
||||||
|
|
||||||
|
// B3 · ControlPlaneLoginClient request-building + response-mapping, driven by a
|
||||||
|
// stub transport (no network). Mirrors the PINNED login contract exactly:
|
||||||
|
//
|
||||||
|
// POST /auth/login { "password": "<operator secret>" }
|
||||||
|
// → 201 { "enrollToken": "<jwt/capability>", "accountId": "<uuid>", "expiresIn": <seconds> }
|
||||||
|
|
||||||
|
private let cpBaseURL = URL(string: "https://cp.terminal.yaojia.wang")!
|
||||||
|
|
||||||
|
/// Records the last request and replays a canned response. `@unchecked Sendable`:
|
||||||
|
/// the mutable capture is guarded by a lock.
|
||||||
|
private final class LoginStubTransport: EnrollmentTransport, @unchecked Sendable {
|
||||||
|
private let lock = NSLock()
|
||||||
|
private var _lastRequest: URLRequest?
|
||||||
|
private let status: Int
|
||||||
|
private let body: Data
|
||||||
|
|
||||||
|
init(status: Int, body: Data) {
|
||||||
|
self.status = status
|
||||||
|
self.body = body
|
||||||
|
}
|
||||||
|
|
||||||
|
var lastRequest: URLRequest? { lock.withLock { _lastRequest } }
|
||||||
|
|
||||||
|
func send(_ request: URLRequest) async throws -> (Data, HTTPURLResponse) {
|
||||||
|
lock.withLock { _lastRequest = request }
|
||||||
|
let response = HTTPURLResponse(
|
||||||
|
url: request.url!, statusCode: status, httpVersion: "HTTP/1.1", headerFields: nil
|
||||||
|
)!
|
||||||
|
return (body, response)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loginBody(
|
||||||
|
enrollToken: String = "v4.public.enroll-token",
|
||||||
|
accountId: String = "11111111-2222-3333-4444-555555555555",
|
||||||
|
expiresIn: Int = 600
|
||||||
|
) -> Data {
|
||||||
|
let json: [String: Any] = [
|
||||||
|
"enrollToken": enrollToken,
|
||||||
|
"accountId": accountId,
|
||||||
|
"expiresIn": expiresIn,
|
||||||
|
]
|
||||||
|
return try! JSONSerialization.data(withJSONObject: json)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("login builds a POST /auth/login carrying only the password (never bearer)")
|
||||||
|
func loginBuildsRequest() async throws {
|
||||||
|
// Arrange
|
||||||
|
let stub = LoginStubTransport(status: 201, body: loginBody())
|
||||||
|
let client = ControlPlaneLoginClient(baseURL: cpBaseURL, transport: stub)
|
||||||
|
|
||||||
|
// Act
|
||||||
|
_ = try await client.login(password: "operator-secret")
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
let request = try #require(stub.lastRequest)
|
||||||
|
#expect(request.httpMethod == "POST")
|
||||||
|
#expect(request.url?.absoluteString == "https://cp.terminal.yaojia.wang/auth/login")
|
||||||
|
#expect(request.value(forHTTPHeaderField: "Content-Type") == "application/json")
|
||||||
|
// Login is credential-based, NOT bearer-authed — no Authorization header.
|
||||||
|
#expect(request.value(forHTTPHeaderField: "Authorization") == nil)
|
||||||
|
|
||||||
|
let sent = try #require(request.httpBody)
|
||||||
|
let object = try #require(try JSONSerialization.jsonObject(with: sent) as? [String: Any])
|
||||||
|
#expect(object["password"] as? String == "operator-secret")
|
||||||
|
#expect(object.keys.count == 1) // password ONLY — no extra fields leak
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("login maps a 201 response into a typed LoginResult")
|
||||||
|
func loginMapsResponse() async throws {
|
||||||
|
// Arrange
|
||||||
|
let stub = LoginStubTransport(status: 201, body: loginBody(
|
||||||
|
enrollToken: "v4.public.abc", accountId: "acct-uuid", expiresIn: 900
|
||||||
|
))
|
||||||
|
let client = ControlPlaneLoginClient(baseURL: cpBaseURL, transport: stub)
|
||||||
|
|
||||||
|
// Act
|
||||||
|
let result = try await client.login(password: "pw")
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
#expect(result.enrollToken == "v4.public.abc")
|
||||||
|
#expect(result.accountId == "acct-uuid")
|
||||||
|
#expect(result.expiresIn == 900)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("an empty password fails fast without any network call")
|
||||||
|
func loginEmptyPasswordRejected() async throws {
|
||||||
|
// Arrange
|
||||||
|
let stub = LoginStubTransport(status: 201, body: loginBody())
|
||||||
|
let client = ControlPlaneLoginClient(baseURL: cpBaseURL, transport: stub)
|
||||||
|
|
||||||
|
// Act / Assert — boundary validation: reject before sending.
|
||||||
|
await #expect(throws: ControlPlaneLoginError.emptyPassword) {
|
||||||
|
_ = try await client.login(password: "")
|
||||||
|
}
|
||||||
|
#expect(stub.lastRequest == nil) // proves zero network happened
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("a 401 login response surfaces http with the server error code")
|
||||||
|
func loginRejectSurfacesStatus() async throws {
|
||||||
|
// The login stub seam denies-by-default → { error: "login rejected" }.
|
||||||
|
let body = try! JSONSerialization.data(withJSONObject: ["error": "login rejected"])
|
||||||
|
let stub = LoginStubTransport(status: 401, body: body)
|
||||||
|
let client = ControlPlaneLoginClient(baseURL: cpBaseURL, transport: stub)
|
||||||
|
|
||||||
|
await #expect(throws: ControlPlaneLoginError.http(status: 401, code: "login rejected")) {
|
||||||
|
_ = try await client.login(password: "wrong")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("a malformed 201 body throws malformedResponse")
|
||||||
|
func loginMalformedBody() async throws {
|
||||||
|
let stub = LoginStubTransport(status: 201, body: Data("not json".utf8))
|
||||||
|
let client = ControlPlaneLoginClient(baseURL: cpBaseURL, transport: stub)
|
||||||
|
|
||||||
|
await #expect(throws: ControlPlaneLoginError.malformedResponse) {
|
||||||
|
_ = try await client.login(password: "pw")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -169,3 +169,46 @@ func renewTargetsRenewEndpoint() async throws {
|
|||||||
#expect(stub.lastRequest?.url?.absoluteString
|
#expect(stub.lastRequest?.url?.absoluteString
|
||||||
== "https://cp.terminal.yaojia.wang/device/dev-9/renew")
|
== "https://cp.terminal.yaojia.wang/device/dev-9/renew")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test("renew body is {csr}-ONLY (server's strict schema rejects any extra field)")
|
||||||
|
func renewSendsCsrOnlyBody() async throws {
|
||||||
|
// The control-plane /device/:id/renew schema authenticates by the presented
|
||||||
|
// mTLS cert and accepts a body of exactly { csr } — a stray `keyAlg` (or any
|
||||||
|
// non-csr field) trips its strict validation and 400s every silent renewal.
|
||||||
|
let stub = StubTransport(status: 201, body: enrollBody())
|
||||||
|
let client = DeviceEnrollmentClient(baseURL: baseURL, transport: stub)
|
||||||
|
let csr = Data([0x0A, 0x0B, 0x0C])
|
||||||
|
|
||||||
|
_ = try await client.renew(deviceId: "dev-9", csrDER: csr)
|
||||||
|
|
||||||
|
let request = try #require(stub.lastRequest)
|
||||||
|
let sent = try #require(request.httpBody)
|
||||||
|
let object = try #require(try JSONSerialization.jsonObject(with: sent) as? [String: Any])
|
||||||
|
#expect(object["csr"] as? String == csr.base64EncodedString())
|
||||||
|
#expect(object["keyAlg"] == nil) // NOT sent — the enroll-only field must be dropped
|
||||||
|
#expect(object.keys.sorted() == ["csr"]) // exactly one field
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("renew with no bearer sends NO Authorization header (mTLS-authenticated)")
|
||||||
|
func renewMTLSOmitsBearer() async throws {
|
||||||
|
// /device/:id/renew authenticates by the CURRENT device client cert (mTLS),
|
||||||
|
// NOT a bearer — the silent rotation scheduler has no fresh bearer weeks
|
||||||
|
// later, so it constructs the client with a nil bearer and relies on the
|
||||||
|
// transport presenting the installed identity. The Authorization header
|
||||||
|
// must therefore be ABSENT (a stale/empty "Bearer " would be wrong).
|
||||||
|
let stub = StubTransport(status: 201, body: enrollBody())
|
||||||
|
let client = DeviceEnrollmentClient(baseURL: baseURL, transport: stub) // bearer defaults to nil
|
||||||
|
_ = try await client.renew(deviceId: "dev-9", csrDER: Data([0x02]))
|
||||||
|
let request = try #require(stub.lastRequest)
|
||||||
|
#expect(request.value(forHTTPHeaderField: "Authorization") == nil)
|
||||||
|
// Content-Type is still set — it's a JSON POST regardless of auth mechanism.
|
||||||
|
#expect(request.value(forHTTPHeaderField: "Content-Type") == "application/json")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("enroll still sets the bearer when one is supplied (unchanged contract)")
|
||||||
|
func enrollKeepsBearerWhenSupplied() async throws {
|
||||||
|
let stub = StubTransport(status: 201, body: enrollBody())
|
||||||
|
let client = DeviceEnrollmentClient(baseURL: baseURL, bearerToken: bearer, transport: stub)
|
||||||
|
_ = try await client.enroll(csrDER: Data([0x01]), subdomain: "a", deviceName: "d")
|
||||||
|
#expect(stub.lastRequest?.value(forHTTPHeaderField: "Authorization") == "Bearer \(bearer)")
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,136 @@
|
|||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
@testable import ClientTLS
|
||||||
|
|
||||||
|
// B3 · DeviceEnrollmentFlow composes the pinned bootstrap end-to-end:
|
||||||
|
// login(password) → bearer → enroll(CSR, subdomain) with that bearer → leaf.
|
||||||
|
// Driven by a path-routing stub transport (no network, no keychain): asserts the
|
||||||
|
// bearer minted at login is exactly what authenticates the enroll call.
|
||||||
|
|
||||||
|
private let flowBaseURL = URL(string: "https://cp.terminal.yaojia.wang")!
|
||||||
|
|
||||||
|
/// Routes by path: `/auth/login` → login body, `/device/enroll` → enroll body.
|
||||||
|
/// Records every request so the test can assert the enroll bearer. `@unchecked
|
||||||
|
/// Sendable`: mutable captures guarded by a lock.
|
||||||
|
private final class RoutingStubTransport: EnrollmentTransport, @unchecked Sendable {
|
||||||
|
private let lock = NSLock()
|
||||||
|
private var _requests: [URLRequest] = []
|
||||||
|
private let loginStatus: Int
|
||||||
|
private let loginBody: Data
|
||||||
|
|
||||||
|
init(loginStatus: Int = 201, loginBody: Data) {
|
||||||
|
self.loginStatus = loginStatus
|
||||||
|
self.loginBody = loginBody
|
||||||
|
}
|
||||||
|
|
||||||
|
var requests: [URLRequest] { lock.withLock { _requests } }
|
||||||
|
func request(forPathSuffix suffix: String) -> URLRequest? {
|
||||||
|
lock.withLock { _requests.first { $0.url?.path.hasSuffix(suffix) == true } }
|
||||||
|
}
|
||||||
|
|
||||||
|
func send(_ request: URLRequest) async throws -> (Data, HTTPURLResponse) {
|
||||||
|
lock.withLock { _requests.append(request) }
|
||||||
|
let path = request.url?.path ?? ""
|
||||||
|
let (status, body): (Int, Data)
|
||||||
|
if path.hasSuffix("/auth/login") {
|
||||||
|
(status, body) = (loginStatus, loginBody)
|
||||||
|
} else {
|
||||||
|
// /device/enroll → a minimal 201 leaf.
|
||||||
|
(status, body) = (201, enrollLeafBody())
|
||||||
|
}
|
||||||
|
let response = HTTPURLResponse(
|
||||||
|
url: request.url!, statusCode: status, httpVersion: "HTTP/1.1", headerFields: nil
|
||||||
|
)!
|
||||||
|
return (body, response)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loginResponseBody(enrollToken: String) -> Data {
|
||||||
|
try! JSONSerialization.data(withJSONObject: [
|
||||||
|
"enrollToken": enrollToken,
|
||||||
|
"accountId": "acct-123",
|
||||||
|
"expiresIn": 600,
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
private func enrollLeafBody() -> Data {
|
||||||
|
try! JSONSerialization.data(withJSONObject: [
|
||||||
|
"deviceId": "dev-1",
|
||||||
|
"cert": Data([0x30, 0x01]).base64EncodedString(),
|
||||||
|
"caChain": [Data([0x30, 0x02]).base64EncodedString()],
|
||||||
|
"notBefore": "2026-07-18T00:00:00Z",
|
||||||
|
"notAfter": "2026-10-16T00:00:00Z",
|
||||||
|
"renewAfter": "2026-09-15T00:00:00Z",
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A fake installer that invokes the real `client.enroll` (so the transport sees
|
||||||
|
/// the enroll request with the login-minted bearer), then returns a summary.
|
||||||
|
/// Records the subdomain/deviceName it was handed. `@unchecked Sendable`: lock.
|
||||||
|
private final class SpyInstaller: @unchecked Sendable {
|
||||||
|
private let lock = NSLock()
|
||||||
|
private(set) var subdomain: String?
|
||||||
|
private(set) var deviceName: String?
|
||||||
|
|
||||||
|
func install(
|
||||||
|
_ client: DeviceEnrollmentClient, _ subdomain: String, _ deviceName: String
|
||||||
|
) async throws -> ClientCertificateSummary? {
|
||||||
|
lock.withLock { self.subdomain = subdomain; self.deviceName = deviceName }
|
||||||
|
_ = try await client.enroll(csrDER: Data([0xAB]), subdomain: subdomain, deviceName: deviceName)
|
||||||
|
return ClientCertificateSummary(
|
||||||
|
subjectCommonName: deviceName, issuerCommonName: "webterm-device-ca", notAfter: nil
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("flow logs in then enrolls with the login-minted bearer (end to end)")
|
||||||
|
func flowEndToEnd() async throws {
|
||||||
|
// Arrange
|
||||||
|
let transport = RoutingStubTransport(loginBody: loginResponseBody(enrollToken: "TOKEN-XYZ"))
|
||||||
|
let installer = SpyInstaller()
|
||||||
|
let flow = DeviceEnrollmentFlow(
|
||||||
|
baseURL: flowBaseURL,
|
||||||
|
transport: transport,
|
||||||
|
install: installer.install
|
||||||
|
)
|
||||||
|
|
||||||
|
// Act
|
||||||
|
let summary = try await flow.run(password: "pw", subdomain: "alice", deviceName: "Alice iPhone")
|
||||||
|
|
||||||
|
// Assert — login happened, THEN enroll with the exact bearer from login.
|
||||||
|
#expect(transport.request(forPathSuffix: "/auth/login") != nil)
|
||||||
|
let enroll = try #require(transport.request(forPathSuffix: "/device/enroll"))
|
||||||
|
#expect(enroll.value(forHTTPHeaderField: "Authorization") == "Bearer TOKEN-XYZ")
|
||||||
|
#expect(installer.subdomain == "alice")
|
||||||
|
#expect(installer.deviceName == "Alice iPhone")
|
||||||
|
#expect(summary?.subjectCommonName == "Alice iPhone")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("a login failure short-circuits: enroll is never attempted")
|
||||||
|
func flowLoginFailureShortCircuits() async {
|
||||||
|
// 401 login → the flow must NOT reach /device/enroll.
|
||||||
|
let body = try! JSONSerialization.data(withJSONObject: ["error": "login rejected"])
|
||||||
|
let transport = RoutingStubTransport(loginStatus: 401, loginBody: body)
|
||||||
|
let installer = SpyInstaller()
|
||||||
|
let flow = DeviceEnrollmentFlow(
|
||||||
|
baseURL: flowBaseURL, transport: transport, install: installer.install
|
||||||
|
)
|
||||||
|
|
||||||
|
await #expect(throws: ControlPlaneLoginError.http(status: 401, code: "login rejected")) {
|
||||||
|
_ = try await flow.run(password: "wrong", subdomain: "alice", deviceName: "d")
|
||||||
|
}
|
||||||
|
#expect(transport.request(forPathSuffix: "/device/enroll") == nil)
|
||||||
|
#expect(installer.subdomain == nil) // installer never ran
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("an empty password is rejected before any network")
|
||||||
|
func flowEmptyPassword() async {
|
||||||
|
let transport = RoutingStubTransport(loginBody: loginResponseBody(enrollToken: "t"))
|
||||||
|
let flow = DeviceEnrollmentFlow(
|
||||||
|
baseURL: flowBaseURL, transport: transport, install: { _, _, _ in nil }
|
||||||
|
)
|
||||||
|
await #expect(throws: ControlPlaneLoginError.emptyPassword) {
|
||||||
|
_ = try await flow.run(password: "", subdomain: "alice", deviceName: "d")
|
||||||
|
}
|
||||||
|
#expect(transport.requests.isEmpty)
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
@testable import ClientTLS
|
||||||
|
|
||||||
|
// C-iOS · The keychain-replace helper backs the rotation scheduler's identity
|
||||||
|
// updates. Its one job: a crash/failure mid-write must NEVER leave zero installed
|
||||||
|
// items (which would lock the device out of its own mTLS identity until a manual
|
||||||
|
// re-enroll). It enforces ADD-new-BEFORE-DELETE-old ordering — the inverse of the
|
||||||
|
// delete-then-add sequence whose window has zero items installed.
|
||||||
|
|
||||||
|
/// A tiny in-memory stand-in for the keychain: the current set of installed item
|
||||||
|
/// ids plus the smallest count ever observed, so a test can assert the invariant
|
||||||
|
/// that the installed set is NEVER empty at any step of the replace.
|
||||||
|
private final class FakeKeychain {
|
||||||
|
private(set) var items: Set<String>
|
||||||
|
private(set) var log: [String] = []
|
||||||
|
private(set) var minCount: Int
|
||||||
|
init(seed: Set<String>) {
|
||||||
|
items = seed
|
||||||
|
minCount = seed.count
|
||||||
|
}
|
||||||
|
|
||||||
|
func add(_ id: String) {
|
||||||
|
items.insert(id)
|
||||||
|
log.append("add(\(id))")
|
||||||
|
minCount = min(minCount, items.count)
|
||||||
|
}
|
||||||
|
|
||||||
|
func delete(_ id: String) {
|
||||||
|
items.remove(id)
|
||||||
|
log.append("delete(\(id))")
|
||||||
|
minCount = min(minCount, items.count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("replace ADDS the new item before DELETING the old — never a zero-item window")
|
||||||
|
func replaceAddsBeforeDeletes() throws {
|
||||||
|
let keychain = FakeKeychain(seed: ["old"])
|
||||||
|
try replaceKeychainItemAtomically(
|
||||||
|
addNew: { keychain.add("new") },
|
||||||
|
deleteOld: { keychain.delete("old") }
|
||||||
|
)
|
||||||
|
#expect(keychain.items == ["new"])
|
||||||
|
#expect(keychain.log == ["add(new)", "delete(old)"]) // add strictly precedes delete
|
||||||
|
#expect(keychain.minCount >= 1) // at least one identity installed at every step
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("a failed add leaves the prior item intact and never runs the delete")
|
||||||
|
func replaceAddFailureKeepsOld() {
|
||||||
|
struct Boom: Error {}
|
||||||
|
let keychain = FakeKeychain(seed: ["old"])
|
||||||
|
#expect(throws: Boom.self) {
|
||||||
|
try replaceKeychainItemAtomically(
|
||||||
|
addNew: { throw Boom() },
|
||||||
|
deleteOld: { keychain.delete("old") }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
#expect(keychain.items == ["old"]) // old preserved — a working identity remains
|
||||||
|
#expect(keychain.log.contains("delete(old)") == false)
|
||||||
|
#expect(keychain.minCount >= 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("a failed delete-of-old is non-fatal — the new item stays installed and the fault is surfaced")
|
||||||
|
func replaceDeleteFailureKeepsNew() throws {
|
||||||
|
struct Boom: Error {}
|
||||||
|
let keychain = FakeKeychain(seed: ["old"])
|
||||||
|
var reported: Error?
|
||||||
|
try replaceKeychainItemAtomically(
|
||||||
|
addNew: { keychain.add("new") },
|
||||||
|
deleteOld: { throw Boom() },
|
||||||
|
reportStaleDeleteFailure: { reported = $0 }
|
||||||
|
)
|
||||||
|
#expect(keychain.items.contains("new")) // renewal succeeded despite the stale-delete fault
|
||||||
|
#expect(reported is Boom) // surfaced (not silently swallowed)
|
||||||
|
#expect(keychain.minCount >= 1)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user