197 lines
6.3 KiB
Swift
197 lines
6.3 KiB
Swift
#if os(macOS)
|
|
import LocalAuthentication
|
|
import Security
|
|
#endif
|
|
|
|
public struct KeychainPromptContext: Sendable {
|
|
public enum Kind: Sendable {
|
|
case claudeOAuth
|
|
case codexCookie
|
|
case claudeCookie
|
|
case cursorCookie
|
|
case opencodeCookie
|
|
case factoryCookie
|
|
case zaiToken
|
|
case syntheticToken
|
|
case copilotToken
|
|
case kimiToken
|
|
case kimiK2Token
|
|
case minimaxCookie
|
|
case minimaxToken
|
|
case augmentCookie
|
|
case ampCookie
|
|
}
|
|
|
|
public let kind: Kind
|
|
public let service: String
|
|
public let account: String?
|
|
|
|
public init(kind: Kind, service: String, account: String?) {
|
|
self.kind = kind
|
|
self.service = service
|
|
self.account = account
|
|
}
|
|
}
|
|
|
|
public enum KeychainPromptHandler {
|
|
final class HandlerStore: @unchecked Sendable {
|
|
let handler: (KeychainPromptContext) -> Void
|
|
|
|
init(handler: @escaping (KeychainPromptContext) -> Void) {
|
|
self.handler = handler
|
|
}
|
|
}
|
|
|
|
@TaskLocal private static var taskHandlerStore: HandlerStore?
|
|
public nonisolated(unsafe) static var handler: ((KeychainPromptContext) -> Void)?
|
|
|
|
public static func notify(_ context: KeychainPromptContext) {
|
|
_ = self.notifyIfHandled(context)
|
|
}
|
|
|
|
@discardableResult
|
|
static func notifyIfHandled(_ context: KeychainPromptContext) -> Bool {
|
|
if let taskHandlerStore {
|
|
taskHandlerStore.handler(context)
|
|
return true
|
|
}
|
|
guard let handler else { return false }
|
|
handler(context)
|
|
return true
|
|
}
|
|
|
|
#if DEBUG
|
|
static func withHandlerForTesting<T>(
|
|
_ handler: ((KeychainPromptContext) -> Void)?,
|
|
operation: () throws -> T) rethrows -> T
|
|
{
|
|
try self.$taskHandlerStore.withValue(handler.map(HandlerStore.init(handler:))) {
|
|
try operation()
|
|
}
|
|
}
|
|
|
|
static func withHandlerForTesting<T>(
|
|
_ handler: ((KeychainPromptContext) -> Void)?,
|
|
operation: () async throws -> T) async rethrows -> T
|
|
{
|
|
try await self.$taskHandlerStore.withValue(handler.map(HandlerStore.init(handler:))) {
|
|
try await operation()
|
|
}
|
|
}
|
|
#endif
|
|
}
|
|
|
|
public enum KeychainAccessPreflight {
|
|
public enum Outcome: Sendable {
|
|
case allowed
|
|
case interactionRequired
|
|
case notFound
|
|
case failure(Int)
|
|
}
|
|
|
|
private static let log = CodexBarLog.logger(LogCategories.keychainPreflight)
|
|
|
|
#if DEBUG
|
|
final class CheckGenericPasswordOverrideStore: @unchecked Sendable {
|
|
let check: (String, String?) -> Outcome
|
|
|
|
init(check: @escaping (String, String?) -> Outcome) {
|
|
self.check = check
|
|
}
|
|
}
|
|
|
|
@TaskLocal private static var taskCheckGenericPasswordOverrideStore: CheckGenericPasswordOverrideStore?
|
|
private nonisolated(unsafe) static var checkGenericPasswordOverride: ((String, String?) -> Outcome)?
|
|
|
|
static func setCheckGenericPasswordOverrideForTesting(_ override: ((String, String?) -> Outcome)?) {
|
|
self.checkGenericPasswordOverride = override
|
|
}
|
|
|
|
static var hasCheckGenericPasswordOverrideForTesting: Bool {
|
|
self.taskCheckGenericPasswordOverrideStore != nil || self.checkGenericPasswordOverride != nil
|
|
}
|
|
|
|
static func withCheckGenericPasswordOverrideForTesting<T>(
|
|
_ override: ((String, String?) -> Outcome)?,
|
|
operation: () throws -> T) rethrows -> T
|
|
{
|
|
try self.$taskCheckGenericPasswordOverrideStore.withValue(
|
|
override.map(CheckGenericPasswordOverrideStore.init(check:)))
|
|
{
|
|
try operation()
|
|
}
|
|
}
|
|
|
|
static func withCheckGenericPasswordOverrideForTesting<T>(
|
|
_ override: ((String, String?) -> Outcome)?,
|
|
isolation _: isolated (any Actor)? = #isolation,
|
|
operation: () async throws -> T) async rethrows -> T
|
|
{
|
|
try await self.$taskCheckGenericPasswordOverrideStore.withValue(
|
|
override.map(CheckGenericPasswordOverrideStore.init(check:)))
|
|
{
|
|
try await operation()
|
|
}
|
|
}
|
|
#endif
|
|
|
|
public static func checkGenericPassword(service: String, account: String?) -> Outcome {
|
|
#if os(macOS)
|
|
#if DEBUG
|
|
if let override = self.taskCheckGenericPasswordOverrideStore {
|
|
return override.check(service, account)
|
|
}
|
|
if let override = self.checkGenericPasswordOverride {
|
|
return override(service, account)
|
|
}
|
|
#endif
|
|
guard !KeychainAccessGate.isDisabled else { return .notFound }
|
|
let query = self.makeGenericPasswordPreflightQuery(service: service, account: account)
|
|
|
|
var result: AnyObject?
|
|
let status = KeychainSecurity.copyMatching(query as CFDictionary, &result)
|
|
switch status {
|
|
case errSecSuccess:
|
|
self.log.debug("Keychain preflight allowed", metadata: ["service": service])
|
|
return .allowed
|
|
case errSecItemNotFound:
|
|
self.log.debug(
|
|
"Keychain preflight not found",
|
|
metadata: ["service": service])
|
|
return .notFound
|
|
case errSecInteractionNotAllowed:
|
|
self.log.info(
|
|
"Keychain preflight requires interaction",
|
|
metadata: ["service": service])
|
|
return .interactionRequired
|
|
default:
|
|
self.log.warning(
|
|
"Keychain preflight failed",
|
|
metadata: ["service": service, "status": "\(status)"])
|
|
return .failure(Int(status))
|
|
}
|
|
#else
|
|
return .notFound
|
|
#endif
|
|
}
|
|
|
|
#if os(macOS)
|
|
static func makeGenericPasswordPreflightQuery(service: String, account: String?) -> [String: Any] {
|
|
var query: [String: Any] = [
|
|
kSecClass as String: kSecClassGenericPassword,
|
|
kSecAttrService as String: service,
|
|
kSecMatchLimit as String: kSecMatchLimitOne,
|
|
// Preflight should never trigger UI. Avoid requesting the secret payload (`kSecReturnData`) because
|
|
// some macOS configurations have been observed to show the legacy keychain prompt unless the query
|
|
// is strictly non-interactive.
|
|
kSecReturnAttributes as String: true,
|
|
]
|
|
KeychainNoUIQuery.apply(to: &query)
|
|
if let account {
|
|
query[kSecAttrAccount as String] = account
|
|
}
|
|
return query
|
|
}
|
|
#endif
|
|
}
|