@@ -12,12 +12,8 @@ enum KeychainService {
12 deletePassword(for: connectionID)
12 deletePassword(for: connectionID)
14 guard let data = password.data(using: .utf8) else { return false }
14 guard let data = password.data(using: .utf8) else { return false }
15− let query: [String: Any] = [
15+ var query = baseQuery(for: connectionID)
16− kSecClass as String: kSecClassGenericPassword,
17− kSecAttrService as String: service,
18− kSecAttrAccount as String: account,
19− kSecValueData as String: data
16+ query[kSecValueData as String] = data
21 let status = SecItemAdd(query as CFDictionary, nil)
17 let status = SecItemAdd(query as CFDictionary, nil)
22 if status != errSecSuccess {
18 if status != errSecSuccess {
23 logger.error("Failed to save password for \(account): OSStatus \(status)")
19 logger.error("Failed to save password for \(account): OSStatus \(status)")
@@ -27,13 +23,9 @@ enum KeychainService {
28 static func loadPassword(for connectionID: UUID) -> String? {
24 static func loadPassword(for connectionID: UUID) -> String? {
29 let account = connectionID.uuidString
25 let account = connectionID.uuidString
30− let query: [String: Any] = [
26+ var query = baseQuery(for: connectionID)
31− kSecClass as String: kSecClassGenericPassword,
32− kSecAttrService as String: service,
33− kSecAttrAccount as String: account,
34− kSecReturnData as String: true,
27+ query[kSecReturnData as String] = true
35− kSecMatchLimit as String: kSecMatchLimitOne
28+ query[kSecMatchLimit as String] = kSecMatchLimitOne
38 var result: AnyObject?
30 var result: AnyObject?
39 let status = SecItemCopyMatching(query as CFDictionary, &result)
31 let status = SecItemCopyMatching(query as CFDictionary, &result)
@@ -47,15 +39,23 @@ enum KeychainService {
47 @discardableResult
39 @discardableResult
48 static func deletePassword(for connectionID: UUID) -> Bool {
40 static func deletePassword(for connectionID: UUID) -> Bool {
49 let account = connectionID.uuidString
41 let account = connectionID.uuidString
50− let query: [String: Any] = [
42+ let query = baseQuery(for: connectionID)
51− kSecClass as String: kSecClassGenericPassword,
52− kSecAttrService as String: service,
53− kSecAttrAccount as String: account
55 let status = SecItemDelete(query as CFDictionary)
43 let status = SecItemDelete(query as CFDictionary)
56 if status != errSecSuccess && status != errSecItemNotFound {
44 if status != errSecSuccess && status != errSecItemNotFound {
57 logger.error("Failed to delete password for \(account): OSStatus \(status)")
45 logger.error("Failed to delete password for \(account): OSStatus \(status)")
59 return status == errSecSuccess || status == errSecItemNotFound
47 return status == errSecSuccess || status == errSecItemNotFound
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] {
55+ kSecClass as String: kSecClassGenericPassword,
56+ kSecAttrService as String: service,
57+ kSecAttrAccount as String: connectionID.uuidString,
58+ kSecUseDataProtectionKeychain as String: true