190 lines · 6.7 KB · Swift
RawDownload
import CodexBarCoreimport Foundationimport Security protocol CookieHeaderStoring: Sendable {    func loadCookieHeader() throws -> String?    func storeCookieHeader(_ header: String?) throws} enum CookieHeaderStoreError: LocalizedError {    case keychainStatus(OSStatus)    case invalidData     var errorDescription: String? {        switch self {        case let .keychainStatus(status):            "Keychain error: \(status)"        case .invalidData:            "Keychain returned invalid data."        }    }} struct KeychainCookieHeaderStore: CookieHeaderStoring {    private static let log = CodexBarLog.logger(LogCategories.cookieHeaderStore)     private let service = "com.steipete.CodexBar"    private let account: String    private let promptKind: KeychainPromptContext.Kind     // Cache to reduce keychain access frequency    private nonisolated(unsafe) static var cache: [String: CachedValue] = [:]    private static let cacheLock = NSLock()    private static let cacheTTL: TimeInterval = 1800 // 30 minutes     private struct CachedValue {        let value: String?        let timestamp: Date         var isExpired: Bool {            Date().timeIntervalSince(self.timestamp) > KeychainCookieHeaderStore.cacheTTL        }    }     init(account: String, promptKind: KeychainPromptContext.Kind) {        self.account = account        self.promptKind = promptKind    }     func loadCookieHeader() throws -> String? {        guard !KeychainAccessGate.isDisabled else {            Self.log.debug("Keychain access disabled; skipping cookie load")            return nil        }        // Check cache first        Self.cacheLock.lock()        if let cached = Self.cache[self.account], !cached.isExpired {            Self.cacheLock.unlock()            Self.log.debug("Using cached cookie header for \(self.account)")            return cached.value        }        Self.cacheLock.unlock()        var result: CFTypeRef?        let query: [String: Any] = [            kSecClass as String: kSecClassGenericPassword,            kSecAttrService as String: self.service,            kSecAttrAccount as String: self.account,            kSecMatchLimit as String: kSecMatchLimitOne,            kSecReturnData as String: true,        ]         if case .interactionRequired = KeychainAccessPreflight            .checkGenericPassword(service: self.service, account: self.account)        {            KeychainPromptHandler.handler?(KeychainPromptContext(                kind: self.promptKind,                service: self.service,                account: self.account))        }         let status = KeychainSecurity.copyMatching(query as CFDictionary, &result)        if status == errSecItemNotFound {            // Cache the nil result            Self.cacheLock.lock()            Self.cache[self.account] = CachedValue(value: nil, timestamp: Date())            Self.cacheLock.unlock()            return nil        }        guard status == errSecSuccess else {            Self.log.error("Keychain read failed: \(status)")            throw CookieHeaderStoreError.keychainStatus(status)        }         guard let data = result as? Data else {            throw CookieHeaderStoreError.invalidData        }        let header = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines)        let finalValue = (header?.isEmpty == false) ? header : nil         // Cache the result        Self.cacheLock.lock()        Self.cache[self.account] = CachedValue(value: finalValue, timestamp: Date())        Self.cacheLock.unlock()         return finalValue    }     func storeCookieHeader(_ header: String?) throws {        guard !KeychainAccessGate.isDisabled else {            Self.log.debug("Keychain access disabled; skipping cookie store")            return        }        guard let raw = header?.trimmingCharacters(in: .whitespacesAndNewlines),              !raw.isEmpty        else {            try self.deleteIfPresent()            // Invalidate cache            Self.cacheLock.lock()            Self.cache.removeValue(forKey: self.account)            Self.cacheLock.unlock()            return        }        guard CookieHeaderNormalizer.normalize(raw) != nil else {            try self.deleteIfPresent()            // Invalidate cache            Self.cacheLock.lock()            Self.cache.removeValue(forKey: self.account)            Self.cacheLock.unlock()            return        }         let data = raw.data(using: .utf8)!        let query: [String: Any] = [            kSecClass as String: kSecClassGenericPassword,            kSecAttrService as String: self.service,            kSecAttrAccount as String: self.account,        ]        let attributes: [String: Any] = [            kSecValueData as String: data,            kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly,        ]         let updateStatus = KeychainSecurity.update(query as CFDictionary, attributes as CFDictionary)        if updateStatus == errSecSuccess {            // Update cache            Self.cacheLock.lock()            Self.cache[self.account] = CachedValue(value: raw, timestamp: Date())            Self.cacheLock.unlock()            return        }        if updateStatus != errSecItemNotFound {            Self.log.error("Keychain update failed: \(updateStatus)")            throw CookieHeaderStoreError.keychainStatus(updateStatus)        }         var addQuery = query        for (key, value) in attributes {            addQuery[key] = value        }        let addStatus = KeychainSecurity.add(addQuery as CFDictionary, nil)        guard addStatus == errSecSuccess else {            Self.log.error("Keychain add failed: \(addStatus)")            throw CookieHeaderStoreError.keychainStatus(addStatus)        }         // Update cache        Self.cacheLock.lock()        Self.cache[self.account] = CachedValue(value: raw, timestamp: Date())        Self.cacheLock.unlock()    }     private func deleteIfPresent() throws {        guard !KeychainAccessGate.isDisabled else { return }        let query: [String: Any] = [            kSecClass as String: kSecClassGenericPassword,            kSecAttrService as String: self.service,            kSecAttrAccount as String: self.account,        ]        let status = KeychainSecurity.delete(query as CFDictionary)        if status == errSecSuccess || status == errSecItemNotFound {            // Invalidate cache            Self.cacheLock.lock()            Self.cache.removeValue(forKey: self.account)            Self.cacheLock.unlock()            return        }        Self.log.error("Keychain delete failed: \(status)")        throw CookieHeaderStoreError.keychainStatus(status)    }}