c881bbefa0
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>
845 lines
30 KiB
Swift
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)
|
|
}
|
|
}
|