codeburn/mac/Sources/CodeBurnMenubar/Security/KeychainCredentialCache.swift
iamtoruk e213e192b4 fix(menubar): never let a Keychain read raise UI on the refresh timer
Cache reads run on the background quota timer, so they must not be able to
put a panel on screen. Measured on macOS 15 against a throwaway keychain:
with the keychain locked, SecItemCopyMatching blocks on an unlock panel even
when the query carries kSecUseAuthenticationUI: ...Fail or a non-interactive
LAContext. Both of those govern the data-protection keychain; unlocking a
file-based keychain is something securityd drives itself. The only reliable
suppression is not issuing the read, so check lock state first and report
.unavailable instead.

.unavailable is separate from readFailed on purpose: a locked keychain means
"cannot look right now", not "the item is gone", and callers must not turn it
into a disconnect. It also carries a readable errorDescription so a -25308
reaching the UI reads as "Keychain unavailable" rather than a struct dump.

SecKeychainGetStatus is soft-deprecated with no replacement that reports
file-keychain lock state; annotating the warning away only moves it to the
call site, so it is left visible with a comment.

Adds the first test that touches a real Keychain, against a throwaway service
name no build reads, skipped when the host has no usable Keychain.
2026-08-19 11:43:22 -07:00

248 lines
11 KiB
Swift

import Foundation
import LocalAuthentication
import Security
/// Serializes credential-store test harnesses that mutate process-wide seams.
enum CredentialStoreTestIsolation {
static let lock = NSLock()
}
/// Narrow CodeBurn-owned Keychain cache over exact service/account pairs.
///
/// Production uses `LiveKeychainCredentialCache`. Tests inject
/// `InMemoryKeychainCredentialCache` so the suite never touches the login
/// Keychain. Errors carry only operation, service, and OSStatus never blob data.
protocol KeychainCredentialCaching: Sendable {
func read(service: String, account: String) throws -> Data?
func upsert(service: String, account: String, data: Data) throws
func delete(service: String, account: String) throws
}
enum KeychainCredentialCacheError: Error, LocalizedError, Equatable {
case readFailed(service: String, status: OSStatus)
case writeFailed(service: String, status: OSStatus)
case deleteFailed(service: String, status: OSStatus)
/// Keychain is locked or consent was refused. Transient callers keep the
/// last known token and must not treat it as "the item is gone".
case unavailable(service: String, status: OSStatus)
var errorDescription: String? {
switch self {
case let .readFailed(service, status):
return "Keychain read failed for \(service) (status \(status))."
case let .writeFailed(service, status):
return "Keychain write failed for \(service) (status \(status))."
case let .deleteFailed(service, status):
return "Keychain delete failed for \(service) (status \(status))."
case .unavailable:
return "Keychain unavailable — unlock your login keychain to refresh quota."
}
}
/// Statuses that mean "we were not allowed to look right now", as opposed to
/// "the item does not exist". `errSecInteractionNotAllowed` (-25308) is what
/// a locked keychain returns once UI is suppressed.
static func isUnavailable(_ status: OSStatus) -> Bool {
status == errSecInteractionNotAllowed
|| status == errSecAuthFailed
|| status == errSecUserCanceled
|| status == errSecInteractionRequired
}
}
/// Published CodeBurn Keychain identities. Keep these exact Electron contracts
/// on the Codex pair (`app/electron/quota/codex.ts`), and installs going back to
/// May 2026 already hold items under these names.
///
/// Deliberately NOT derived from `CFBundleIdentifier`: the Electron app hardcodes
/// the same strings, so a per-bundle suffix would break that contract. The
/// tradeoff is that a dev/beta build sharing this source shares the item patch
/// these constants when running a second build alongside the release.
enum CodeBurnKeychainIdentity {
static let claudeService = "org.agentseal.codeburn.menubar.claude.oauth.v1"
static let codexService = "org.agentseal.codeburn.menubar.codex.oauth.v1"
static let account = "default"
}
struct LiveKeychainCredentialCache: KeychainCredentialCaching {
/// True when the default (login) keychain exists and is currently locked.
/// Returns false when the state cannot be determined, so an unexpected
/// failure degrades to "just try the read" rather than a hard outage.
///
/// `SecKeychain*` is soft-deprecated with no replacement that reports
/// file-keychain lock state `kSecUseDataProtectionKeychain` would move our
/// item to a different store and orphan every existing install. The
/// This is the one intentional deprecation warning in the file; annotating it
/// away only moves the warning to the call site, so it is left visible.
private func isDefaultKeychainLocked() -> Bool {
var status: SecKeychainStatus = 0
guard SecKeychainGetStatus(nil, &status) == errSecSuccess else { return false }
return (status & SecKeychainStatus(kSecUnlockStateStatus)) == 0
}
func read(service: String, account: String) throws -> Data? {
// Reads happen on the background refresh timer, so they must never be
// able to raise UI. Measured on macOS 15 against a locked test keychain:
// NEITHER `kSecUseAuthenticationUI: Fail` NOR
// `LAContext.interactionNotAllowed` suppresses the unlock panel for a
// file-based keychain both govern the data-protection keychain, while
// unlocking is a keychain-level operation securityd drives itself. The
// only thing that reliably avoids the panel is not issuing the read at
// all, so check lock state first. Same class of bug as the
// partition-list re-prompt in #490.
if isDefaultKeychainLocked() {
throw KeychainCredentialCacheError.unavailable(
service: service, status: errSecInteractionNotAllowed)
}
// Still pass a non-interactive context: it is the supported way to keep
// a data-protection-backed item from raising biometric/passcode UI.
let context = LAContext()
context.interactionNotAllowed = true
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
kSecMatchLimit as String: kSecMatchLimitOne,
kSecReturnData as String: true,
kSecUseAuthenticationContext as String: context,
]
var result: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &result)
if status == errSecItemNotFound { return nil }
if KeychainCredentialCacheError.isUnavailable(status) {
throw KeychainCredentialCacheError.unavailable(service: service, status: status)
}
guard status == errSecSuccess, let data = result as? Data else {
throw KeychainCredentialCacheError.readFailed(service: service, status: status)
}
return data
}
func upsert(service: String, account: String, data: Data) throws {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
]
let attributes: [String: Any] = [
kSecValueData as String: data,
]
let updateStatus = SecItemUpdate(query as CFDictionary, attributes as CFDictionary)
if updateStatus == errSecSuccess { return }
if updateStatus == errSecItemNotFound {
var add = query
add[kSecValueData as String] = data
let addStatus = SecItemAdd(add as CFDictionary, nil)
if addStatus == errSecSuccess { return }
if addStatus == errSecDuplicateItem {
let retry = SecItemUpdate(query as CFDictionary, attributes as CFDictionary)
guard retry == errSecSuccess else {
throw KeychainCredentialCacheError.writeFailed(service: service, status: retry)
}
return
}
throw KeychainCredentialCacheError.writeFailed(service: service, status: addStatus)
}
throw KeychainCredentialCacheError.writeFailed(service: service, status: updateStatus)
}
func delete(service: String, account: String) throws {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
]
let status = SecItemDelete(query as CFDictionary)
if status == errSecSuccess || status == errSecItemNotFound { return }
throw KeychainCredentialCacheError.deleteFailed(service: service, status: status)
}
}
/// Process-local fake for tests. Never writes to the system Keychain.
final class InMemoryKeychainCredentialCache: KeychainCredentialCaching, @unchecked Sendable {
private let lock = NSLock()
private var items: [String: Data] = [:]
private(set) var upsertCount = 0
private(set) var readCount = 0
private(set) var deleteCount = 0
private func key(_ service: String, _ account: String) -> String {
"\(service)\u{1f}\(account)"
}
func read(service: String, account: String) throws -> Data? {
lock.lock(); defer { lock.unlock() }
readCount += 1
return items[key(service, account)]
}
func upsert(service: String, account: String, data: Data) throws {
lock.lock(); defer { lock.unlock() }
upsertCount += 1
items[key(service, account)] = data
}
func delete(service: String, account: String) throws {
lock.lock(); defer { lock.unlock() }
deleteCount += 1
items.removeValue(forKey: key(service, account))
}
func storedJSONObject(service: String, account: String) -> [String: Any]? {
lock.lock(); defer { lock.unlock() }
guard let data = items[key(service, account)] else { return nil }
return (try? JSONSerialization.jsonObject(with: data)) as? [String: Any]
}
func storedKeys(service: String, account: String) -> [String]? {
storedJSONObject(service: service, account: account).map { Array($0.keys).sorted() }
}
/// Snapshot for simulated process restart: keep Keychain bytes, drop nothing else.
func cloneStorage() -> InMemoryKeychainCredentialCache {
lock.lock(); defer { lock.unlock() }
let copy = InMemoryKeychainCredentialCache()
copy.items = items
return copy
}
}
/// Test double that wraps another backend and can force upsert/read/delete failures.
final class ControllableKeychainCredentialCache: KeychainCredentialCaching, @unchecked Sendable {
private let inner: any KeychainCredentialCaching
var failUpsert = false
var failRead = false
var failDelete = false
var upsertStatus: OSStatus = -1
var readStatus: OSStatus = -1
var deleteStatus: OSStatus = -1
init(inner: any KeychainCredentialCaching) {
self.inner = inner
}
func read(service: String, account: String) throws -> Data? {
if failRead {
// Mirror the live adapter's mapping so tests exercise the same branch.
if KeychainCredentialCacheError.isUnavailable(readStatus) {
throw KeychainCredentialCacheError.unavailable(service: service, status: readStatus)
}
throw KeychainCredentialCacheError.readFailed(service: service, status: readStatus)
}
return try inner.read(service: service, account: account)
}
func upsert(service: String, account: String, data: Data) throws {
if failUpsert {
throw KeychainCredentialCacheError.writeFailed(service: service, status: upsertStatus)
}
try inner.upsert(service: service, account: account, data: data)
}
func delete(service: String, account: String) throws {
if failDelete {
throw KeychainCredentialCacheError.deleteFailed(service: service, status: deleteStatus)
}
try inner.delete(service: service, account: account)
}
}