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

666 lines
23 KiB
Swift

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<Entry> {
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<Entry: Codable>(
key: Key,
as type: Entry.Type = Entry.self) -> LoadResult<Entry>
{
#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<T>(
_ service: String?,
operation: () throws -> T) rethrows -> T
{
try self.$serviceOverride.withValue(service) {
try operation()
}
}
public static func withServiceOverrideForTesting<T>(
_ service: String?,
operation: () async throws -> T) async rethrows -> T
{
try await self.$serviceOverride.withValue(service) {
try await operation()
}
}
public static func withCurrentServiceOverrideForTesting<T>(
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<T>(
_ recorder: OperationRecorder?,
operation: () throws -> T) rethrows -> T
{
try self.$operationRecorder.withValue(recorder) {
try operation()
}
}
static func withOperationRecorderForTesting<T>(
_ 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<T>(
_ status: OSStatus?,
operation: () throws -> T) rethrows -> T
{
try self.$loadFailureStatusOverride.withValue(status) {
try operation()
}
}
public static func withStoreFailureStatusOverrideForTesting<T>(
_ status: OSStatus?,
operation: () throws -> T) rethrows -> T
{
try self.$storeFailureStatusOverride.withValue(status) {
try operation()
}
}
public static func withClearFailureStatusOverrideForTesting<T>(
_ status: OSStatus?,
operation: () throws -> T) rethrows -> T
{
try self.$clearFailureStatusOverride.withValue(status) {
try operation()
}
}
public static func withClearFailureStatusOverrideForTesting<T>(
_ status: OSStatus?,
operation: () async throws -> T) async rethrows -> T
{
try await self.$clearFailureStatusOverride.withValue(status) {
try await operation()
}
}
public static func withKeysFailureStatusOverrideForTesting<T>(
_ 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<Entry>(
status: OSStatus,
key: Key) -> LoadResult<Entry>
{
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<CChar>?,
UnsafeMutablePointer<SecTrustedApplication?>?) -> OSStatus
private typealias SecAccessCreateFunction = @convention(c) (
CFString,
CFArray,
UnsafeMutablePointer<SecAccess?>?) -> 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<Entry: Codable>(
key: Key,
as type: Entry.Type) -> LoadResult<Entry>?
{
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)
}
}