@@ -13,21 +13,29 @@ final class KeychainHelper {
1313 private let service = " com.TablePro "
1414 private static let logger = Logger ( subsystem: " com.TablePro " , category: " KeychainHelper " )
1515 private static let migrationKey = " com.TablePro.keychainMigratedToDataProtection "
16+ private static let passwordSyncEnabledKey = " com.TablePro.keychainPasswordSyncEnabled "
17+
18+ private var isPasswordSyncEnabled : Bool {
19+ UserDefaults . standard. bool ( forKey: Self . passwordSyncEnabledKey)
20+ }
1621
1722 private init ( ) { }
1823
1924 // MARK: - Core Methods
2025
2126 @discardableResult
2227 func save( key: String , data: Data ) -> Bool {
23- let addQuery : [ String : Any ] = [
28+ var addQuery : [ String : Any ] = [
2429 kSecClass as String : kSecClassGenericPassword,
2530 kSecAttrService as String : service,
2631 kSecAttrAccount as String : key,
2732 kSecValueData as String : data,
2833 kSecUseDataProtectionKeychain as String : true ,
2934 kSecAttrAccessible as String : kSecAttrAccessibleAfterFirstUnlock
3035 ]
36+ if isPasswordSyncEnabled {
37+ addQuery [ kSecAttrSynchronizable as String ] = true
38+ }
3139
3240 var status = SecItemAdd ( addQuery as CFDictionary , nil )
3341
@@ -36,7 +44,8 @@ final class KeychainHelper {
3644 kSecClass as String : kSecClassGenericPassword,
3745 kSecAttrService as String : service,
3846 kSecAttrAccount as String : key,
39- kSecUseDataProtectionKeychain as String : true
47+ kSecUseDataProtectionKeychain as String : true ,
48+ kSecAttrSynchronizable as String : kSecAttrSynchronizableAny
4049 ]
4150 let updateAttributes : [ String : Any ] = [
4251 kSecValueData as String : data
@@ -57,6 +66,7 @@ final class KeychainHelper {
5766 kSecAttrService as String : service,
5867 kSecAttrAccount as String : key,
5968 kSecUseDataProtectionKeychain as String : true ,
69+ kSecAttrSynchronizable as String : kSecAttrSynchronizableAny,
6070 kSecReturnData as String : true ,
6171 kSecMatchLimit as String : kSecMatchLimitOne
6272 ]
@@ -79,7 +89,8 @@ final class KeychainHelper {
7989 kSecClass as String : kSecClassGenericPassword,
8090 kSecAttrService as String : service,
8191 kSecAttrAccount as String : key,
82- kSecUseDataProtectionKeychain as String : true
92+ kSecUseDataProtectionKeychain as String : true ,
93+ kSecAttrSynchronizable as String : kSecAttrSynchronizableAny
8394 ]
8495
8596 let status = SecItemDelete ( query as CFDictionary )
@@ -180,4 +191,89 @@ final class KeychainHelper {
180191 Self . logger. warning ( " Legacy keychain migration incomplete, will retry on next launch " )
181192 }
182193 }
194+
195+ // MARK: - Password Sync Migration
196+
197+ /// Migrates all TablePro keychain items between local-only and iCloud-synchronizable.
198+ func migratePasswordSyncState( synchronizable: Bool ) {
199+ Self . logger. info ( " Starting keychain sync migration: synchronizable= \( synchronizable) " )
200+
201+ let searchQuery : [ String : Any ] = [
202+ kSecClass as String : kSecClassGenericPassword,
203+ kSecAttrService as String : service,
204+ kSecUseDataProtectionKeychain as String : true ,
205+ kSecAttrSynchronizable as String : kSecAttrSynchronizableAny,
206+ kSecMatchLimit as String : kSecMatchLimitAll,
207+ kSecReturnAttributes as String : true ,
208+ kSecReturnData as String : true
209+ ]
210+
211+ var result : AnyObject ?
212+ let status = SecItemCopyMatching ( searchQuery as CFDictionary , & result)
213+
214+ guard status == errSecSuccess, let items = result as? [ [ String : Any ] ] else {
215+ if status == errSecItemNotFound {
216+ Self . logger. info ( " No keychain items to migrate " )
217+ } else {
218+ Self . logger. error ( " Failed to query items for sync migration: \( status) " )
219+ }
220+ return
221+ }
222+
223+ var migratedCount = 0
224+ var skippedCount = 0
225+
226+ for item in items {
227+ guard let account = item [ kSecAttrAccount as String ] as? String ,
228+ let data = item [ kSecValueData as String ] as? Data
229+ else { continue }
230+
231+ let currentlySync = item [ kSecAttrSynchronizable as String ] as? Bool ?? false
232+ if currentlySync == synchronizable {
233+ skippedCount += 1
234+ continue
235+ }
236+
237+ var addQuery : [ String : Any ] = [
238+ kSecClass as String : kSecClassGenericPassword,
239+ kSecAttrService as String : service,
240+ kSecAttrAccount as String : account,
241+ kSecValueData as String : data,
242+ kSecUseDataProtectionKeychain as String : true ,
243+ kSecAttrAccessible as String : kSecAttrAccessibleAfterFirstUnlock
244+ ]
245+ if synchronizable {
246+ addQuery [ kSecAttrSynchronizable as String ] = true
247+ }
248+
249+ let addStatus = SecItemAdd ( addQuery as CFDictionary , nil )
250+
251+ guard addStatus == errSecSuccess || addStatus == errSecDuplicateItem else {
252+ Self . logger. error (
253+ " Failed to create migrated item ' \( account, privacy: . public) ': \( addStatus) "
254+ )
255+ continue
256+ }
257+
258+ let deleteQuery : [ String : Any ] = [
259+ kSecClass as String : kSecClassGenericPassword,
260+ kSecAttrService as String : service,
261+ kSecAttrAccount as String : account,
262+ kSecUseDataProtectionKeychain as String : true ,
263+ kSecAttrSynchronizable as String : !synchronizable as CFBoolean
264+ ]
265+ let deleteStatus = SecItemDelete ( deleteQuery as CFDictionary )
266+ if deleteStatus != errSecSuccess, deleteStatus != errSecItemNotFound {
267+ Self . logger. warning (
268+ " Migrated item ' \( account, privacy: . public) ' but failed to delete old entry: \( deleteStatus) "
269+ )
270+ }
271+
272+ migratedCount += 1
273+ }
274+
275+ Self . logger. info (
276+ " Keychain sync migration complete: \( migratedCount) migrated, \( skippedCount) already correct "
277+ )
278+ }
183279}
0 commit comments