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,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