ClientTLS was the most security-sensitive package in the tree and the least covered, and it was not in the coverage gate at all (the gate's 4-package set predates it). 48 -> 84 tests against the real macOS keychain, serialized with a custom Testing trait after a @globalActor proved insufficient (actors yield at await, so cross-await critical sections got interleaved by other cases' cleanup). CI: the app/ipad/ios17 legs ran a bundle containing LiveServerSmokeTests, which spawns tsx, with no npm ci -- a hard failure, not a skip, on a bare checkout. Adds the missing iPad UI-test leg, and makes a missing iOS 17 runtime fail loudly instead of silently reporting green.
152 lines
6.7 KiB
Swift
152 lines
6.7 KiB
Swift
import Foundation
|
|
import Security
|
|
import Testing
|
|
@testable import ClientTLS
|
|
|
|
// B4 · The persistence half of enrollment: what `storeEnrolledLeaf` actually
|
|
// leaves in the keychain. Three cases, all reachable off-device because the
|
|
// server's answer is a REAL certificate here (`EnrolledLeafFixtures`):
|
|
//
|
|
// 1. first enroll → leaf installed + enrollment record ADDED
|
|
// 2. rotation (new leaf) → distinct leaf added, record UPDATED in place
|
|
// 3. retry (same leaf) → errSecDuplicateItem tolerated, record still correct
|
|
//
|
|
// Case 2/3 matter because the record write and the leaf write are separate
|
|
// keychain operations: a device whose record says "dev-B" while the installed
|
|
// leaf is "dev-A" cannot renew (the server checks the PoP against the enrolled
|
|
// key for THAT deviceId).
|
|
//
|
|
// These tests install certificates into the real keychain, so each one sweeps the
|
|
// fixture leaves before AND after itself — see `EnrolledLeafFixtures`.
|
|
|
|
private let controlPlaneURL = URL(string: "https://cp.terminal.yaojia.wang")!
|
|
|
|
/// Run `body` with the fixture leaves swept on both sides. Actor-isolated like
|
|
/// its callers so nothing crosses a concurrency boundary.
|
|
private func withCleanLeafKeychain(
|
|
_ body: (StoreKeychainKeys, KeychainClientIdentityStore) async throws -> Void
|
|
) async throws {
|
|
let keys = StoreKeychainKeys.unique()
|
|
EnrolledLeafFixtures.purgeFixtureLeaves()
|
|
defer {
|
|
EnrolledLeafFixtures.purgeFixtureLeaves()
|
|
KeychainProbe.purge(keys)
|
|
}
|
|
let store = KeychainClientIdentityStore(service: keys.service, account: keys.account)
|
|
try await body(keys, store)
|
|
}
|
|
|
|
private func enrollmentClient(
|
|
_ transport: RecordingEnrollmentTransport
|
|
) -> DeviceEnrollmentClient {
|
|
DeviceEnrollmentClient(
|
|
baseURL: controlPlaneURL, bearerToken: "enroll-bearer", transport: transport
|
|
)
|
|
}
|
|
|
|
@Test("a first enroll installs the leaf and records the server's rotation timing", .keychainSerialized)
|
|
func enrollInstallsLeafAndRecord() async throws {
|
|
try await withCleanLeafKeychain { keys, store in
|
|
// Arrange
|
|
#expect(try store.renewalState() == nil)
|
|
let transport = RecordingEnrollmentTransport(
|
|
status: 201,
|
|
body: enrollmentResponseJSON(
|
|
deviceId: "dev-first",
|
|
certDER: EnrolledLeafFixtures.leaf,
|
|
caChain: [EnrolledLeafFixtures.issuer],
|
|
notAfter: "2026-10-28T00:00:00.000Z",
|
|
renewAfter: "2026-09-27T00:00:00.000Z"
|
|
)
|
|
)
|
|
|
|
// Act — the returned summary is NOT asserted: reading it goes through
|
|
// `SecItemCopyMatching(kSecClassIdentity)`, which on macOS ignores the
|
|
// key tag and can hand back an unrelated login-keychain identity (see
|
|
// `defaultKeychainYieldsForeignIdentity`). What is persisted IS asserted.
|
|
_ = try await store.enroll(
|
|
using: enrollmentClient(transport), subdomain: "yaojia", deviceName: "Yaojia iPhone",
|
|
keyProvider: { try SecureEnclaveKeyFactory.generateSoftware() }
|
|
)
|
|
|
|
// Assert — exactly one leaf installed…
|
|
#expect(EnrolledLeafFixtures.installedCount() == 1)
|
|
// …and the record carries what the server said, so a later renew can
|
|
// address the right device.
|
|
let state = try #require(try store.renewalState())
|
|
#expect(state.deviceId == "dev-first")
|
|
#expect(state.notAfter != nil)
|
|
#expect(state.renewAfter != nil)
|
|
#expect(state.isRenewalDue(asOf: try #require(state.renewAfter)))
|
|
}
|
|
}
|
|
|
|
@Test("rotation installs the new leaf and updates the record in place", .keychainSerialized)
|
|
func rotationUpdatesRecordInPlace() async throws {
|
|
try await withCleanLeafKeychain { keys, store in
|
|
// Arrange — already enrolled.
|
|
let first = RecordingEnrollmentTransport(
|
|
status: 201,
|
|
body: enrollmentResponseJSON(
|
|
deviceId: "dev-old", certDER: EnrolledLeafFixtures.leaf
|
|
)
|
|
)
|
|
_ = try await store.enroll(
|
|
using: enrollmentClient(first), subdomain: "yaojia", deviceName: "iPhone",
|
|
keyProvider: { try SecureEnclaveKeyFactory.generateSoftware() }
|
|
)
|
|
#expect(try store.renewalState()?.deviceId == "dev-old")
|
|
|
|
// Act — a rotation returns a DISTINCT leaf.
|
|
let second = RecordingEnrollmentTransport(
|
|
status: 201,
|
|
body: enrollmentResponseJSON(
|
|
deviceId: "dev-new", certDER: EnrolledLeafFixtures.rotatedLeaf
|
|
)
|
|
)
|
|
_ = try await store.enroll(
|
|
using: enrollmentClient(second), subdomain: "yaojia", deviceName: "iPhone",
|
|
keyProvider: { try SecureEnclaveKeyFactory.generateSoftware() }
|
|
)
|
|
|
|
// Assert — the record is UPDATED (one item, new value), never duplicated:
|
|
// two records at the same (service, account) would make renew pick one at
|
|
// random.
|
|
#expect(KeychainProbe.count(service: keys.service, account: keys.enrollmentAccount) == 1)
|
|
#expect(try store.renewalState()?.deviceId == "dev-new")
|
|
// …and a leaf is installed at every step — the anti-lockout invariant
|
|
// (ordering itself is pinned by KeychainItemReplaceTests).
|
|
#expect(EnrolledLeafFixtures.installedCount() >= 1)
|
|
}
|
|
}
|
|
|
|
@Test("re-storing a byte-identical leaf is tolerated and keeps the record correct", .keychainSerialized)
|
|
func reEnrollingSameLeafIsIdempotent() async throws {
|
|
try await withCleanLeafKeychain { keys, store in
|
|
// Arrange — an interrupted enroll that is retried: the server re-issues
|
|
// the SAME certificate.
|
|
let body = enrollmentResponseJSON(
|
|
deviceId: "dev-retry", certDER: EnrolledLeafFixtures.leaf
|
|
)
|
|
let first = RecordingEnrollmentTransport(status: 201, body: body)
|
|
_ = try await store.enroll(
|
|
using: enrollmentClient(first), subdomain: "yaojia", deviceName: "iPhone",
|
|
keyProvider: { try SecureEnclaveKeyFactory.generateSoftware() }
|
|
)
|
|
|
|
// Act — `SecItemAdd` now answers errSecDuplicateItem, which must NOT be
|
|
// treated as a failure (the leaf we wanted installed IS installed).
|
|
let retry = RecordingEnrollmentTransport(status: 201, body: body)
|
|
await #expect(throws: Never.self) {
|
|
_ = try await store.enroll(
|
|
using: enrollmentClient(retry), subdomain: "yaojia", deviceName: "iPhone",
|
|
keyProvider: { try SecureEnclaveKeyFactory.generateSoftware() }
|
|
)
|
|
}
|
|
|
|
// Assert — still one leaf, and the record is intact.
|
|
#expect(EnrolledLeafFixtures.installedCount() == 1)
|
|
#expect(try store.renewalState()?.deviceId == "dev-retry")
|
|
}
|
|
}
|