-
Notifications
You must be signed in to change notification settings - Fork 84
SDK-478 keychain migration off main thread, one-shot gated, race-safe reads #1066
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -1101,10 +1101,18 @@ final class InternalIterableAPI: NSObject, PushTrackerProtocol, AuthProvider { | |
| } | ||
|
|
||
| private func updateSDKVersion() { | ||
| // Always attempt keychain migration to handle uninstall/reinstall scenario | ||
| // where UserDefaults are cleared but keychain persists | ||
| localStorage.migrateKeychainToIsolatedStorage() | ||
|
|
||
| // Keychain migration is one-shot per install. We gate on a | ||
| // UserDefaults flag so every subsequent cold start (the hot path) | ||
| // skips the SecItem syscall cost entirely. On the rare first launch | ||
| // after an upgrade from a pre-isolated-keychain build we run the | ||
| // migration synchronously on the calling thread, because | ||
| // retrieveIdentifierData() runs immediately after this and must | ||
| // observe the migrated identity. See SDK-478. | ||
| if !localStorage.keychainMigrationCompleted { | ||
| localStorage.migrateKeychainToIsolatedStorage() | ||
| localStorage.keychainMigrationCompleted = true | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Confirming Franco's concern is real — the flag flips unconditionally. And Proposed: if !localStorage.keychainMigrationCompleted {
if localStorage.migrateKeychainToIsolatedStorage() {
localStorage.keychainMigrationCompleted = true
} else {
ITBError("keychain migration did not complete; will retry on next cold start")
}
} |
||
| } | ||
|
|
||
| if let lastVersion = localStorage.sdkVersion, lastVersion != IterableAPI.sdkVersion { | ||
| performUpgrade(lastVersion: lastVersion, newVersion: IterableAPI.sdkVersion) | ||
| } else { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -14,131 +14,134 @@ class IterableKeychain { | |
| /// Migrates keychain data from legacy storage to isolated storage | ||
| /// This is needed when upgrading from older SDK versions that used a shared service name | ||
| /// - Returns: true if migration was performed, false otherwise | ||
| /// | ||
| /// Runs as a barrier on `coordinatorQueue` so it blocks until in-flight | ||
| /// reads finish, then blocks subsequent reads / writes until the barrier | ||
| /// returns. After it returns, reads run fully in parallel. Uses raw | ||
| /// `wrapper`/`legacyWrapper` access internally to avoid re-entering the | ||
| /// queue (the public properties also go through the queue and would | ||
| /// deadlock if used from inside the barrier). | ||
| @discardableResult | ||
| func migrateFromLegacy() -> Bool { | ||
| guard let legacyWrapper = legacyWrapper else { | ||
| return false | ||
| } | ||
|
|
||
| var migrated = false | ||
| return coordinatorQueue.sync(flags: .barrier) { | ||
| if migrationAttempted { return false } | ||
| migrationAttempted = true | ||
|
|
||
| // Migrate email if isolated keychain is empty and legacy has data | ||
| if email == nil, let legacyEmail = getString(forKey: Const.Keychain.Key.email, from: legacyWrapper) { | ||
| email = legacyEmail | ||
| legacyWrapper.removeValue(forKey: Const.Keychain.Key.email) | ||
| ITBInfo("UPDATED: migrated email from legacy keychain to isolated keychain") | ||
| migrated = true | ||
| } | ||
| var migrated = false | ||
| let migrationStartedAt = Date() | ||
|
|
||
| // Migrate userId if isolated keychain is empty and legacy has data | ||
| if userId == nil, let legacyUserId = getString(forKey: Const.Keychain.Key.userId, from: legacyWrapper) { | ||
| userId = legacyUserId | ||
| legacyWrapper.removeValue(forKey: Const.Keychain.Key.userId) | ||
| ITBInfo("UPDATED: migrated userId from legacy keychain to isolated keychain") | ||
| migrated = true | ||
| } | ||
| if rawString(forKey: Const.Keychain.Key.email, from: wrapper) == nil, | ||
| let legacyEmail = rawString(forKey: Const.Keychain.Key.email, from: legacyWrapper) { | ||
| rawWrite(string: legacyEmail, forKey: Const.Keychain.Key.email, to: wrapper) | ||
| legacyWrapper.removeValue(forKey: Const.Keychain.Key.email) | ||
| ITBInfo("UPDATED: migrated email from legacy keychain to isolated keychain") | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔴 Data-loss risk. Guard each per-key delete on a successful isolated write: if rawWrite(string: legacyEmail, forKey: Const.Keychain.Key.email, to: wrapper) {
legacyWrapper.removeValue(forKey: Const.Keychain.Key.email)
ITBInfo("UPDATED: migrated email from legacy keychain to isolated keychain")
migrated = true
} else {
// isolated write failed — keep legacy intact so the next cold start retries
allSucceeded = false
}Same pattern for |
||
| migrated = true | ||
| } | ||
|
|
||
| // Migrate userIdUnknownUser if isolated keychain is empty and legacy has data | ||
| if userIdUnknownUser == nil, let legacyUserIdUnknownUser = getString(forKey: Const.Keychain.Key.userIdUnknownUser, from: legacyWrapper) { | ||
| userIdUnknownUser = legacyUserIdUnknownUser | ||
| legacyWrapper.removeValue(forKey: Const.Keychain.Key.userIdUnknownUser) | ||
| ITBInfo("UPDATED: migrated userIdUnknownUser from legacy keychain to isolated keychain") | ||
| migrated = true | ||
| } | ||
| if rawString(forKey: Const.Keychain.Key.userId, from: wrapper) == nil, | ||
| let legacyUserId = rawString(forKey: Const.Keychain.Key.userId, from: legacyWrapper) { | ||
| rawWrite(string: legacyUserId, forKey: Const.Keychain.Key.userId, to: wrapper) | ||
| legacyWrapper.removeValue(forKey: Const.Keychain.Key.userId) | ||
| ITBInfo("UPDATED: migrated userId from legacy keychain to isolated keychain") | ||
| migrated = true | ||
| } | ||
|
|
||
| // Migrate authToken if isolated keychain is empty and legacy has data | ||
| if authToken == nil, let legacyAuthToken = getString(forKey: Const.Keychain.Key.authToken, from: legacyWrapper) { | ||
| authToken = legacyAuthToken | ||
| legacyWrapper.removeValue(forKey: Const.Keychain.Key.authToken) | ||
| ITBInfo("UPDATED: migrated authToken from legacy keychain to isolated keychain") | ||
| migrated = true | ||
| } | ||
| if rawString(forKey: Const.Keychain.Key.userIdUnknownUser, from: wrapper) == nil, | ||
| let legacyUserIdUnknownUser = rawString(forKey: Const.Keychain.Key.userIdUnknownUser, from: legacyWrapper) { | ||
| rawWrite(string: legacyUserIdUnknownUser, forKey: Const.Keychain.Key.userIdUnknownUser, to: wrapper) | ||
| legacyWrapper.removeValue(forKey: Const.Keychain.Key.userIdUnknownUser) | ||
| ITBInfo("UPDATED: migrated userIdUnknownUser from legacy keychain to isolated keychain") | ||
| migrated = true | ||
| } | ||
|
|
||
| return migrated | ||
| } | ||
| if rawString(forKey: Const.Keychain.Key.authToken, from: wrapper) == nil, | ||
| let legacyAuthToken = rawString(forKey: Const.Keychain.Key.authToken, from: legacyWrapper) { | ||
| rawWrite(string: legacyAuthToken, forKey: Const.Keychain.Key.authToken, to: wrapper) | ||
| legacyWrapper.removeValue(forKey: Const.Keychain.Key.authToken) | ||
| ITBInfo("UPDATED: migrated authToken from legacy keychain to isolated keychain") | ||
| migrated = true | ||
| } | ||
|
|
||
| /// Helper to get string from a specific wrapper | ||
| private func getString(forKey key: String, from wrapper: KeychainWrapper) -> String? { | ||
| let data = wrapper.data(forKey: key) | ||
| return data.flatMap { String(data: $0, encoding: .utf8) } | ||
| let elapsedMs = Int(Date().timeIntervalSince(migrationStartedAt) * 1000) | ||
| ITBInfo("keychain migrateFromLegacy completed in \(elapsedMs)ms migrated=\(migrated)") | ||
|
|
||
| return migrated | ||
| } | ||
| } | ||
|
|
||
| var email: String? { | ||
| get { | ||
| let data = wrapper.data(forKey: Const.Keychain.Key.email) | ||
|
|
||
| return data.flatMap { String(data: $0, encoding: .utf8) } | ||
| } | ||
|
|
||
| get { coordinatorQueue.sync { rawString(forKey: Const.Keychain.Key.email, from: wrapper) } } | ||
| set { | ||
| guard let token = newValue, | ||
| let data = token.data(using: .utf8) else { | ||
| wrapper.removeValue(forKey: Const.Keychain.Key.email) | ||
| return | ||
| coordinatorQueue.sync(flags: .barrier) { | ||
| rawWrite(string: newValue, forKey: Const.Keychain.Key.email, to: wrapper) | ||
| } | ||
|
|
||
| wrapper.set(data, forKey: Const.Keychain.Key.email) | ||
| } | ||
| } | ||
|
|
||
| var userId: String? { | ||
| get { | ||
| let data = wrapper.data(forKey: Const.Keychain.Key.userId) | ||
|
|
||
| return data.flatMap { String(data: $0, encoding: .utf8) } | ||
| } | ||
|
|
||
| get { coordinatorQueue.sync { rawString(forKey: Const.Keychain.Key.userId, from: wrapper) } } | ||
| set { | ||
| guard let token = newValue, | ||
| let data = token.data(using: .utf8) else { | ||
| wrapper.removeValue(forKey: Const.Keychain.Key.userId) | ||
| return | ||
| coordinatorQueue.sync(flags: .barrier) { | ||
| rawWrite(string: newValue, forKey: Const.Keychain.Key.userId, to: wrapper) | ||
| } | ||
|
|
||
| wrapper.set(data, forKey: Const.Keychain.Key.userId) | ||
| } | ||
| } | ||
|
|
||
| var userIdUnknownUser: String? { | ||
| get { | ||
| let data = wrapper.data(forKey: Const.Keychain.Key.userIdUnknownUser) | ||
|
|
||
| return data.flatMap { String(data: $0, encoding: .utf8) } | ||
| } | ||
|
|
||
| get { coordinatorQueue.sync { rawString(forKey: Const.Keychain.Key.userIdUnknownUser, from: wrapper) } } | ||
| set { | ||
| guard let token = newValue, | ||
| let data = token.data(using: .utf8) else { | ||
| wrapper.removeValue(forKey: Const.Keychain.Key.userIdUnknownUser) | ||
| return | ||
| coordinatorQueue.sync(flags: .barrier) { | ||
| rawWrite(string: newValue, forKey: Const.Keychain.Key.userIdUnknownUser, to: wrapper) | ||
| } | ||
|
|
||
| wrapper.set(data, forKey: Const.Keychain.Key.userIdUnknownUser) | ||
| } | ||
| } | ||
|
|
||
| var authToken: String? { | ||
| get { | ||
| let data = wrapper.data(forKey: Const.Keychain.Key.authToken) | ||
|
|
||
| return data.flatMap { String(data: $0, encoding: .utf8) } | ||
| } | ||
|
|
||
| get { coordinatorQueue.sync { rawString(forKey: Const.Keychain.Key.authToken, from: wrapper) } } | ||
| set { | ||
| guard let token = newValue, | ||
| let data = token.data(using: .utf8) else { | ||
| wrapper.removeValue(forKey: Const.Keychain.Key.authToken) | ||
| return | ||
| coordinatorQueue.sync(flags: .barrier) { | ||
| rawWrite(string: newValue, forKey: Const.Keychain.Key.authToken, to: wrapper) | ||
| } | ||
|
|
||
| wrapper.set(data, forKey: Const.Keychain.Key.authToken) | ||
| } | ||
| } | ||
|
|
||
| // MARK: - PRIVATE/INTERNAL | ||
|
|
||
| private let wrapper: KeychainWrapper | ||
| private let legacyWrapper: KeychainWrapper? | ||
|
|
||
| /// Guards against re-running the migration body within a single | ||
| /// process lifetime if `migrateFromLegacy()` is invoked more than once | ||
| /// (e.g. a second `start()` call before the UserDefaults flag is | ||
| /// observed). Read/write only inside the `coordinatorQueue` barrier. | ||
| private var migrationAttempted = false | ||
|
|
||
| /// Concurrent queue used to serialize the migration (which runs as a | ||
| /// barrier) against the property reads/writes. Reads run fully in | ||
| /// parallel after migration completes. See SDK-478. | ||
| private let coordinatorQueue = DispatchQueue(label: "com.iterable.IterableKeychain.coordinator", | ||
| attributes: .concurrent) | ||
|
|
||
| /// Raw read - bypasses the coordinator queue. Only safe to call from | ||
| /// inside a `coordinatorQueue.sync { }` block. | ||
| private func rawString(forKey key: String, from wrapper: KeychainWrapper) -> String? { | ||
| let data = wrapper.data(forKey: key) | ||
| return data.flatMap { String(data: $0, encoding: .utf8) } | ||
| } | ||
|
|
||
| /// Raw write - bypasses the coordinator queue. Only safe to call from | ||
| /// inside a `coordinatorQueue.sync(flags: .barrier) { }` block. | ||
| private func rawWrite(string: String?, forKey key: String, to wrapper: KeychainWrapper) { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Make @discardableResult
private func rawWrite(string: String?, forKey key: String, to wrapper: KeychainWrapper) -> Bool {
guard let value = string, let data = value.data(using: .utf8) else {
return wrapper.removeValue(forKey: key)
}
return wrapper.set(data, forKey: key)
}This pairs with the failure-logging you already added to |
||
| guard let value = string, let data = value.data(using: .utf8) else { | ||
| wrapper.removeValue(forKey: key) | ||
| return | ||
| } | ||
| wrapper.set(data, forKey: key) | ||
| } | ||
|
|
||
| private func encodeJsonPayload(_ json: [AnyHashable: Any]?) -> Data? { | ||
| guard let json = json, JSONSerialization.isValidJSONObject(json) else { | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
can we check if the boolean of the migrateKeychainToIsolatedStorage is true before flipping the keychainMigrationCompleted flag to true?