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.
77 lines
2.8 KiB
Swift
77 lines
2.8 KiB
Swift
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)
|
|
}
|