git.iko.soy/parseable-macos
on master help sign in
Refactor KeychainService to use data protection keychain (#40)

* Use data-protection keychain to avoid password prompts on new releases Ad-hoc code signing (CODE_SIGN_IDENTITY: "-") gives each build a unique signature. The file-based macOS keychain ties items to the signing identity via ACLs, so every new release triggers a system prompt asking the user to allow keychain access. Switch to the data-protection keychain (kSecUseDataProtectionKeychain) which scopes items by bundle identifier instead of code-signing identity. Since the bundle ID stays constant across builds, password access no longer requires re-authorization. Includes one-time migration: on first load after the update, passwords are read from the legacy file-based keychain and moved to the data-protection keychain automatically. https://claude.ai/code/session_01K8vxNWKENi6LJVj6rr1k8S * Remove legacy keychain migration Users will re-enter passwords once after updating. https://claude.ai/code/session_01K8vxNWKENi6LJVj6rr1k8S --------- Co-authored-by: Claude <noreply@anthropic.com>

commit5f1c7be20a8dda64d06ba72157d2bd37dc86f1e7 authoriko <mail@iko.soy> dateMon 16 Feb 2026, 01:55 (8 months ago) parent6694446
1 file changed+18−18
ParseableViewer/Services/KeychainService.swift+18−18
ParseableViewer/Services/KeychainService.swift+18−18
@@ -12,12 +12,8 @@ enum KeychainService {
1212 deletePassword(for: connectionID)
1313
1414 guard let data = password.data(using: .utf8) else { return false }
15− let query: [String: Any] = [
16− kSecClass as String: kSecClassGenericPassword,
17− kSecAttrService as String: service,
18− kSecAttrAccount as String: account,
19− kSecValueData as String: data
20− ]
15+ var query = baseQuery(for: connectionID)
16+ query[kSecValueData as String] = data
2117 let status = SecItemAdd(query as CFDictionary, nil)
2218 if status != errSecSuccess {
2319 logger.error("Failed to save password for \(account): OSStatus \(status)")
@@ -27,13 +23,9 @@ enum KeychainService {
2723
2824 static func loadPassword(for connectionID: UUID) -> String? {
2925 let account = connectionID.uuidString
30− let query: [String: Any] = [
31− kSecClass as String: kSecClassGenericPassword,
32− kSecAttrService as String: service,
33− kSecAttrAccount as String: account,
34− kSecReturnData as String: true,
35− kSecMatchLimit as String: kSecMatchLimitOne
36− ]
26+ var query = baseQuery(for: connectionID)
27+ query[kSecReturnData as String] = true
28+ query[kSecMatchLimit as String] = kSecMatchLimitOne
3729
3830 var result: AnyObject?
3931 let status = SecItemCopyMatching(query as CFDictionary, &result)
@@ -47,15 +39,23 @@ enum KeychainService {
4739 @discardableResult
4840 static func deletePassword(for connectionID: UUID) -> Bool {
4941 let account = connectionID.uuidString
50− let query: [String: Any] = [
51− kSecClass as String: kSecClassGenericPassword,
52− kSecAttrService as String: service,
53− kSecAttrAccount as String: account
54− ]
42+ let query = baseQuery(for: connectionID)
5543 let status = SecItemDelete(query as CFDictionary)
5644 if status != errSecSuccess && status != errSecItemNotFound {
5745 logger.error("Failed to delete password for \(account): OSStatus \(status)")
5846 }
5947 return status == errSecSuccess || status == errSecItemNotFound
6048 }
49+
50+ /// Base query targeting the data-protection keychain.
51+ /// Items stored here are scoped by bundle identifier rather than by
52+ /// code-signing identity, so they survive ad-hoc re-signing across builds.
53+ private static func baseQuery(for connectionID: UUID) -> [String: Any] {
54+ [
55+ kSecClass as String: kSecClassGenericPassword,
56+ kSecAttrService as String: service,
57+ kSecAttrAccount as String: connectionID.uuidString,
58+ kSecUseDataProtectionKeychain as String: true
59+ ]
60+ }
6161}