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:
@@ -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.
|
||||
public struct DeviceEnrollmentClient: Sendable {
|
||||
private let baseURL: URL
|
||||
/// The one-time account `device:enroll` bearer obtained at login.
|
||||
private let bearerToken: String
|
||||
/// The one-time account `device:enroll` bearer obtained at login. `nil` on
|
||||
/// 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
|
||||
|
||||
public init(baseURL: URL, bearerToken: String, transport: any EnrollmentTransport) {
|
||||
public init(baseURL: URL, bearerToken: String? = nil, transport: any EnrollmentTransport) {
|
||||
self.baseURL = baseURL
|
||||
self.bearerToken = bearerToken
|
||||
self.transport = transport
|
||||
@@ -42,10 +46,12 @@ public struct DeviceEnrollmentClient: Sendable {
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// so the rotation scheduler has an endpoint to drive.
|
||||
/// `/device/:id/renew`. The endpoint authenticates by the presented mTLS cert
|
||||
/// 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 {
|
||||
let body = ["csr": csrDER.base64EncodedString(), "keyAlg": "ec-p256"]
|
||||
let body = ["csr": csrDER.base64EncodedString()]
|
||||
let path = "/device/\(deviceId)/renew"
|
||||
let request = try makeJSONRequest(path: path, jsonObject: body)
|
||||
return try await sendExpectingLeaf(request)
|
||||
@@ -73,7 +79,11 @@ public struct DeviceEnrollmentClient: Sendable {
|
||||
}
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "POST"
|
||||
request.setValue("Bearer \(bearerToken)", forHTTPHeaderField: "Authorization")
|
||||
// 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("application/json", forHTTPHeaderField: "Content-Type")
|
||||
request.httpBody = try JSONSerialization.data(withJSONObject: jsonObject)
|
||||
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()
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// to the SE key) plus the stored issuer chain. `nil` when not enrolled.
|
||||
func loadDeviceIdentity() throws -> ClientIdentity? {
|
||||
@@ -205,31 +219,62 @@ public struct KeychainClientIdentityStore: ClientIdentityStore {
|
||||
return SecItemCopyMatching(query as CFDictionary, nil) == errSecSuccess
|
||||
}
|
||||
|
||||
/// Persist the enrolled leaf (delete-then-add so rotation replaces the prior
|
||||
/// one) plus the enrollment record. The leaf binds to the permanent SE key,
|
||||
/// letting the keychain form the identity on load.
|
||||
/// Persist the enrolled leaf plus the enrollment record. The leaf binds to the
|
||||
/// permanent SE key, 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 {
|
||||
guard let certificate = SecCertificateCreateWithData(nil, result.certificate as CFData)
|
||||
else {
|
||||
throw ClientIdentityStoreError.corruptStoredBlob // not a valid DER cert
|
||||
}
|
||||
let deleteLeaf: [String: Any] = [
|
||||
kSecClass as String: kSecClassCertificate,
|
||||
kSecAttrLabel as String: leafLabel,
|
||||
]
|
||||
let deleteStatus = SecItemDelete(deleteLeaf as CFDictionary)
|
||||
guard deleteStatus == errSecSuccess || deleteStatus == errSecItemNotFound else {
|
||||
throw ClientIdentityStoreError.keychain(deleteStatus)
|
||||
}
|
||||
let addLeaf: [String: Any] = [
|
||||
kSecClass as String: kSecClassCertificate,
|
||||
kSecValueRef as String: certificate,
|
||||
kSecAttrLabel as String: leafLabel,
|
||||
]
|
||||
let addStatus = SecItemAdd(addLeaf as CFDictionary, nil)
|
||||
guard addStatus == errSecSuccess else {
|
||||
throw ClientIdentityStoreError.keychain(addStatus)
|
||||
}
|
||||
// Capture the prior leaf BEFORE the add so it can be deleted by identity
|
||||
// afterwards. `nil` on first enroll (nothing to replace).
|
||||
let priorLeafRef = existingLeafPersistentRef()
|
||||
var addedDistinctLeaf = false
|
||||
try replaceKeychainItemAtomically(
|
||||
addNew: {
|
||||
let addLeaf: [String: Any] = [
|
||||
kSecClass as String: kSecClassCertificate,
|
||||
kSecValueRef as String: certificate,
|
||||
kSecAttrLabel as String: leafLabel,
|
||||
]
|
||||
let addStatus = SecItemAdd(addLeaf as CFDictionary, nil)
|
||||
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)
|
||||
}
|
||||
},
|
||||
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(
|
||||
StoredEnrollment(
|
||||
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.
|
||||
private func removeEnrolled() throws {
|
||||
let deleteLeaf: [String: Any] = [
|
||||
@@ -273,9 +334,17 @@ public struct KeychainClientIdentityStore: ClientIdentityStore {
|
||||
} catch {
|
||||
throw ClientIdentityStoreError.corruptStoredBlob
|
||||
}
|
||||
let deleteStatus = SecItemDelete(enrollmentQuery() as CFDictionary)
|
||||
guard deleteStatus == errSecSuccess || deleteStatus == errSecItemNotFound else {
|
||||
throw ClientIdentityStoreError.keychain(deleteStatus)
|
||||
// ATOMIC upsert (reached automatically by the rotation scheduler): update
|
||||
// the DATA in place — the (service, account) primary key and protection
|
||||
// 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()
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user