Files
steipete--codexbar/Sources/CodexBar/CookieHeaderStore.swift
T
2026-07-13 12:22:33 +08:00

191 lines
6.7 KiB
Swift

import CodexBarCore
import Foundation
import 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)
}
}