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) }