bugger/Sources/Utils/KeychainHelper.swift

112 lines
4.2 KiB
Swift

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
}
}