import Foundation import Security @testable import ClientTLS // B4 · Direct `SecItem*` access for the store tests. Two jobs: // 1. INSPECT what `KeychainClientIdentityStore` actually wrote — the protection // class and the no-iCloud-sync flag are security requirements (plan §5.3 / // contract §1.1) that only a raw read can confirm. // 2. SEED / CORRUPT the persisted records so the read-side error paths and the // renew path can be driven without first performing a real enrollment // (which would have to install a certificate — see `undecodableCertificateDER`). /// The keychain coordinates a `KeychainClientIdentityStore(service:account:)` /// derives internally. Mirrored here ON PURPOSE: the persisted layout is a /// migration contract, so the tests pin it rather than infer it. struct StoreKeychainKeys { let service: String let account: String /// `KeychainClientIdentityStore.enrollmentAccount`. var enrollmentAccount: String { "\(account).enrollment" } /// `KeychainClientIdentityStore.deviceKeyTag`. var deviceKeyTag: Data { Data("\(service).device-key".utf8) } /// `KeychainClientIdentityStore.leafLabel`. var leafLabel: String { "\(service).device-leaf" } /// A fresh, collision-free coordinate set for one test. static func unique() -> StoreKeychainKeys { StoreKeychainKeys( service: "com.yaojia.webterm.clienttls.test-\(UUID().uuidString)", account: "device-identity" ) } } enum KeychainProbe { private static func query(service: String, account: String) -> [String: Any] { [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service, kSecAttrAccount as String: account, ] } /// How many generic-password items sit at `(service, account)`. `0` = nothing /// stored; `> 1` = a write duplicated instead of replacing. /// /// Attributes-only, `kSecMatchLimitAll`: asking for `kSecReturnData` /// together with `kSecMatchLimitAll` is `errSecParam (-50)` on the macOS /// file-based keychain (verified 2026-07-30), so counting and reading are /// separate calls. static func count(service: String, account: String) -> Int { var request = query(service: service, account: account) request[kSecReturnAttributes as String] = true request[kSecMatchLimit as String] = kSecMatchLimitAll var result: CFTypeRef? guard SecItemCopyMatching(request as CFDictionary, &result) == errSecSuccess else { return 0 } if let array = result as? [Any] { return array.count } return result == nil ? 0 : 1 } /// The stored bytes at `(service, account)`; `nil` when no item exists. static func data(service: String, account: String) -> Data? { var request = query(service: service, account: account) request[kSecReturnData as String] = true request[kSecMatchLimit as String] = kSecMatchLimitOne var result: CFTypeRef? guard SecItemCopyMatching(request as CFDictionary, &result) == errSecSuccess else { return nil } return result as? Data } /// Write raw bytes at `(service, account)` with the store's own protection /// class — used to seed or corrupt a record. @discardableResult static func write(service: String, account: String, data: Data) -> OSStatus { SecItemDelete(query(service: service, account: account) as CFDictionary) var attributes = query(service: service, account: account) attributes[kSecValueData as String] = data attributes[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly return SecItemAdd(attributes as CFDictionary, nil) } @discardableResult static func delete(service: String, account: String) -> OSStatus { SecItemDelete(query(service: service, account: account) as CFDictionary) } /// Remove everything a test could have created under `keys`. static func purge(_ keys: StoreKeychainKeys) { delete(service: keys.service, account: keys.account) delete(service: keys.service, account: keys.enrollmentAccount) try? SecureEnclaveKeyFactory.delete(tag: keys.deviceKeyTag) } } /// The two fields of the stored `.p12` envelope, decoded from the keychain item. /// /// Compared field-by-field rather than byte-by-byte because Foundation's /// `JSONEncoder` serializes through an unordered dictionary on Darwin: two /// encodings of the SAME value differ in key order (verified 2026-07-30 — both /// blobs 2109 bytes, not equal), so byte equality is not a valid invariant. func storedP12Fields(service: String, account: String) -> (p12: String, passphrase: String)? { guard let data = KeychainProbe.data(service: service, account: account), let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let p12 = json["p12"] as? String, let passphrase = json["passphrase"] as? String else { return nil } return (p12, passphrase) } /// The JSON `StoredEnrollment` shape (`JSONEncoder` defaults: `Data` → base64 /// string, `Date` → seconds since the reference date). Written directly so the /// read path can be exercised without installing a leaf certificate. func storedEnrollmentJSON( deviceId: String, deviceName: String, caChain: [Data] = [], notAfter: Date? = nil, renewAfter: Date? = nil ) -> Data { var json: [String: Any] = [ "deviceId": deviceId, "deviceName": deviceName, "caChain": caChain.map { $0.base64EncodedString() }, ] if let notAfter { json["notAfter"] = notAfter.timeIntervalSinceReferenceDate } if let renewAfter { json["renewAfter"] = renewAfter.timeIntervalSinceReferenceDate } return try! JSONSerialization.data(withJSONObject: json) }