import Foundation #if os(macOS) import Darwin import Security #endif public enum KeychainCacheStore { public struct Key: Hashable, Sendable { public let category: String public let identifier: String public init(category: String, identifier: String) { self.category = category self.identifier = identifier } var account: String { "\(self.category).\(self.identifier)" } } public enum LoadResult { case found(Entry) case missing case temporarilyUnavailable case invalid } public enum ClearResult: Equatable, Sendable { case removed case missing case failed } public enum KeysResult: Equatable, Sendable { case found([Key]) case temporarilyUnavailable case failed } private static let log = CodexBarLog.logger(LogCategories.keychainCache) private static let cacheService = "com.steipete.codexbar.cache" private static let cacheLabel = "CodexBar Cache" private nonisolated(unsafe) static var globalServiceOverride: String? @TaskLocal private static var serviceOverride: String? #if DEBUG @TaskLocal private static var operationRecorder: OperationRecorder? enum Operation: Equatable, Sendable { case load case store case clear } final class OperationRecorder: @unchecked Sendable { private let lock = NSLock() private var recordedOperations: [Operation] = [] var operations: [Operation] { self.lock.withLock { self.recordedOperations } } func record(_ operation: Operation) { self.lock.withLock { self.recordedOperations.append(operation) } } } #endif #if DEBUG && os(macOS) @TaskLocal private static var loadFailureStatusOverride: OSStatus? @TaskLocal private static var storeFailureStatusOverride: OSStatus? @TaskLocal private static var clearFailureStatusOverride: OSStatus? @TaskLocal private static var keysFailureStatusOverride: OSStatus? #endif private static let testStoreLock = NSLock() private struct TestStoreKey: Hashable { let service: String let account: String } private nonisolated(unsafe) static var testStore: [TestStoreKey: Data]? private nonisolated(unsafe) static var implicitTestStore: [TestStoreKey: Data] = [:] private nonisolated(unsafe) static var testStoreRefCount = 0 public static func load( key: Key, as type: Entry.Type = Entry.self) -> LoadResult { #if DEBUG self.operationRecorder?.record(.load) #endif #if DEBUG && os(macOS) if let status = self.loadFailureStatusOverride { return self.loadResultForKeychainReadFailure(status: status, key: key) } #endif if let testResult = loadFromTestStore(key: key, as: type) { return testResult } guard self.canUseRealKeychain else { return .missing } #if os(macOS) var query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: self.serviceName, kSecAttrAccount as String: key.account, kSecMatchLimit as String: kSecMatchLimitOne, kSecReturnData as String: true, ] KeychainNoUIQuery.apply(to: &query) var result: AnyObject? let status = KeychainSecurity.copyMatching(query as CFDictionary, &result) switch status { case errSecSuccess: guard let data = result as? Data, !data.isEmpty else { self.log.error("Keychain cache item was empty (\(key.account))") return .invalid } let decoder = Self.makeDecoder() guard let decoded = try? decoder.decode(Entry.self, from: data) else { self.log.error("Failed to decode keychain cache (\(key.account))") return .invalid } return .found(decoded) default: return self.loadResultForKeychainReadFailure(status: status, key: key) } #else return .missing #endif } public static func store(key: Key, entry: some Codable) { _ = self.storeResult(key: key, entry: entry) } @discardableResult public static func storeResult(key: Key, entry: some Codable) -> Bool { #if DEBUG self.operationRecorder?.record(.store) #endif #if DEBUG && os(macOS) if let status = self.storeFailureStatusOverride { self.log.error("Keychain cache store failed (\(key.account)): \(status)") return false } #endif if let stored = self.storeInTestStore(key: key, entry: entry) { return stored } guard self.canUseRealKeychain else { return false } #if os(macOS) let encoder = Self.makeEncoder() guard let data = try? encoder.encode(entry) else { self.log.error("Failed to encode keychain cache (\(key.account))") return false } var query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: self.serviceName, kSecAttrAccount as String: key.account, ] KeychainNoUIQuery.apply(to: &query) let updateStatus = KeychainSecurity.update( query as CFDictionary, [kSecValueData as String: data] as CFDictionary) if updateStatus == errSecSuccess { return true } if updateStatus != errSecItemNotFound { self.log.error("Keychain cache update failed (\(key.account)): \(updateStatus)") return false } var addQuery = query addQuery[kSecValueData as String] = data addQuery[kSecAttrLabel as String] = self.cacheLabel addQuery[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly if let access = self.cacheAccessControl() { addQuery[kSecAttrAccess as String] = access } let addStatus = KeychainSecurity.add(addQuery as CFDictionary, nil) if addStatus != errSecSuccess { self.log.error("Keychain cache add failed (\(key.account)): \(addStatus)") } return addStatus == errSecSuccess #else return false #endif } @discardableResult public static func clear(key: Key) -> Bool { self.clearResult(key: key) == .removed } public static func clearResult(key: Key) -> ClearResult { #if DEBUG self.operationRecorder?.record(.clear) #endif #if DEBUG && os(macOS) if let status = self.clearFailureStatusOverride { return self.clearResultForKeychainDeleteStatus(status, key: key) } #endif if let removed = self.clearTestStore(key: key) { return removed ? .removed : .missing } guard self.canUseRealKeychain else { return .failed } #if os(macOS) var query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: self.serviceName, kSecAttrAccount as String: key.account, ] KeychainNoUIQuery.apply(to: &query) return self.clearResultForKeychainDeleteStatus(KeychainSecurity.delete(query as CFDictionary), key: key) #else return .failed #endif } public static func keys(category: String) -> [Key] { switch self.keysResult(category: category) { case let .found(keys): keys case .temporarilyUnavailable, .failed: [] } } public static func keysResult(category: String) -> KeysResult { #if DEBUG && os(macOS) if let status = self.keysFailureStatusOverride { return self.keysResultForKeychainStatus(status, category: category, result: nil) } #endif if let keys = self.keysFromTestStore(category: category) { return .found(keys) } guard self.canUseRealKeychain else { return .failed } #if os(macOS) var query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: self.serviceName, kSecMatchLimit as String: kSecMatchLimitAll, kSecReturnAttributes as String: true, ] KeychainNoUIQuery.apply(to: &query) var result: AnyObject? let status = KeychainSecurity.copyMatching(query as CFDictionary, &result) return self.keysResultForKeychainStatus(status, category: category, result: result) #else return .failed #endif } static func setServiceOverrideForTesting(_ service: String?) { self.globalServiceOverride = service } public static func withServiceOverrideForTesting( _ service: String?, operation: () throws -> T) rethrows -> T { try self.$serviceOverride.withValue(service) { try operation() } } public static func withServiceOverrideForTesting( _ service: String?, operation: () async throws -> T) async rethrows -> T { try await self.$serviceOverride.withValue(service) { try await operation() } } public static func withCurrentServiceOverrideForTesting( operation: () async throws -> T) async rethrows -> T { let service = self.serviceOverride return try await self.$serviceOverride.withValue(service) { try await operation() } } public static var currentServiceOverrideForTesting: String? { self.serviceOverride } #if DEBUG static func withOperationRecorderForTesting( _ recorder: OperationRecorder?, operation: () throws -> T) rethrows -> T { try self.$operationRecorder.withValue(recorder) { try operation() } } static func withOperationRecorderForTesting( _ recorder: OperationRecorder?, operation: () async throws -> T) async rethrows -> T { try await self.$operationRecorder.withValue(recorder) { try await operation() } } static var currentOperationRecorderForTesting: OperationRecorder? { self.operationRecorder } #endif static var canUseRealKeychainForTesting: Bool { self.canUseRealKeychain } static var canEnumerateOrDeleteRealKeychainForTesting: Bool { self.canUseRealKeychain } #if DEBUG && os(macOS) public static func withLoadFailureStatusOverrideForTesting( _ status: OSStatus?, operation: () throws -> T) rethrows -> T { try self.$loadFailureStatusOverride.withValue(status) { try operation() } } public static func withStoreFailureStatusOverrideForTesting( _ status: OSStatus?, operation: () throws -> T) rethrows -> T { try self.$storeFailureStatusOverride.withValue(status) { try operation() } } public static func withClearFailureStatusOverrideForTesting( _ status: OSStatus?, operation: () throws -> T) rethrows -> T { try self.$clearFailureStatusOverride.withValue(status) { try operation() } } public static func withClearFailureStatusOverrideForTesting( _ status: OSStatus?, operation: () async throws -> T) async rethrows -> T { try await self.$clearFailureStatusOverride.withValue(status) { try await operation() } } public static func withKeysFailureStatusOverrideForTesting( _ status: OSStatus?, operation: () throws -> T) rethrows -> T { try self.$keysFailureStatusOverride.withValue(status) { try operation() } } #endif static func setTestStoreForTesting(_ enabled: Bool) { self.testStoreLock.lock() defer { self.testStoreLock.unlock() } if enabled { self.testStoreRefCount += 1 if self.testStoreRefCount == 1 { self.testStore = [:] } } else { self.testStoreRefCount = max(0, self.testStoreRefCount - 1) if self.testStoreRefCount == 0 { self.testStore = nil } } } private static var serviceName: String { serviceOverride ?? self.globalServiceOverride ?? self.cacheService } private static var canUseRealKeychain: Bool { !KeychainAccessGate.isDisabled } #if DEBUG private static var shouldUseImplicitTestStore: Bool { KeychainTestSafety.isRunningUnderTests( processName: ProcessInfo.processInfo.processName, environment: ProcessInfo.processInfo.environment) && !self.canUseRealKeychain } #else private static var shouldUseImplicitTestStore: Bool { false } #endif private static func makeEncoder() -> JSONEncoder { let encoder = JSONEncoder() encoder.dateEncodingStrategy = .iso8601 return encoder } private static func makeDecoder() -> JSONDecoder { let decoder = JSONDecoder() decoder.dateDecodingStrategy = .iso8601 return decoder } #if os(macOS) static func loadResultForKeychainReadFailure( status: OSStatus, key: Key) -> LoadResult { switch status { case errSecItemNotFound: return .missing case errSecInteractionNotAllowed: // Keychain is temporarily locked, e.g. immediately after wake from sleep. self.log.info("Keychain cache temporarily locked (\(key.account)), will retry on next access") return .temporarilyUnavailable default: self.log.error("Keychain cache read failed (\(key.account)): \(status)") return .invalid } } static func clearResultForKeychainDeleteStatus(_ status: OSStatus, key: Key) -> ClearResult { switch status { case errSecSuccess: return .removed case errSecItemNotFound: return .missing case errSecInteractionNotAllowed: self.log.info("Keychain cache delete temporarily unavailable (\(key.account))") return .failed default: self.log.error("Keychain cache delete failed (\(key.account)): \(status)") return .failed } } private static func keysResultForKeychainStatus( _ status: OSStatus, category: String, result: AnyObject?) -> KeysResult { switch status { case errSecSuccess: guard let rows = result as? [[String: Any]] else { return .failed } let keys: [Key] = rows.compactMap { row in guard let account = row[kSecAttrAccount as String] as? String else { return nil } return self.key(fromAccount: account, category: category) } return .found(keys) case errSecItemNotFound: return .found([]) case errSecInteractionNotAllowed: self.log.info("Keychain cache keys temporarily unavailable (\(category))") return .temporarilyUnavailable default: self.log.error("Keychain cache key listing failed (\(category)): \(status)") return .failed } } static func trustedApplicationPathsForCacheAccess( bundleURL: URL = Bundle.main.bundleURL, executableURL: URL? = Bundle.main.executableURL, fileExists: (String) -> Bool = { FileManager.default.fileExists(atPath: $0) }) -> [String] { var paths: [String] = [] func append(_ path: String) { guard !path.isEmpty, fileExists(path), !paths.contains(path) else { return } paths.append(path) } let appBundle = self.appBundleURL(containing: bundleURL) ?? executableURL.flatMap(self.appBundleURL(containing:)) if let appBundle { append(appBundle.path) append(appBundle.appendingPathComponent("Contents/Helpers/CodexBarCLI").path) } if let executableURL { append(executableURL.path) } return paths } private static func appBundleURL(containing url: URL) -> URL? { var current = url.standardizedFileURL while current.path != "/" { if current.pathExtension == "app" { return current } current.deleteLastPathComponent() } return nil } private static func cacheAccessControl() -> SecAccess? { let trustedPaths = self.trustedApplicationPathsForCacheAccess() guard !trustedPaths.isEmpty else { return nil } var trustedApplications: [SecTrustedApplication] = [] for path in trustedPaths { let (status, application) = self.createTrustedApplication(path: path) if status == errSecSuccess, let application { trustedApplications.append(application) } else { self.log.error("Keychain cache trusted app creation failed (\(path)): \(status)") } } guard !trustedApplications.isEmpty else { return nil } let (status, access) = self.createAccessControl(trustedApplications: trustedApplications) if status != errSecSuccess { self.log.error("Keychain cache access control creation failed: \(status)") return nil } return access } private typealias SecTrustedApplicationCreateFromPathFunction = @convention(c) ( UnsafePointer?, UnsafeMutablePointer?) -> OSStatus private typealias SecAccessCreateFunction = @convention(c) ( CFString, CFArray, UnsafeMutablePointer?) -> OSStatus private static func createTrustedApplication(path: String) -> (OSStatus, SecTrustedApplication?) { guard let symbol = self.securitySymbol(named: "SecTrustedApplicationCreateFromPath") else { return (errSecInternalComponent, nil) } let function = unsafeBitCast(symbol, to: SecTrustedApplicationCreateFromPathFunction.self) var application: SecTrustedApplication? let status = path.withCString { cPath in function(cPath, &application) } return (status, application) } private static func createAccessControl(trustedApplications: [SecTrustedApplication]) -> (OSStatus, SecAccess?) { guard let symbol = self.securitySymbol(named: "SecAccessCreate") else { return (errSecInternalComponent, nil) } let function = unsafeBitCast(symbol, to: SecAccessCreateFunction.self) var access: SecAccess? let status = function(self.cacheLabel as CFString, trustedApplications as CFArray, &access) return (status, access) } private nonisolated(unsafe) static let securityFrameworkHandle: UnsafeMutableRawPointer? = { let securityPath = "/System/Library/Frameworks/Security.framework/Security" return dlopen(securityPath, RTLD_NOW) }() private static func securitySymbol(named name: String) -> UnsafeMutableRawPointer? { // Resolve deprecated SecKeychain ACL helpers at runtime so release builds stay warning-free // while still granting the app bundle and bundled CLI prompt-free access to cache entries. guard let securityFrameworkHandle else { return nil } return dlsym(securityFrameworkHandle, name) } #endif private static func loadFromTestStore( key: Key, as type: Entry.Type) -> LoadResult? { self.testStoreLock.lock() defer { self.testStoreLock.unlock() } guard let store = self.testStore ?? (self.shouldUseImplicitTestStore ? self.implicitTestStore : nil) else { return nil } let testKey = TestStoreKey(service: self.serviceName, account: key.account) guard let data = store[testKey] else { return .missing } let decoder = Self.makeDecoder() guard let decoded = try? decoder.decode(Entry.self, from: data) else { return .invalid } return .found(decoded) } private static func storeInTestStore(key: Key, entry: some Codable) -> Bool? { self.testStoreLock.lock() defer { self.testStoreLock.unlock() } let encoder = Self.makeEncoder() guard let data = try? encoder.encode(entry) else { return false } let testKey = TestStoreKey(service: self.serviceName, account: key.account) if var store = self.testStore { store[testKey] = data self.testStore = store return true } if self.shouldUseImplicitTestStore { self.implicitTestStore[testKey] = data return true } return nil } private static func clearTestStore(key: Key) -> Bool? { self.testStoreLock.lock() defer { self.testStoreLock.unlock() } let testKey = TestStoreKey(service: self.serviceName, account: key.account) if var store = self.testStore { let removed = store.removeValue(forKey: testKey) != nil self.testStore = store return removed } if self.shouldUseImplicitTestStore { return self.implicitTestStore.removeValue(forKey: testKey) != nil } return nil } private static func keysFromTestStore(category: String) -> [Key]? { self.testStoreLock.lock() defer { self.testStoreLock.unlock() } guard let store = self.testStore ?? (self.shouldUseImplicitTestStore ? self.implicitTestStore : nil) else { return nil } return store.keys .filter { $0.service == self.serviceName } .compactMap { self.key(fromAccount: $0.account, category: category) } .sorted { $0.identifier < $1.identifier } } private static func key(fromAccount account: String, category: String) -> Key? { let prefix = "\(category)." guard account.hasPrefix(prefix) else { return nil } let identifier = String(account.dropFirst(prefix.count)) guard !identifier.isEmpty else { return nil } return Key(category: category, identifier: identifier) } } extension KeychainCacheStore.Key { public static func cookie(provider: UsageProvider, scopeIdentifier: String? = nil) -> Self { let identifier: String = if let scopeIdentifier, !scopeIdentifier.isEmpty { "\(provider.rawValue).\(scopeIdentifier)" } else { provider.rawValue } return Self(category: "cookie", identifier: identifier) } public static func oauth(provider: UsageProvider) -> Self { Self(category: "oauth", identifier: provider.rawValue) } }