import Foundation import Security final class KeychainHelper { static let shared = KeychainHelper() private let service = "com.xorbitlab.bugger" private init() {} func write(_ value: String, forKey key: String) { let data = Data(value.utf8) delete(key) var query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service, kSecAttrAccount as String: key, kSecValueData as String: data, kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock ] if let access = accessForAnyApplication() { query[kSecAttrAccess as String] = access } let status = SecItemAdd(query as CFDictionary, nil) if status != errSecSuccess { BuggerLog.error("Keychain write \(key) failed (\(status))") } } /// Never shows UI. A prompting read from a background thread blocks forever /// without a dialog when the app is a menu-bar item. func read(_ key: String) -> String? { let result = copyMatching(key, prompt: false) switch result.status { case errSecSuccess, errSecItemNotFound: return result.value case errSecInteractionNotAllowed, errSecAuthFailed: return nil default: BuggerLog.error("Keychain read \(key) failed (\(result.status))") return nil } } func needsApproval(_ key: String) -> Bool { let status = copyMatching(key, prompt: false).status return status == errSecInteractionNotAllowed || status == errSecAuthFailed } /// Call on the main thread, after the app is a regular foreground app, /// so the Allow dialog is visible. func readPrompting(_ key: String) -> String? { copyMatching(key, prompt: true).value } func delete(_ key: String) { let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service, kSecAttrAccount as String: key ] SecItemDelete(query as CFDictionary) } private func copyMatching(_ key: String, prompt: Bool) -> (value: String?, status: OSStatus) { // These items live in the legacy login keychain. kSecUseAuthenticationUI // is ignored there, and a background read waits forever on a dialog // that a menu-bar app never shows. SecKeychainSetUserInteractionAllowed(prompt) var query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service, kSecAttrAccount as String: key, kSecReturnData as String: true, kSecMatchLimit as String: kSecMatchLimitOne ] if !prompt { query[kSecUseAuthenticationUI as String] = kSecUseAuthenticationUIFail } var result: AnyObject? let status = SecItemCopyMatching(query as CFDictionary, &result) guard status == errSecSuccess, let data = result as? Data else { return (nil, status) } return (String(data: data, encoding: .utf8), status) } /// Each ad-hoc build has a new code signature. The default keychain ACL /// then blocks in a permission dialog, and launch never reaches the fetch. /// Trust every application on this Mac so the next build can still read /// the Feishu tokens. private func accessForAnyApplication() -> SecAccess? { var access: SecAccess? guard SecAccessCreate("Bugger" as CFString, nil, &access) == errSecSuccess, let access else { return nil } var list: CFArray? guard SecAccessCopyACLList(access, &list) == errSecSuccess, let acls = list as? [SecACL] else { return access } for acl in acls { var applications: CFArray? var description: CFString? var prompt = SecKeychainPromptSelector() guard SecACLCopyContents(acl, &applications, &description, &prompt) == errSecSuccess else { continue } let label = description ?? "Bugger" as CFString SecACLSetContents(acl, nil, label, []) } return access } }