112 lines
4.2 KiB
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
|
|
}
|
|
}
|