Files
web-terminal/ios/Packages/ClientTLS/Tests/ClientTLSTests/KeychainClientIdentityStoreEnrollmentTests.swift
Yaojia Wang a5fa843f00 test(ios): ClientTLS coverage 55.76% -> 89.49%, gate it, fix the three dead CI legs
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.
2026-07-30 12:45:26 +02:00

316 lines
15 KiB
Swift

import Foundation
import Security
import Testing
@testable import ClientTLS
// B4 · The Secure-Enclave enrollment half of `KeychainClientIdentityStore`:
// `enroll`, `renew`, `renewalState`. What is pinned here is the ORCHESTRATION
// which key signs the CSR, which endpoint it goes to, what the request body is
// allowed to contain, and what happens to local state when the server's answer
// is unusable. The signing key is injected (`keyProvider`) or pre-installed
// under the store's derived tag, so no Secure Enclave is needed.
//
// Every test stops at the leaf-installation step by having the server return an
// undecodable certificate (`undecodableCertificateDER`) see the note there for
// why installing a real leaf is not possible in a macOS `swift test` run. The
// happy-path leaf install + `SecItemCopyMatching(kSecClassIdentity)` assembly
// stays a device/simulator concern.
private let controlPlaneURL = URL(string: "https://cp.terminal.yaojia.wang")!
@Test("renewalState is nil before any enrollment", .keychainSerialized)
func renewalStateNilWhenNotEnrolled() async throws {
let keys = StoreKeychainKeys.unique()
defer { KeychainProbe.purge(keys) }
let store = KeychainClientIdentityStore(service: keys.service, account: keys.account)
#expect(try store.renewalState() == nil)
}
@Test("renewalState reads back the persisted enrollment record verbatim", .keychainSerialized)
func renewalStateReadsPersistedRecord() async throws {
// Arrange the record layout is a migration contract, so it is seeded
// directly and read back through the public API.
let keys = StoreKeychainKeys.unique()
defer { KeychainProbe.purge(keys) }
let notAfter = Date(timeIntervalSinceReferenceDate: 800_000_000)
let renewAfter = Date(timeIntervalSinceReferenceDate: 790_000_000)
KeychainProbe.write(
service: keys.service, account: keys.enrollmentAccount,
data: storedEnrollmentJSON(
deviceId: "dev-42", deviceName: "Yaojia iPhone",
caChain: [Data([0x30, 0xAA])], notAfter: notAfter, renewAfter: renewAfter
)
)
let store = KeychainClientIdentityStore(service: keys.service, account: keys.account)
// Act
let state = try #require(try store.renewalState())
// Assert deviceId drives POST /device/:id/renew; the dates drive the
// scheduler's decision.
#expect(state.deviceId == "dev-42")
#expect(state.notAfter == notAfter)
#expect(state.renewAfter == renewAfter)
#expect(state.isRenewalDue(asOf: renewAfter.addingTimeInterval(1)))
#expect(state.isRenewalDue(asOf: renewAfter.addingTimeInterval(-1)) == false)
}
@Test("a record with no renewAfter never reports renewal due (fail-safe)", .keychainSerialized)
func renewalStateWithoutRenewAfterNeverDue() async throws {
// Arrange an older server that omitted the advisory field.
let keys = StoreKeychainKeys.unique()
defer { KeychainProbe.purge(keys) }
KeychainProbe.write(
service: keys.service, account: keys.enrollmentAccount,
data: storedEnrollmentJSON(deviceId: "dev-legacy", deviceName: "iPad")
)
let store = KeychainClientIdentityStore(service: keys.service, account: keys.account)
// Act
let state = try #require(try store.renewalState())
// Assert the TLS stack stays the real gate; the scheduler must not guess.
#expect(state.notAfter == nil)
#expect(state.renewAfter == nil)
#expect(state.isRenewalDue(asOf: Date.distantFuture) == false)
}
@Test("an undecodable enrollment record surfaces .corruptStoredBlob", .keychainSerialized)
func renewalStateCorruptRecord() async throws {
// Arrange
let keys = StoreKeychainKeys.unique()
defer { KeychainProbe.purge(keys) }
KeychainProbe.write(
service: keys.service, account: keys.enrollmentAccount,
data: Data("{\"deviceId\":".utf8) // truncated JSON
)
let store = KeychainClientIdentityStore(service: keys.service, account: keys.account)
// Act / Assert never a silent "not enrolled" (that would make the
// scheduler skip rotation forever on a device that IS enrolled).
#expect(throws: ClientIdentityStoreError.corruptStoredBlob) {
_ = try store.renewalState()
}
}
@Test("enroll signs the CSR with the injected key and posts it to /device/enroll", .keychainSerialized)
func enrollPostsCSRSignedByTheDeviceKey() async throws {
// Arrange
let keys = StoreKeychainKeys.unique()
defer { KeychainProbe.purge(keys) }
let store = KeychainClientIdentityStore(service: keys.service, account: keys.account)
let deviceKey = try SecureEnclaveKeyFactory.generateSoftware()
let transport = RecordingEnrollmentTransport(status: 201, body: enrollmentResponseJSON())
let client = DeviceEnrollmentClient(
baseURL: controlPlaneURL, bearerToken: "enroll-bearer", transport: transport
)
// Act the response's leaf is undecodable, so persistence fails; the CSR has
// already been built and sent, which is what this test is about.
await #expect(throws: ClientIdentityStoreError.corruptStoredBlob) {
_ = try await store.enroll(
using: client, subdomain: "yaojia", deviceName: "Yaojia iPhone",
keyProvider: { deviceKey }
)
}
// Assert one request, to the enroll endpoint, carrying the A4 body.
#expect(transport.callCount == 1)
let request = try #require(transport.requests.first)
#expect(request.url?.absoluteString == "https://cp.terminal.yaojia.wang/device/enroll")
let body = try #require(transport.jsonBody(at: 0))
#expect(body["subdomain"] as? String == "yaojia")
#expect(body["deviceName"] as? String == "Yaojia iPhone")
#expect(body["keyAlg"] as? String == "ec-p256")
// and the CSR is bound to the injected key, with the device name as CN.
let csrDER = try #require(transport.csrDER(at: 0))
let csr = try #require(parseCSR(csrDER))
#expect(csr.subjectCommonName == "Yaojia iPhone")
#expect(csr.publicPointX963 == (try deviceKey.publicKeyX963()))
}
@Test("an undecodable leaf leaves NO enrollment record behind", .keychainSerialized)
func enrollDoesNotPersistOnUndecodableLeaf() async throws {
// Arrange
let keys = StoreKeychainKeys.unique()
defer { KeychainProbe.purge(keys) }
let store = KeychainClientIdentityStore(service: keys.service, account: keys.account)
let transport = RecordingEnrollmentTransport(status: 201, body: enrollmentResponseJSON())
let client = DeviceEnrollmentClient(
baseURL: controlPlaneURL, bearerToken: "enroll-bearer", transport: transport
)
// Act
await #expect(throws: ClientIdentityStoreError.corruptStoredBlob) {
_ = try await store.enroll(
using: client, subdomain: "yaojia", deviceName: "iPhone",
keyProvider: { try SecureEnclaveKeyFactory.generateSoftware() }
)
}
// Assert a half-written record would make `renewalState` claim an
// enrollment that has no leaf, and the scheduler would renew a phantom.
#expect(KeychainProbe.count(service: keys.service, account: keys.enrollmentAccount) == 0)
#expect(try store.renewalState() == nil)
}
@Test("a rejected enrollment (403) propagates the server error and stores nothing", .keychainSerialized)
func enrollPropagatesServerRejection() async throws {
// Arrange subdomain not owned by the account.
let keys = StoreKeychainKeys.unique()
defer { KeychainProbe.purge(keys) }
let store = KeychainClientIdentityStore(service: keys.service, account: keys.account)
let body = try JSONSerialization.data(withJSONObject: ["error": "subdomain_not_owned"])
let transport = RecordingEnrollmentTransport(status: 403, body: body)
let client = DeviceEnrollmentClient(
baseURL: controlPlaneURL, bearerToken: "enroll-bearer", transport: transport
)
// Act / Assert
await #expect(
throws: DeviceEnrollmentError.http(status: 403, code: "subdomain_not_owned")
) {
_ = try await store.enroll(
using: client, subdomain: "someone-else", deviceName: "iPhone",
keyProvider: { try SecureEnclaveKeyFactory.generateSoftware() }
)
}
#expect(KeychainProbe.count(service: keys.service, account: keys.enrollmentAccount) == 0)
}
@Test("renew without an enrollment record fails locally and never calls the server", .keychainSerialized)
func renewWithoutRecordDoesNotCallServer() async throws {
// Arrange
let keys = StoreKeychainKeys.unique()
defer { KeychainProbe.purge(keys) }
let store = KeychainClientIdentityStore(service: keys.service, account: keys.account)
let transport = RecordingEnrollmentTransport(status: 201, body: enrollmentResponseJSON())
let client = DeviceEnrollmentClient(baseURL: controlPlaneURL, transport: transport)
// Act / Assert nothing to renew: a fresh install or a legacy .p12-only
// device. The scheduler must not fire a request it cannot authenticate.
await #expect(throws: ClientIdentityStoreError.corruptStoredBlob) {
_ = try await store.renew(using: client)
}
#expect(transport.callCount == 0)
}
@Test("renew without the device key fails locally and never calls the server", .keychainSerialized)
func renewWithoutDeviceKeyDoesNotCallServer() async throws {
// Arrange a record exists but the Secure-Enclave key is gone (restored
// backup / manually cleared keychain).
let keys = StoreKeychainKeys.unique()
defer { KeychainProbe.purge(keys) }
KeychainProbe.write(
service: keys.service, account: keys.enrollmentAccount,
data: storedEnrollmentJSON(deviceId: "dev-99", deviceName: "iPhone")
)
let store = KeychainClientIdentityStore(service: keys.service, account: keys.account)
let transport = RecordingEnrollmentTransport(status: 201, body: enrollmentResponseJSON())
let client = DeviceEnrollmentClient(baseURL: controlPlaneURL, transport: transport)
// Act / Assert a renew CSR signed by a NEW key would be rejected by the
// server (PoP against the enrolled key), so it must not be attempted.
await #expect(throws: ClientIdentityStoreError.corruptStoredBlob) {
_ = try await store.renew(using: client)
}
#expect(transport.callCount == 0)
}
@Test("renew re-signs with the SAME device key and posts a csr-only body to /device/:id/renew", .keychainSerialized)
func renewReusesTheEnrolledDeviceKey() async throws {
// Arrange an enrolled device: record + the permanent key under the store's
// derived tag (the shape `enroll` leaves behind).
let keys = StoreKeychainKeys.unique()
defer { KeychainProbe.purge(keys) }
let enrolledKey = try SecureEnclaveKeyFactory.generateSoftware(
tag: keys.deviceKeyTag, permanent: true
)
let originalRecord = storedEnrollmentJSON(
deviceId: "dev-77", deviceName: "Yaojia iPad",
notAfter: Date(timeIntervalSinceReferenceDate: 800_000_000),
renewAfter: Date(timeIntervalSinceReferenceDate: 790_000_000)
)
KeychainProbe.write(
service: keys.service, account: keys.enrollmentAccount, data: originalRecord
)
let store = KeychainClientIdentityStore(service: keys.service, account: keys.account)
let transport = RecordingEnrollmentTransport(status: 201, body: enrollmentResponseJSON())
// Nil bearer: the renew endpoint authenticates by the CURRENT client cert.
let client = DeviceEnrollmentClient(baseURL: controlPlaneURL, transport: transport)
// Act
await #expect(throws: ClientIdentityStoreError.corruptStoredBlob) {
_ = try await store.renew(using: client)
}
// Assert the endpoint carries the stored deviceId
#expect(transport.callCount == 1)
let request = try #require(transport.requests.first)
#expect(request.url?.absoluteString == "https://cp.terminal.yaojia.wang/device/dev-77/renew")
#expect(request.value(forHTTPHeaderField: "Authorization") == nil)
// the body is `{ csr }` ONLY (a stray field 400s every silent renewal)
let body = try #require(transport.jsonBody(at: 0))
#expect(Set(body.keys) == ["csr"])
// and the CSR re-uses the ENROLLED key (a new key would fail the server's
// PoP-against-the-enrolled-key check) with the stored device name as CN.
let csrDER = try #require(transport.csrDER(at: 0))
let csr = try #require(parseCSR(csrDER))
#expect(csr.publicPointX963 == (try enrolledKey.publicKeyX963()))
#expect(csr.subjectCommonName == "Yaojia iPad")
// and a failed rotation leaves the EXISTING enrollment intact.
let state = try #require(try store.renewalState())
#expect(state.deviceId == "dev-77")
#expect(state.renewAfter == Date(timeIntervalSinceReferenceDate: 790_000_000))
}
@Test("the flow's production install step is wired to the keychain store's SE enroll", .keychainSerialized)
func enrollmentFlowWiresTheKeychainStore() async throws {
// Arrange the `store:` convenience initializer is the composition the app
// uses: login bearer enroll install. Its install step deliberately
// takes NO keyProvider, i.e. it always asks for a real Secure-Enclave key.
let keys = StoreKeychainKeys.unique()
defer { KeychainProbe.purge(keys) }
let store = KeychainClientIdentityStore(service: keys.service, account: keys.account)
let loginBody = try JSONSerialization.data(withJSONObject: [
"enrollToken": "enroll-bearer", "accountId": "acct-1", "expiresIn": 300,
])
let transport = RecordingEnrollmentTransport(replies: [
.init(status: 201, body: loginBody),
.init(status: 201, body: enrollmentResponseJSON()),
])
let flow = DeviceEnrollmentFlow(
baseURL: controlPlaneURL, transport: transport, store: store
)
// Act
var thrown: Error?
do {
_ = try await flow.run(
password: "account-password", subdomain: "yaojia", deviceName: "iPhone"
)
} catch {
thrown = error
}
// Assert login happened first, with the password and nothing else
let loginRequest = try #require(transport.requests.first)
#expect(loginRequest.url?.path == "/auth/login")
#expect(Set(try #require(transport.jsonBody(at: 0)).keys) == ["password"])
// and the failure came from the INSTALL step, not the login step: the
// convenience initializer really does route into the keychain store. Off
// device (macOS `swift test`, Simulator) `generateSecureEnclave` cannot mint
// a key, so `.secureEnclaveUnavailable` is the expected stop; an entitled
// host gets as far as the undecodable leaf (`.corruptStoredBlob`).
let error = try #require(thrown)
#expect(error is SecureEnclaveKeyError || error is ClientIdentityStoreError)
#expect(error is ControlPlaneLoginError == false)
#expect(KeychainProbe.count(service: keys.service, account: keys.enrollmentAccount) == 0)
}