@@ -13,33 +13,46 @@ 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+ static let passwordSyncEnabledKey = " com.TablePro.keychainPasswordSyncEnabled "
17+
18+ private let migrationLock = NSLock ( )
19+
20+ private var isPasswordSyncEnabled : Bool {
21+ UserDefaults . standard. bool ( forKey: Self . passwordSyncEnabledKey)
22+ }
1623
1724 private init ( ) { }
1825
1926 // MARK: - Core Methods
2027
2128 @discardableResult
2229 func save( key: String , data: Data ) -> Bool {
23- let addQuery : [ String : Any ] = [
30+ var addQuery : [ String : Any ] = [
2431 kSecClass as String : kSecClassGenericPassword,
2532 kSecAttrService as String : service,
2633 kSecAttrAccount as String : key,
2734 kSecValueData as String : data,
2835 kSecUseDataProtectionKeychain as String : true ,
2936 kSecAttrAccessible as String : kSecAttrAccessibleAfterFirstUnlock
3037 ]
38+ if isPasswordSyncEnabled {
39+ addQuery [ kSecAttrSynchronizable as String ] = true
40+ }
3141
3242 var status = SecItemAdd ( addQuery as CFDictionary , nil )
3343
3444 if status == errSecDuplicateItem {
45+ let synchronizable = isPasswordSyncEnabled
3546 let searchQuery : [ String : Any ] = [
3647 kSecClass as String : kSecClassGenericPassword,
3748 kSecAttrService as String : service,
3849 kSecAttrAccount as String : key,
39- kSecUseDataProtectionKeychain as String : true
50+ kSecUseDataProtectionKeychain as String : true ,
51+ kSecAttrSynchronizable as String : kSecAttrSynchronizableAny
4052 ]
4153 let updateAttributes : [ String : Any ] = [
42- kSecValueData as String : data
54+ kSecValueData as String : data,
55+ kSecAttrSynchronizable as String : synchronizable
4356 ]
4457 status = SecItemUpdate ( searchQuery as CFDictionary , updateAttributes as CFDictionary )
4558 }
@@ -57,6 +70,7 @@ final class KeychainHelper {
5770 kSecAttrService as String : service,
5871 kSecAttrAccount as String : key,
5972 kSecUseDataProtectionKeychain as String : true ,
73+ kSecAttrSynchronizable as String : kSecAttrSynchronizableAny,
6074 kSecReturnData as String : true ,
6175 kSecMatchLimit as String : kSecMatchLimitOne
6276 ]
@@ -79,7 +93,8 @@ final class KeychainHelper {
7993 kSecClass as String : kSecClassGenericPassword,
8094 kSecAttrService as String : service,
8195 kSecAttrAccount as String : key,
82- kSecUseDataProtectionKeychain as String : true
96+ kSecUseDataProtectionKeychain as String : true ,
97+ kSecAttrSynchronizable as String : kSecAttrSynchronizableAny
8398 ]
8499
85100 let status = SecItemDelete ( query as CFDictionary )
@@ -180,4 +195,98 @@ final class KeychainHelper {
180195 Self . logger. warning ( " Legacy keychain migration incomplete, will retry on next launch " )
181196 }
182197 }
198+
199+ // MARK: - Password Sync Migration
200+
201+ /// Migrates all TablePro keychain items between local-only and iCloud-synchronizable.
202+ /// Serialized via `migrationLock` to prevent concurrent migrations from rapid toggling.
203+ func migratePasswordSyncState( synchronizable: Bool ) {
204+ migrationLock. lock ( )
205+ defer { migrationLock. unlock ( ) }
206+
207+ Self . logger. info ( " Starting keychain sync migration: synchronizable= \( synchronizable) " )
208+
209+ let searchQuery : [ String : Any ] = [
210+ kSecClass as String : kSecClassGenericPassword,
211+ kSecAttrService as String : service,
212+ kSecUseDataProtectionKeychain as String : true ,
213+ kSecAttrSynchronizable as String : kSecAttrSynchronizableAny,
214+ kSecMatchLimit as String : kSecMatchLimitAll,
215+ kSecReturnAttributes as String : true ,
216+ kSecReturnData as String : true
217+ ]
218+
219+ var result : AnyObject ?
220+ let status = SecItemCopyMatching ( searchQuery as CFDictionary , & result)
221+
222+ guard status == errSecSuccess, let items = result as? [ [ String : Any ] ] else {
223+ if status == errSecItemNotFound {
224+ Self . logger. info ( " No keychain items to migrate " )
225+ } else {
226+ Self . logger. error ( " Failed to query items for sync migration: \( status) " )
227+ }
228+ return
229+ }
230+
231+ var migratedCount = 0
232+ var skippedCount = 0
233+
234+ for item in items {
235+ guard let account = item [ kSecAttrAccount as String ] as? String ,
236+ let data = item [ kSecValueData as String ] as? Data
237+ else { continue }
238+
239+ let currentlySync = item [ kSecAttrSynchronizable as String ] as? Bool ?? false
240+ if currentlySync == synchronizable {
241+ skippedCount += 1
242+ continue
243+ }
244+
245+ var addQuery : [ String : Any ] = [
246+ kSecClass as String : kSecClassGenericPassword,
247+ kSecAttrService as String : service,
248+ kSecAttrAccount as String : account,
249+ kSecValueData as String : data,
250+ kSecUseDataProtectionKeychain as String : true ,
251+ kSecAttrAccessible as String : kSecAttrAccessibleAfterFirstUnlock
252+ ]
253+ if synchronizable {
254+ addQuery [ kSecAttrSynchronizable as String ] = true
255+ }
256+
257+ let addStatus = SecItemAdd ( addQuery as CFDictionary , nil )
258+
259+ guard addStatus == errSecSuccess || addStatus == errSecDuplicateItem else {
260+ Self . logger. error (
261+ " Failed to create migrated item ' \( account, privacy: . public) ': \( addStatus) "
262+ )
263+ continue
264+ }
265+
266+ // When opting IN (synchronizable=true), delete the old local-only item safely.
267+ // When opting OUT (synchronizable=false), keep the synchronizable item — deleting it
268+ // would propagate via iCloud Keychain and remove it from other Macs still opted in.
269+ if synchronizable {
270+ let deleteQuery : [ String : Any ] = [
271+ kSecClass as String : kSecClassGenericPassword,
272+ kSecAttrService as String : service,
273+ kSecAttrAccount as String : account,
274+ kSecUseDataProtectionKeychain as String : true ,
275+ kSecAttrSynchronizable as String : false
276+ ]
277+ let deleteStatus = SecItemDelete ( deleteQuery as CFDictionary )
278+ if deleteStatus != errSecSuccess, deleteStatus != errSecItemNotFound {
279+ Self . logger. warning (
280+ " Migrated item ' \( account, privacy: . public) ' but failed to delete old entry: \( deleteStatus) "
281+ )
282+ }
283+ }
284+
285+ migratedCount += 1
286+ }
287+
288+ Self . logger. info (
289+ " Keychain sync migration complete: \( migratedCount) migrated, \( skippedCount) already correct "
290+ )
291+ }
183292}
0 commit comments