From f8bc0faec11f8f1457a5028e8b773b966727b7e9 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sun, 15 Mar 2026 13:28:55 +0700 Subject: [PATCH 1/2] fix: switch keychain to Data Protection to eliminate auth prompts --- CHANGELOG.md | 4 + TablePro/AppDelegate.swift | 1 + TablePro/Core/Storage/AIKeyStorage.swift | 58 +----- TablePro/Core/Storage/ConnectionStorage.swift | 178 ++---------------- TablePro/Core/Storage/KeychainHelper.swift | 178 ++++++++++++++++++ TablePro/Core/Storage/LicenseStorage.swift | 58 +----- .../Storage/KeychainAccessControlTests.swift | 11 +- .../Core/Storage/KeychainHelperTests.swift | 75 ++++++++ 8 files changed, 283 insertions(+), 280 deletions(-) create mode 100644 TablePro/Core/Storage/KeychainHelper.swift create mode 100644 TableProTests/Core/Storage/KeychainHelperTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 6ef2c0be..eac488ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Sync status indicator in welcome window showing real-time sync state - Conflict resolution dialog for handling simultaneous edits across devices +### Fixed + +- Keychain authorization prompt no longer appears on every table open + ## [0.18.1] - 2026-03-14 ### Fixed diff --git a/TablePro/AppDelegate.swift b/TablePro/AppDelegate.swift index b9fe530a..3421cde7 100644 --- a/TablePro/AppDelegate.swift +++ b/TablePro/AppDelegate.swift @@ -52,6 +52,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { func applicationDidFinishLaunching(_ notification: Notification) { NSWindow.allowsAutomaticWindowTabbing = true + KeychainHelper.shared.migrateFromLegacyKeychainIfNeeded() PluginManager.shared.loadPlugins() Task { @MainActor in diff --git a/TablePro/Core/Storage/AIKeyStorage.swift b/TablePro/Core/Storage/AIKeyStorage.swift index 7da85446..726158ac 100644 --- a/TablePro/Core/Storage/AIKeyStorage.swift +++ b/TablePro/Core/Storage/AIKeyStorage.swift @@ -7,13 +7,10 @@ // import Foundation -import os -import Security /// Singleton Keychain storage for AI provider API keys final class AIKeyStorage { static let shared = AIKeyStorage() - private static let logger = Logger(subsystem: "com.TablePro", category: "AIKeyStorage") private init() {} @@ -22,67 +19,18 @@ final class AIKeyStorage { /// Save an API key to Keychain for the given provider func saveAPIKey(_ apiKey: String, for providerID: UUID) { let key = "com.TablePro.aikey.\(providerID.uuidString)" - - // Delete existing - let deleteQuery: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: "com.TablePro", - kSecAttrAccount as String: key, - ] - SecItemDelete(deleteQuery as CFDictionary) - - // Add new - guard let data = apiKey.data(using: .utf8) else { return } - - let addQuery: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: "com.TablePro", - kSecAttrAccount as String: key, - kSecValueData as String: data, - kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly, - ] - - let status = SecItemAdd(addQuery as CFDictionary, nil) - if status != errSecSuccess { - Self.logger.error("Failed to save API key for provider \(providerID.uuidString): \(status)") - } + KeychainHelper.shared.saveString(apiKey, forKey: key) } /// Load an API key from Keychain for the given provider func loadAPIKey(for providerID: UUID) -> String? { let key = "com.TablePro.aikey.\(providerID.uuidString)" - - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: "com.TablePro", - kSecAttrAccount as String: key, - kSecReturnData as String: true, - kSecMatchLimit as String: kSecMatchLimitOne, - ] - - var result: AnyObject? - let status = SecItemCopyMatching(query as CFDictionary, &result) - - guard status == errSecSuccess, - let data = result as? Data, - let apiKey = String(data: data, encoding: .utf8) - else { - return nil - } - - return apiKey + return KeychainHelper.shared.loadString(forKey: key) } /// Delete an API key from Keychain for the given provider func deleteAPIKey(for providerID: UUID) { let key = "com.TablePro.aikey.\(providerID.uuidString)" - - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: "com.TablePro", - kSecAttrAccount as String: key, - ] - - SecItemDelete(query as CFDictionary) + KeychainHelper.shared.delete(key: key) } } diff --git a/TablePro/Core/Storage/ConnectionStorage.swift b/TablePro/Core/Storage/ConnectionStorage.swift index c8895305..b4001e05 100644 --- a/TablePro/Core/Storage/ConnectionStorage.swift +++ b/TablePro/Core/Storage/ConnectionStorage.swift @@ -7,7 +7,6 @@ import Foundation import os -import Security /// Service for persisting database connections final class ConnectionStorage { @@ -162,223 +161,70 @@ final class ConnectionStorage { // - ConnectionFormView — single-item lookup during form population (negligible latency) // No async wrapper is needed; adding one would add complexity without measurable benefit. - /// Upsert a value into the Keychain: tries SecItemAdd first, falls back to SecItemUpdate - /// on duplicate. Returns true on success. - @discardableResult - private func keychainUpsert(key: String, data: Data) -> Bool { - let baseQuery: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: "com.TablePro", - kSecAttrAccount as String: key, - ] - - let addQuery = baseQuery.merging([ - kSecValueData as String: data, - kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly, - ]) { _, new in new } - - let addStatus = SecItemAdd(addQuery as CFDictionary, nil) - - if addStatus == errSecDuplicateItem { - // Item already exists — update it - let updateAttrs: [String: Any] = [kSecValueData as String: data] - let updateStatus = SecItemUpdate(baseQuery as CFDictionary, updateAttrs as CFDictionary) - if updateStatus != errSecSuccess { - Self.logger.error("Failed to update Keychain item '\(key)': OSStatus \(updateStatus)") - return false - } - return true - } else if addStatus != errSecSuccess { - Self.logger.error("Failed to add Keychain item '\(key)': OSStatus \(addStatus)") - return false - } - return true - } - - /// Save password to Keychain func savePassword(_ password: String, for connectionId: UUID) { let key = "com.TablePro.password.\(connectionId.uuidString)" - guard let data = password.data(using: .utf8) else { return } - keychainUpsert(key: key, data: data) + KeychainHelper.shared.saveString(password, forKey: key) } - /// Load password from Keychain func loadPassword(for connectionId: UUID) -> String? { let key = "com.TablePro.password.\(connectionId.uuidString)" - - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: "com.TablePro", - kSecAttrAccount as String: key, - kSecReturnData as String: true, - kSecMatchLimit as String: kSecMatchLimitOne, - ] - - var result: AnyObject? - let status = SecItemCopyMatching(query as CFDictionary, &result) - - guard status == errSecSuccess, - let data = result as? Data, - let password = String(data: data, encoding: .utf8) - else { - return nil - } - - return password + return KeychainHelper.shared.loadString(forKey: key) } - /// Delete password from Keychain func deletePassword(for connectionId: UUID) { let key = "com.TablePro.password.\(connectionId.uuidString)" - - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: "com.TablePro", - kSecAttrAccount as String: key, - ] - - SecItemDelete(query as CFDictionary) + KeychainHelper.shared.delete(key: key) } // MARK: - SSH Password Storage - /// Save SSH password to Keychain func saveSSHPassword(_ password: String, for connectionId: UUID) { let key = "com.TablePro.sshpassword.\(connectionId.uuidString)" - guard let data = password.data(using: .utf8) else { return } - keychainUpsert(key: key, data: data) + KeychainHelper.shared.saveString(password, forKey: key) } - /// Load SSH password from Keychain func loadSSHPassword(for connectionId: UUID) -> String? { let key = "com.TablePro.sshpassword.\(connectionId.uuidString)" - - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: "com.TablePro", - kSecAttrAccount as String: key, - kSecReturnData as String: true, - kSecMatchLimit as String: kSecMatchLimitOne, - ] - - var result: AnyObject? - let status = SecItemCopyMatching(query as CFDictionary, &result) - - guard status == errSecSuccess, - let data = result as? Data, - let password = String(data: data, encoding: .utf8) - else { - return nil - } - - return password + return KeychainHelper.shared.loadString(forKey: key) } - /// Delete SSH password from Keychain func deleteSSHPassword(for connectionId: UUID) { let key = "com.TablePro.sshpassword.\(connectionId.uuidString)" - - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: "com.TablePro", - kSecAttrAccount as String: key, - ] - - SecItemDelete(query as CFDictionary) + KeychainHelper.shared.delete(key: key) } // MARK: - Key Passphrase Storage - /// Save private key passphrase to Keychain func saveKeyPassphrase(_ passphrase: String, for connectionId: UUID) { let key = "com.TablePro.keypassphrase.\(connectionId.uuidString)" - guard let data = passphrase.data(using: .utf8) else { return } - keychainUpsert(key: key, data: data) + KeychainHelper.shared.saveString(passphrase, forKey: key) } - /// Load private key passphrase from Keychain func loadKeyPassphrase(for connectionId: UUID) -> String? { let key = "com.TablePro.keypassphrase.\(connectionId.uuidString)" - - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: "com.TablePro", - kSecAttrAccount as String: key, - kSecReturnData as String: true, - kSecMatchLimit as String: kSecMatchLimitOne, - ] - - var result: AnyObject? - let status = SecItemCopyMatching(query as CFDictionary, &result) - - guard status == errSecSuccess, - let data = result as? Data, - let passphrase = String(data: data, encoding: .utf8) - else { - return nil - } - - return passphrase + return KeychainHelper.shared.loadString(forKey: key) } - /// Delete private key passphrase from Keychain func deleteKeyPassphrase(for connectionId: UUID) { let key = "com.TablePro.keypassphrase.\(connectionId.uuidString)" - - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: "com.TablePro", - kSecAttrAccount as String: key, - ] - - SecItemDelete(query as CFDictionary) + KeychainHelper.shared.delete(key: key) } // MARK: - TOTP Secret Storage - /// Save TOTP secret to Keychain func saveTOTPSecret(_ secret: String, for connectionId: UUID) { let key = "com.TablePro.totpsecret.\(connectionId.uuidString)" - guard let data = secret.data(using: .utf8) else { return } - keychainUpsert(key: key, data: data) + KeychainHelper.shared.saveString(secret, forKey: key) } - /// Load TOTP secret from Keychain func loadTOTPSecret(for connectionId: UUID) -> String? { let key = "com.TablePro.totpsecret.\(connectionId.uuidString)" - - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: "com.TablePro", - kSecAttrAccount as String: key, - kSecReturnData as String: true, - kSecMatchLimit as String: kSecMatchLimitOne, - ] - - var result: AnyObject? - let status = SecItemCopyMatching(query as CFDictionary, &result) - - guard status == errSecSuccess, - let data = result as? Data, - let secret = String(data: data, encoding: .utf8) - else { - return nil - } - - return secret + return KeychainHelper.shared.loadString(forKey: key) } - /// Delete TOTP secret from Keychain func deleteTOTPSecret(for connectionId: UUID) { let key = "com.TablePro.totpsecret.\(connectionId.uuidString)" - - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: "com.TablePro", - kSecAttrAccount as String: key, - ] - - SecItemDelete(query as CFDictionary) + KeychainHelper.shared.delete(key: key) } } diff --git a/TablePro/Core/Storage/KeychainHelper.swift b/TablePro/Core/Storage/KeychainHelper.swift new file mode 100644 index 00000000..3142a262 --- /dev/null +++ b/TablePro/Core/Storage/KeychainHelper.swift @@ -0,0 +1,178 @@ +import Foundation +import os +import Security + +final class KeychainHelper { + static let shared = KeychainHelper() + + private let service = "com.TablePro" + private static let logger = Logger(subsystem: "com.TablePro", category: "KeychainHelper") + private static let migrationKey = "com.TablePro.keychainMigratedToDataProtection" + + private init() {} + + // MARK: - Core Methods + + @discardableResult + func save(key: String, data: Data) -> Bool { + let addQuery: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: key, + kSecValueData as String: data, + kSecUseDataProtectionKeychain as String: true, + kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock + ] + + var status = SecItemAdd(addQuery as CFDictionary, nil) + + if status == errSecDuplicateItem { + // SecItemUpdate search query must not include kSecUseDataProtectionKeychain (TN3137) + let searchQuery: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: key + ] + let updateAttributes: [String: Any] = [ + kSecValueData as String: data + ] + status = SecItemUpdate(searchQuery as CFDictionary, updateAttributes as CFDictionary) + } + + if status != errSecSuccess { + Self.logger.error("Failed to save keychain item for key '\(key, privacy: .public)': \(status)") + } + + return status == errSecSuccess + } + + func load(key: String) -> Data? { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: key, + kSecUseDataProtectionKeychain as String: true, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne + ] + + var result: AnyObject? + let status = SecItemCopyMatching(query as CFDictionary, &result) + + guard status == errSecSuccess else { + if status != errSecItemNotFound { + Self.logger.error("Failed to load keychain item for key '\(key, privacy: .public)': \(status)") + } + return nil + } + + return result as? Data + } + + func delete(key: String) { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: key, + kSecUseDataProtectionKeychain as String: true + ] + + let status = SecItemDelete(query as CFDictionary) + + if status != errSecSuccess, status != errSecItemNotFound { + Self.logger.error("Failed to delete keychain item for key '\(key, privacy: .public)': \(status)") + } + } + + // MARK: - String Convenience + + @discardableResult + func saveString(_ value: String, forKey key: String) -> Bool { + guard let data = value.data(using: .utf8) else { + Self.logger.error("Failed to encode string to UTF-8 for key '\(key, privacy: .public)'") + return false + } + return save(key: key, data: data) + } + + func loadString(forKey key: String) -> String? { + guard let data = load(key: key) else { + return nil + } + return String(data: data, encoding: .utf8) + } + + // MARK: - Migration + + func migrateFromLegacyKeychainIfNeeded() { + guard !UserDefaults.standard.bool(forKey: Self.migrationKey) else { + return + } + + Self.logger.info("Starting legacy keychain migration to Data Protection keychain") + + let legacyQuery: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecMatchLimit as String: kSecMatchLimitAll, + kSecReturnAttributes as String: true, + kSecReturnData as String: true + ] + + var result: AnyObject? + let status = SecItemCopyMatching(legacyQuery as CFDictionary, &result) + + if status == errSecItemNotFound { + Self.logger.info("No legacy keychain items found, marking migration as complete") + UserDefaults.standard.set(true, forKey: Self.migrationKey) + return + } + + guard status == errSecSuccess, let items = result as? [[String: Any]] else { + Self.logger.error("Failed to query legacy keychain items: \(status)") + return + } + + Self.logger.info("Found \(items.count) legacy keychain items to migrate") + + var allSucceeded = true + + for item in items { + guard let account = item[kSecAttrAccount as String] as? String, + let data = item[kSecValueData as String] as? Data else { + Self.logger.warning("Skipping legacy item with missing account or data") + allSucceeded = false + continue + } + + let saved = save(key: account, data: data) + + if saved { + let deleteLegacyQuery: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account + ] + let deleteStatus = SecItemDelete(deleteLegacyQuery as CFDictionary) + + if deleteStatus != errSecSuccess, deleteStatus != errSecItemNotFound { + Self.logger.warning( + "Migrated item '\(account, privacy: .public)' but failed to delete legacy entry: \(deleteStatus)" + ) + } else { + Self.logger.info("Successfully migrated item '\(account, privacy: .public)'") + } + } else { + Self.logger.error("Failed to migrate item '\(account, privacy: .public)' to Data Protection keychain") + allSucceeded = false + } + } + + if allSucceeded { + UserDefaults.standard.set(true, forKey: Self.migrationKey) + Self.logger.info("Legacy keychain migration completed successfully") + } else { + Self.logger.warning("Legacy keychain migration incomplete, will retry on next launch") + } + } +} diff --git a/TablePro/Core/Storage/LicenseStorage.swift b/TablePro/Core/Storage/LicenseStorage.swift index 01d9643f..cb72a2f1 100644 --- a/TablePro/Core/Storage/LicenseStorage.swift +++ b/TablePro/Core/Storage/LicenseStorage.swift @@ -8,7 +8,6 @@ import Foundation import IOKit import os -import Security /// Persists license data using Keychain (secrets) and UserDefaults (metadata) final class LicenseStorage { @@ -29,68 +28,17 @@ final class LicenseStorage { /// Save license key to Keychain func saveLicenseKey(_ key: String) { - let account = Keys.keychainLicenseKey - - // Delete existing - let deleteQuery: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: "com.TablePro", - kSecAttrAccount as String: account, - ] - SecItemDelete(deleteQuery as CFDictionary) - - guard let data = key.data(using: .utf8) else { return } - - let addQuery: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: "com.TablePro", - kSecAttrAccount as String: account, - kSecValueData as String: data, - kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly, - ] - - let status = SecItemAdd(addQuery as CFDictionary, nil) - if status != errSecSuccess { - Self.logger.error("Failed to save license key: OSStatus \(status)") - } + KeychainHelper.shared.saveString(key, forKey: Keys.keychainLicenseKey) } /// Load license key from Keychain func loadLicenseKey() -> String? { - let account = Keys.keychainLicenseKey - - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: "com.TablePro", - kSecAttrAccount as String: account, - kSecReturnData as String: true, - kSecMatchLimit as String: kSecMatchLimitOne, - ] - - var result: AnyObject? - let status = SecItemCopyMatching(query as CFDictionary, &result) - - guard status == errSecSuccess, - let data = result as? Data, - let key = String(data: data, encoding: .utf8) - else { - return nil - } - - return key + KeychainHelper.shared.loadString(forKey: Keys.keychainLicenseKey) } /// Delete license key from Keychain func deleteLicenseKey() { - let account = Keys.keychainLicenseKey - - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: "com.TablePro", - kSecAttrAccount as String: account, - ] - - SecItemDelete(query as CFDictionary) + KeychainHelper.shared.delete(key: Keys.keychainLicenseKey) } // MARK: - Signed Payload (UserDefaults) diff --git a/TableProTests/Core/Storage/KeychainAccessControlTests.swift b/TableProTests/Core/Storage/KeychainAccessControlTests.swift index 29a27760..b6976f22 100644 --- a/TableProTests/Core/Storage/KeychainAccessControlTests.swift +++ b/TableProTests/Core/Storage/KeychainAccessControlTests.swift @@ -10,12 +10,15 @@ import Testing @Suite("Keychain Access Control") struct KeychainAccessControlTests { - @Test("kSecAttrAccessibleWhenUnlockedThisDeviceOnly constant is available") + @Test("AfterFirstUnlock constant is available for background access") func correctConstantAvailable() { - let expected = kSecAttrAccessibleWhenUnlockedThisDeviceOnly + let expected = kSecAttrAccessibleAfterFirstUnlock #expect(expected != nil) + } - let lessSecure = kSecAttrAccessibleAfterFirstUnlock - #expect(expected as String != lessSecure as String) + @Test("Data Protection keychain flag is a valid boolean") + func dataProtectionKeychainFlag() { + let flag = kSecUseDataProtectionKeychain + #expect(flag != nil) } } diff --git a/TableProTests/Core/Storage/KeychainHelperTests.swift b/TableProTests/Core/Storage/KeychainHelperTests.swift new file mode 100644 index 00000000..eb31b574 --- /dev/null +++ b/TableProTests/Core/Storage/KeychainHelperTests.swift @@ -0,0 +1,75 @@ +// +// KeychainHelperTests.swift +// TableProTests +// + +import Foundation +import Testing +@testable import TablePro + +@Suite("KeychainHelper") +struct KeychainHelperTests { + private let helper = KeychainHelper.shared + + @Test("Save and load round trip") + func saveAndLoadRoundTrip() { + let key = "test.roundtrip.\(UUID().uuidString)" + defer { helper.delete(key: key) } + + let saved = helper.saveString("hello", forKey: key) + #expect(saved) + + let loaded = helper.loadString(forKey: key) + #expect(loaded == "hello") + } + + @Test("Delete removes item") + func deleteRemovesItem() { + let key = "test.delete.\(UUID().uuidString)" + defer { helper.delete(key: key) } + + _ = helper.saveString("temporary", forKey: key) + helper.delete(key: key) + + let loaded = helper.loadString(forKey: key) + #expect(loaded == nil) + } + + @Test("Upsert overwrites existing value") + func upsertOverwritesExistingValue() { + let key = "test.upsert.\(UUID().uuidString)" + defer { helper.delete(key: key) } + + _ = helper.saveString("first", forKey: key) + _ = helper.saveString("second", forKey: key) + + let loaded = helper.loadString(forKey: key) + #expect(loaded == "second") + } + + @Test("Load nonexistent returns nil") + func loadNonexistentReturnsNil() { + let key = "test.nonexistent.\(UUID().uuidString)" + defer { helper.delete(key: key) } + + let loaded = helper.loadString(forKey: key) + #expect(loaded == nil) + } + + @Test("Migration flag defaults to false") + func migrationFlagDefaultsFalse() { + let defaultsKey = "com.TablePro.keychainMigratedToDataProtection" + let previous = UserDefaults.standard.object(forKey: defaultsKey) + defer { + if let previous { + UserDefaults.standard.set(previous, forKey: defaultsKey) + } else { + UserDefaults.standard.removeObject(forKey: defaultsKey) + } + } + + UserDefaults.standard.removeObject(forKey: defaultsKey) + let value = UserDefaults.standard.bool(forKey: defaultsKey) + #expect(value == false) + } +} From 67176a6238d6a12f1d61c1629b3d1c44eadf74b7 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sun, 15 Mar 2026 13:33:57 +0700 Subject: [PATCH 2/2] fix: add kSecUseDataProtectionKeychain to SecItemUpdate search query --- TablePro/Core/Storage/KeychainHelper.swift | 9 +++++++-- TablePro/Core/Storage/LicenseStorage.swift | 2 +- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/TablePro/Core/Storage/KeychainHelper.swift b/TablePro/Core/Storage/KeychainHelper.swift index 3142a262..2eb3d315 100644 --- a/TablePro/Core/Storage/KeychainHelper.swift +++ b/TablePro/Core/Storage/KeychainHelper.swift @@ -1,3 +1,8 @@ +// +// KeychainHelper.swift +// TablePro +// + import Foundation import os import Security @@ -27,11 +32,11 @@ final class KeychainHelper { var status = SecItemAdd(addQuery as CFDictionary, nil) if status == errSecDuplicateItem { - // SecItemUpdate search query must not include kSecUseDataProtectionKeychain (TN3137) let searchQuery: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service, - kSecAttrAccount as String: key + kSecAttrAccount as String: key, + kSecUseDataProtectionKeychain as String: true ] let updateAttributes: [String: Any] = [ kSecValueData as String: data diff --git a/TablePro/Core/Storage/LicenseStorage.swift b/TablePro/Core/Storage/LicenseStorage.swift index cb72a2f1..20087892 100644 --- a/TablePro/Core/Storage/LicenseStorage.swift +++ b/TablePro/Core/Storage/LicenseStorage.swift @@ -33,7 +33,7 @@ final class LicenseStorage { /// Load license key from Keychain func loadLicenseKey() -> String? { - KeychainHelper.shared.loadString(forKey: Keys.keychainLicenseKey) + return KeychainHelper.shared.loadString(forKey: Keys.keychainLicenseKey) } /// Delete license key from Keychain