import Foundation import Security import Testing @testable import ClientTLS // B4 · `KeychainClientIdentityStore` against the REAL keychain — the legacy // `.p12` half (the dual-trust migration window: devices enrolled before the // Secure-Enclave path still carry an imported `.p12`). // // This package has no `SecItemShim` seam (unlike HostRegistry), so these tests // drive the actual `SecItem*` calls. Each test gets a UUID-scoped service and // purges it afterwards, so runs never collide and nothing is left behind. // // WHAT THIS LAYER CANNOT CHECK: the `kSecAttrAccessible` protection class. On // macOS `swift test` reaches the FILE-BASED keychain, which has no // data-protection class at all — a stored item's attributes come back as only // `acct/cdat/class/labl/mdat/svce`, with no `pdmn` (verified 2026-07-30). The // "AfterFirstUnlockThisDeviceOnly, never synchronized" assertion therefore // belongs to a SIGNED simulator host, exactly as plan §9 already records for // `KeychainHostStore`. It is NOT covered by this package's gate. @Test("save persists exactly one item holding the .p12 and its passphrase", .keychainSerialized) func keychainStoreSavePersistsSingleItem() async throws { // Arrange let keys = StoreKeychainKeys.unique() defer { KeychainProbe.purge(keys) } let store = KeychainClientIdentityStore(service: keys.service, account: keys.account) // Act try store.save( p12Data: ClientTLSFixtures.deviceP12Data, passphrase: ClientTLSFixtures.passphrase ) // Assert — one item, and the blob carries BOTH halves (the passphrase is // required to re-import at every launch, so storing the .p12 alone would // brick the identity after a relaunch). #expect(KeychainProbe.count(service: keys.service, account: keys.account) == 1) let stored = try #require(storedP12Fields(service: keys.service, account: keys.account)) #expect(stored.p12 == ClientTLSFixtures.deviceP12Data.base64EncodedString()) #expect(stored.passphrase == ClientTLSFixtures.passphrase) } @Test("saving again REPLACES the stored blob instead of duplicating the item", .keychainSerialized) func keychainStoreSaveReplaces() async throws { // Arrange let keys = StoreKeychainKeys.unique() defer { KeychainProbe.purge(keys) } let store = KeychainClientIdentityStore(service: keys.service, account: keys.account) try store.save( p12Data: ClientTLSFixtures.deviceP12Data, passphrase: ClientTLSFixtures.passphrase ) let first = try #require(storedP12Fields(service: keys.service, account: keys.account)) // Act — same coordinates, re-saved (the rotation / re-install path). try store.save( p12Data: ClientTLSFixtures.deviceP12Data, passphrase: ClientTLSFixtures.passphrase ) // Assert — still exactly one item (a duplicate would make loads // order-dependent, i.e. an old cert could resurface after rotation) and the // content is intact. #expect(KeychainProbe.count(service: keys.service, account: keys.account) == 1) let second = try #require(storedP12Fields(service: keys.service, account: keys.account)) #expect(second == first) } @Test("a wrong passphrase throws and writes NOTHING to the keychain", .keychainSerialized) func keychainStoreSaveRejectsWrongPassphrase() async throws { // Arrange let keys = StoreKeychainKeys.unique() defer { KeychainProbe.purge(keys) } let store = KeychainClientIdentityStore(service: keys.service, account: keys.account) // Act / Assert — validation happens BEFORE persistence. #expect(throws: PKCS12ImportError.wrongPassphrase) { try store.save(p12Data: ClientTLSFixtures.deviceP12Data, passphrase: "wrong") } #expect(KeychainProbe.count(service: keys.service, account: keys.account) == 0) } @Test("a corrupt .p12 throws .corruptFile and leaves the PRIOR identity installed", .keychainSerialized) func keychainStoreSaveKeepsPriorIdentityOnCorruptFile() async throws { // Arrange — a good identity is already installed. let keys = StoreKeychainKeys.unique() defer { KeychainProbe.purge(keys) } let store = KeychainClientIdentityStore(service: keys.service, account: keys.account) try store.save( p12Data: ClientTLSFixtures.deviceP12Data, passphrase: ClientTLSFixtures.passphrase ) let good = try #require(storedP12Fields(service: keys.service, account: keys.account)) // Act — a truncated file arrives (bad download / wrong file picked). #expect(throws: PKCS12ImportError.corruptFile) { try store.save( p12Data: ClientTLSFixtures.deviceP12Data.prefix(64), passphrase: ClientTLSFixtures.passphrase ) } // Assert — the working identity is untouched, not wiped by the failed install. #expect(KeychainProbe.count(service: keys.service, account: keys.account) == 1) let survivor = try #require(storedP12Fields(service: keys.service, account: keys.account)) #expect(survivor == good) } @Test("remove deletes the stored blob and a second remove is a no-op", .keychainSerialized) func keychainStoreRemoveIsIdempotent() async throws { // Arrange let keys = StoreKeychainKeys.unique() defer { KeychainProbe.purge(keys) } let store = KeychainClientIdentityStore(service: keys.service, account: keys.account) try store.save( p12Data: ClientTLSFixtures.deviceP12Data, passphrase: ClientTLSFixtures.passphrase ) #expect(KeychainProbe.count(service: keys.service, account: keys.account) == 1) // Act try store.remove() // Assert — gone, and removing again must NOT throw errSecItemNotFound // (removal is reachable from "delete host" with no identity installed). #expect(KeychainProbe.count(service: keys.service, account: keys.account) == 0) #expect(throws: Never.self) { try store.remove() } #expect(KeychainProbe.count(service: keys.service, account: keys.account) == 0) } @Test("remove also clears the enrollment record and the device key", .keychainSerialized) func keychainStoreRemoveClearsEnrolledState() async throws { // Arrange — a device that enrolled (record + device key) AND carries a // legacy .p12, i.e. mid-migration. let keys = StoreKeychainKeys.unique() defer { KeychainProbe.purge(keys) } let store = KeychainClientIdentityStore(service: keys.service, account: keys.account) try store.save( p12Data: ClientTLSFixtures.deviceP12Data, passphrase: ClientTLSFixtures.passphrase ) KeychainProbe.write( service: keys.service, account: keys.enrollmentAccount, data: storedEnrollmentJSON(deviceId: "dev-remove", deviceName: "iPhone") ) _ = try SecureEnclaveKeyFactory.generateSoftware(tag: keys.deviceKeyTag, permanent: true) // Act try store.remove() // Assert — removal is unconditional across BOTH paths; a leftover device key // would make a later enroll bind a new leaf to a stale key. #expect(KeychainProbe.count(service: keys.service, account: keys.account) == 0) #expect(KeychainProbe.count(service: keys.service, account: keys.enrollmentAccount) == 0) #expect(try SecureEnclaveKeyFactory.load(tag: keys.deviceKeyTag) == nil) #expect(try store.renewalState() == nil) } // The `.p12` fallback inside `loadIdentity()` is only reachable when the default // keychain holds no identities: on macOS the `kSecClassIdentity` lookup ignores // `kSecAttrApplicationTag` and returns an arbitrary foreign identity, which // short-circuits the fallback (see `defaultKeychainYieldsForeignIdentity`). The // test is gated rather than fudged — it runs on a clean runner and is reported as // skipped on a developer machine with identities in the login keychain. @Test( "save → loadIdentity → loadSummary roundtrips through the real keychain", .enabled(if: !defaultKeychainYieldsForeignIdentity()) , .keychainSerialized) func keychainStoreLoadsBackTheStoredIdentity() async throws { // Arrange let keys = StoreKeychainKeys.unique() defer { KeychainProbe.purge(keys) } let store = KeychainClientIdentityStore(service: keys.service, account: keys.account) #expect(store.hasInstalledIdentity() == false) #expect(try store.loadIdentity() == nil) // Act try store.save( p12Data: ClientTLSFixtures.deviceP12Data, passphrase: ClientTLSFixtures.passphrase ) // Assert — re-imported from the stored bytes + passphrase. #expect(store.hasInstalledIdentity() == true) let identity = try #require(try store.loadIdentity()) #expect(identity.summary()?.subjectCommonName == ClientTLSFixtures.leafCommonName) let summary = try #require(try store.loadSummary()) #expect(summary.issuerCommonName == ClientTLSFixtures.issuerCommonName) // Act / Assert — and after removal the gate closes again. try store.remove() #expect(store.hasInstalledIdentity() == false) #expect(store.loadedIdentityOrNil() == nil) } @Test( "a corrupt stored blob surfaces .corruptStoredBlob, and loadedIdentityOrNil swallows it", .enabled(if: !defaultKeychainYieldsForeignIdentity()) , .keychainSerialized) func keychainStoreCorruptBlobIsTyped() async throws { // Arrange — bytes that are not the stored JSON envelope (a partial write, or // an item written by an older schema). let keys = StoreKeychainKeys.unique() defer { KeychainProbe.purge(keys) } KeychainProbe.write( service: keys.service, account: keys.account, data: Data("not-json".utf8) ) let store = KeychainClientIdentityStore(service: keys.service, account: keys.account) // Act / Assert — typed error for the install UI… #expect(throws: ClientIdentityStoreError.corruptStoredBlob) { _ = try store.loadIdentity() } // …and launch does not crash: the convenience wrapper logs and degrades. #expect(store.loadedIdentityOrNil() == nil) }