Files
steipete--codexbar/Sources/CodexBarCore/KeychainCacheStore.swift
T
Peter Steinberger c881bbefa0 Stop CodexBar Cache keychain prompts from dev and test tooling
Audit findings behind the recurring 'CodexBar wants to use your
confidential information stored in CodexBar Cache' dialogs:

- The cache item is a legacy file-keychain item whose trusted-application
  ACL freezes at creation. A 'swift build' binary with no .app ancestor
  could create it trusting only its own ephemeral unsigned path, after
  which the packaged app prompts on every background cookie refresh.
  Unbundled processes now use a process-local in-memory cache and the ACL
  builder refuses bare dev binaries outright.
- Test-process self-detection is name/env based and cannot cover spawned
  CLI child binaries. When a process decides keychain access is blocked it
  now exports CODEXBAR_SUPPRESS_TEST_KEYCHAIN_ACCESS for children and
  disables legacy keychain interaction process-wide
  (SecKeychainSetUserInteractionAllowed(false)), the documented switch for
  the ACL dialog the per-query no-UI attributes cannot always prevent.
- make test-tty ran swift test without the suppression flag; make
  test-live now opts into keychain access explicitly.
- Test spawn sites for CLI binaries pass the suppression flag explicitly,
  and the prompt-safety audit test now also flags dlsym-resolved Security
  symbols so runtime-resolved calls cannot bypass the grep.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-01 15:51:02 -07:00

845 lines
30 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"
@TaskLocal private static var serviceOverride: String?
@TaskLocal private static var forceImplicitTestStore = false
#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),
!self.prefersDisabledAccessMemoryStoreOverTestStore
{
return testResult
}
if self.shouldUseDisabledAccessMemoryStore(for: key.category) {
return self.loadFromDisabledAccessMemory(key: key, as: type)
}
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 !self.prefersDisabledAccessMemoryStoreOverTestStore,
let stored = self.storeInTestStore(key: key, entry: entry)
{
return stored
}
if self.shouldUseDisabledAccessMemoryStore(for: key.category) {
return self.storeInDisabledAccessMemory(key: key, entry: entry)
}
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 !self.prefersDisabledAccessMemoryStoreOverTestStore,
let removed = self.clearTestStore(key: key)
{
return removed ? .removed : .missing
}
if self.shouldUseDisabledAccessMemoryStore(for: key.category) {
return self.clearDisabledAccessMemory(key: key) ? .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 !self.prefersDisabledAccessMemoryStoreOverTestStore,
let keys = self.keysFromTestStore(category: category)
{
return .found(keys)
}
if self.shouldUseDisabledAccessMemoryStore(for: category) {
return .found(self.keysFromDisabledAccessMemory(category: category))
}
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
}
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?,
isolation _: isolated (any Actor)? = #isolation,
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()
}
}
static func withImplicitTestStoreForTesting<T>(
operation: () throws -> T) rethrows -> T
{
try self.$forceImplicitTestStore.withValue(true) {
try operation()
}
}
static func withImplicitTestStoreForTesting<T>(
isolation _: isolated (any Actor)? = #isolation,
operation: () async throws -> T) async rethrows -> T
{
try await self.$forceImplicitTestStore.withValue(true) {
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.cacheService
}
private static var canUseRealKeychain: Bool {
!KeychainAccessGate.isDisabled
}
/// When the user disables Keychain access, keep an in-process cache so cookie/session
/// reconciliation can still succeed without treating every refresh as a session change.
/// Unit tests keep using the isolated test stores instead, unless a test explicitly opts in.
private static func shouldUseDisabledAccessMemoryStore(for category: String) -> Bool {
#if DEBUG
if self.disabledAccessMemoryStoreEnabledForTesting == true {
return category == "cookie"
}
if KeychainTestSafety.isRunningUnderTests(
processName: ProcessInfo.processInfo.processName,
environment: ProcessInfo.processInfo.environment)
{
return false
}
#endif
// Unbundled processes (no .app ancestor: `swift build` binaries, dev CLI
// runs) must never touch the shared cache item. Creating it would freeze
// a trusted-application ACL onto an ephemeral unsigned binary — after
// which the real app prompts forever — and reading someone else's item
// raises the login-keychain password dialog. They get a process-local
// in-memory cache instead.
if self.isUnbundledProcess {
return true
}
guard category == "cookie" else { return false }
return KeychainAccessGate.isExplicitlyDisabled
}
/// True when the running executable has no `.app` bundle ancestor.
static let isUnbundledProcess: Bool = {
if Self.appBundleURL(containing: Bundle.main.bundleURL) != nil {
return false
}
if let executableURL = Bundle.main.executableURL,
Self.appBundleURL(containing: executableURL) != nil
{
return false
}
return true
}()
#if DEBUG
@TaskLocal private static var disabledAccessMemoryStoreEnabledForTesting: Bool?
static func withDisabledAccessMemoryStoreForTesting<T>(
_ enabled: Bool,
operation: () throws -> T) rethrows -> T
{
try self.$disabledAccessMemoryStoreEnabledForTesting.withValue(enabled) {
try operation()
}
}
static func withDisabledAccessMemoryStoreForTesting<T>(
_ enabled: Bool,
isolation _: isolated (any Actor)? = #isolation,
operation: () async throws -> T) async rethrows -> T
{
try await self.$disabledAccessMemoryStoreEnabledForTesting.withValue(enabled) {
try await operation()
}
}
static func resetDisabledAccessMemoryStoreForTesting() {
self.clearDisabledAccessMemoryStore()
}
#endif
/// Drops the in-process fallback used while Keychain access is explicitly disabled.
static func clearDisabledAccessMemoryStore() {
self.disabledAccessMemoryLock.lock()
self.disabledAccessMemoryStore.removeAll()
self.disabledAccessMemoryLock.unlock()
}
private static let disabledAccessMemoryLock = NSLock()
private nonisolated(unsafe) static var disabledAccessMemoryStore: [TestStoreKey: Data] = [:]
private static var prefersDisabledAccessMemoryStoreOverTestStore: Bool {
#if DEBUG
self.disabledAccessMemoryStoreEnabledForTesting == true
#else
false
#endif
}
#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)
}
// No .app ancestor means an ephemeral dev binary; trusting its bare path
// would freeze a broken ACL onto the shared item (the packaged app would
// then prompt on every read). Refuse the ACL entirely in that case —
// unbundled processes use the in-memory store and never reach this path
// in practice.
guard let appBundle = self.appBundleURL(containing: bundleURL)
?? executableURL.flatMap(self.appBundleURL(containing:))
else { return [] }
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.forceImplicitTestStore
? self.implicitTestStore
: 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 self.forceImplicitTestStore {
self.implicitTestStore[testKey] = data
return true
}
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 self.forceImplicitTestStore {
return self.implicitTestStore.removeValue(forKey: testKey) != nil
}
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.forceImplicitTestStore
? self.implicitTestStore
: 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 loadFromDisabledAccessMemory<Entry: Codable>(
key: Key,
as type: Entry.Type) -> LoadResult<Entry>
{
self.disabledAccessMemoryLock.lock()
defer { self.disabledAccessMemoryLock.unlock() }
let memoryKey = TestStoreKey(service: self.serviceName, account: key.account)
guard let data = self.disabledAccessMemoryStore[memoryKey] 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 storeInDisabledAccessMemory(key: Key, entry: some Codable) -> Bool {
let encoder = Self.makeEncoder()
guard let data = try? encoder.encode(entry) else { return false }
self.disabledAccessMemoryLock.lock()
defer { self.disabledAccessMemoryLock.unlock() }
let memoryKey = TestStoreKey(service: self.serviceName, account: key.account)
self.disabledAccessMemoryStore[memoryKey] = data
self.log.debug("Keychain cache stored in memory (Keychain access disabled)", metadata: [
"account": key.account,
])
return true
}
private static func clearDisabledAccessMemory(key: Key) -> Bool {
self.disabledAccessMemoryLock.lock()
defer { self.disabledAccessMemoryLock.unlock() }
let memoryKey = TestStoreKey(service: self.serviceName, account: key.account)
return self.disabledAccessMemoryStore.removeValue(forKey: memoryKey) != nil
}
private static func keysFromDisabledAccessMemory(category: String) -> [Key] {
self.disabledAccessMemoryLock.lock()
defer { self.disabledAccessMemoryLock.unlock() }
return self.disabledAccessMemoryStore.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)
}
}