@mmerterden/multi-agent-pipeline 20.7.0 → 20.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +9 -0
- package/LICENSE +0 -10
- package/docs/facts.json +1 -1
- package/manifest.json +266 -267
- package/package.json +2 -2
- package/pipeline/scripts/_notices.mjs +1 -1
- package/pipeline/skills/.skill-manifest.json +68 -68
- package/pipeline/skills/shared/README.md +70 -70
- package/pipeline/skills/shared/external/alarmkit/SKILL.md +373 -381
- package/pipeline/skills/shared/external/alarmkit/evals/evals.json +23 -18
- package/pipeline/skills/shared/external/alarmkit/references/alarmkit-patterns.md +328 -378
- package/pipeline/skills/shared/external/app-clips/SKILL.md +260 -160
- package/pipeline/skills/shared/external/app-clips/evals/evals.json +27 -27
- package/pipeline/skills/shared/external/app-clips/references/data-handoff-notifications-location.md +150 -83
- package/pipeline/skills/shared/external/app-clips/references/routing-and-experiences.md +135 -83
- package/pipeline/skills/shared/external/app-clips/references/size-capabilities-and-promotion.md +143 -85
- package/pipeline/skills/shared/external/app-intents/SKILL.md +302 -304
- package/pipeline/skills/shared/external/app-intents/evals/evals.json +21 -21
- package/pipeline/skills/shared/external/app-intents/references/appintents-advanced.md +594 -894
- package/pipeline/skills/shared/external/app-store-optimization/SKILL.md +339 -277
- package/pipeline/skills/shared/external/app-store-optimization/evals/evals.json +27 -23
- package/pipeline/skills/shared/external/app-store-optimization/references/keyword-research-methodology.md +105 -122
- package/pipeline/skills/shared/external/app-store-optimization/references/product-page-variants.md +143 -166
- package/pipeline/skills/shared/external/app-store-review/SKILL.md +307 -326
- package/pipeline/skills/shared/external/app-store-review/evals/evals.json +21 -21
- package/pipeline/skills/shared/external/app-store-review/references/privacy-manifest.md +105 -67
- package/pipeline/skills/shared/external/app-store-review/references/review-checklists.md +114 -101
- package/pipeline/skills/shared/external/apple-on-device-ai/SKILL.md +333 -360
- package/pipeline/skills/shared/external/apple-on-device-ai/evals/evals.json +24 -27
- package/pipeline/skills/shared/external/apple-on-device-ai/references/coreml-conversion.md +215 -322
- package/pipeline/skills/shared/external/apple-on-device-ai/references/coreml-optimization.md +161 -256
- package/pipeline/skills/shared/external/apple-on-device-ai/references/foundation-models.md +277 -387
- package/pipeline/skills/shared/external/apple-on-device-ai/references/mlx-swift.md +196 -210
- package/pipeline/skills/shared/external/authentication/SKILL.md +265 -381
- package/pipeline/skills/shared/external/authentication/evals/evals.json +25 -25
- package/pipeline/skills/shared/external/authentication/references/keychain-biometric.md +133 -178
- package/pipeline/skills/shared/external/authentication/references/passkeys.md +111 -147
- package/pipeline/skills/shared/external/avkit/SKILL.md +267 -364
- package/pipeline/skills/shared/external/avkit/evals/evals.json +26 -26
- package/pipeline/skills/shared/external/avkit/references/avkit-patterns.md +375 -493
- package/pipeline/skills/shared/external/background-processing/SKILL.md +270 -382
- package/pipeline/skills/shared/external/background-processing/evals/evals.json +22 -22
- package/pipeline/skills/shared/external/background-processing/references/background-task-patterns.md +169 -317
- package/pipeline/skills/shared/external/callkit-voip/SKILL.md +290 -371
- package/pipeline/skills/shared/external/callkit-voip/evals/evals.json +24 -24
- package/pipeline/skills/shared/external/callkit-voip/references/callkit-patterns.md +175 -343
- package/pipeline/skills/shared/external/cloudkit-sync/SKILL.md +292 -381
- package/pipeline/skills/shared/external/cloudkit-sync/evals/evals.json +33 -30
- package/pipeline/skills/shared/external/cloudkit-sync/references/cloudkit-patterns.md +227 -355
- package/pipeline/skills/shared/external/contacts-framework/SKILL.md +197 -346
- package/pipeline/skills/shared/external/contacts-framework/evals/evals.json +19 -21
- package/pipeline/skills/shared/external/contacts-framework/references/contacts-patterns.md +169 -308
- package/pipeline/skills/shared/external/core-bluetooth/SKILL.md +226 -376
- package/pipeline/skills/shared/external/core-bluetooth/evals/evals.json +25 -22
- package/pipeline/skills/shared/external/core-bluetooth/references/ble-patterns.md +257 -337
- package/pipeline/skills/shared/external/core-data/SKILL.md +292 -368
- package/pipeline/skills/shared/external/core-data/evals/evals.json +30 -27
- package/pipeline/skills/shared/external/core-motion/SKILL.md +235 -324
- package/pipeline/skills/shared/external/core-motion/evals/evals.json +31 -27
- package/pipeline/skills/shared/external/core-motion/references/motion-patterns.md +210 -310
- package/pipeline/skills/shared/external/core-nfc/SKILL.md +292 -366
- package/pipeline/skills/shared/external/core-nfc/evals/evals.json +22 -24
- package/pipeline/skills/shared/external/core-nfc/references/nfc-patterns.md +233 -329
- package/pipeline/skills/shared/external/coreml/SKILL.md +323 -367
- package/pipeline/skills/shared/external/coreml/evals/evals.json +24 -21
- package/pipeline/skills/shared/external/coreml/references/coreml-swift-integration.md +562 -565
- package/pipeline/skills/shared/external/cryptokit/SKILL.md +253 -394
- package/pipeline/skills/shared/external/cryptokit/evals/evals.json +20 -18
- package/pipeline/skills/shared/external/cryptokit/references/cryptokit-patterns.md +299 -488
- package/pipeline/skills/shared/external/debugging-instruments/SKILL.md +270 -323
- package/pipeline/skills/shared/external/debugging-instruments/evals/evals.json +27 -30
- package/pipeline/skills/shared/external/debugging-instruments/references/instruments-guide.md +167 -315
- package/pipeline/skills/shared/external/debugging-instruments/references/lldb-patterns.md +140 -193
- package/pipeline/skills/shared/external/device-integrity/SKILL.md +230 -353
- package/pipeline/skills/shared/external/device-integrity/evals/evals.json +25 -21
- package/pipeline/skills/shared/external/device-integrity/references/device-integrity-patterns.md +159 -197
- package/pipeline/skills/shared/external/energykit/SKILL.md +225 -392
- package/pipeline/skills/shared/external/energykit/evals/evals.json +29 -28
- package/pipeline/skills/shared/external/energykit/references/energykit-patterns.md +174 -470
- package/pipeline/skills/shared/external/eventkit-calendar/SKILL.md +261 -383
- package/pipeline/skills/shared/external/eventkit-calendar/evals/evals.json +25 -22
- package/pipeline/skills/shared/external/eventkit-calendar/references/eventkit-patterns.md +165 -268
- package/pipeline/skills/shared/external/healthkit/SKILL.md +252 -303
- package/pipeline/skills/shared/external/healthkit/evals/evals.json +24 -23
- package/pipeline/skills/shared/external/healthkit/references/healthkit-patterns.md +369 -523
- package/pipeline/skills/shared/external/homekit-matter/SKILL.md +233 -348
- package/pipeline/skills/shared/external/homekit-matter/evals/evals.json +27 -22
- package/pipeline/skills/shared/external/homekit-matter/references/matter-commissioning.md +199 -305
- package/pipeline/skills/shared/external/ios-accessibility/SKILL.md +368 -340
- package/pipeline/skills/shared/external/ios-accessibility/evals/evals.json +28 -27
- package/pipeline/skills/shared/external/ios-accessibility/references/a11y-patterns.md +314 -260
- package/pipeline/skills/shared/external/ios-accessibility/references/media-accessibility.md +97 -67
- package/pipeline/skills/shared/external/ios-accessibility/references/nutrition-labels.md +165 -101
- package/pipeline/skills/shared/external/ios-localization/SKILL.md +258 -371
- package/pipeline/skills/shared/external/ios-localization/evals/evals.json +23 -23
- package/pipeline/skills/shared/external/ios-localization/references/formatstyle-locale.md +283 -491
- package/pipeline/skills/shared/external/ios-localization/references/string-catalogs.md +313 -440
- package/pipeline/skills/shared/external/ios-networking/SKILL.md +265 -341
- package/pipeline/skills/shared/external/ios-networking/evals/evals.json +24 -24
- package/pipeline/skills/shared/external/ios-networking/references/background-websocket.md +425 -652
- package/pipeline/skills/shared/external/ios-networking/references/file-storage-patterns.md +143 -285
- package/pipeline/skills/shared/external/ios-networking/references/lightweight-clients.md +93 -53
- package/pipeline/skills/shared/external/ios-networking/references/network-framework.md +231 -456
- package/pipeline/skills/shared/external/ios-networking/references/urlsession-patterns.md +517 -784
- package/pipeline/skills/shared/external/ios-simulator/SKILL.md +265 -393
- package/pipeline/skills/shared/external/ios-simulator/evals/evals.json +21 -21
- package/pipeline/skills/shared/external/ios-simulator/references/simctl-commands.md +177 -270
- package/pipeline/skills/shared/external/live-activities/SKILL.md +318 -360
- package/pipeline/skills/shared/external/live-activities/evals/evals.json +21 -21
- package/pipeline/skills/shared/external/live-activities/references/activitykit-patterns.md +478 -710
- package/pipeline/skills/shared/external/mapkit-location/SKILL.md +295 -267
- package/pipeline/skills/shared/external/mapkit-location/evals/evals.json +28 -24
- package/pipeline/skills/shared/external/mapkit-location/references/mapkit-corelocation-patterns.md +378 -532
- package/pipeline/skills/shared/external/mapkit-location/references/mapkit-patterns.md +397 -499
- package/pipeline/skills/shared/external/metrickit-diagnostics/SKILL.md +165 -348
- package/pipeline/skills/shared/external/metrickit-diagnostics/evals/evals.json +26 -23
- package/pipeline/skills/shared/external/metrickit-diagnostics/references/metrickit-patterns.md +123 -130
- package/pipeline/skills/shared/external/musickit-audio/SKILL.md +189 -315
- package/pipeline/skills/shared/external/musickit-audio/evals/evals.json +22 -21
- package/pipeline/skills/shared/external/musickit-audio/references/musickit-patterns.md +181 -270
- package/pipeline/skills/shared/external/natural-language/SKILL.md +188 -340
- package/pipeline/skills/shared/external/natural-language/evals/evals.json +21 -21
- package/pipeline/skills/shared/external/natural-language/references/translation-patterns.md +171 -225
- package/pipeline/skills/shared/external/passkit-wallet/SKILL.md +258 -392
- package/pipeline/skills/shared/external/passkit-wallet/evals/evals.json +30 -29
- package/pipeline/skills/shared/external/passkit-wallet/references/wallet-passes.md +164 -231
- package/pipeline/skills/shared/external/pdfkit/SKILL.md +312 -344
- package/pipeline/skills/shared/external/pdfkit/evals/evals.json +19 -19
- package/pipeline/skills/shared/external/pdfkit/references/pdfkit-patterns.md +413 -624
- package/pipeline/skills/shared/external/pencilkit-drawing/SKILL.md +242 -358
- package/pipeline/skills/shared/external/pencilkit-drawing/evals/evals.json +25 -21
- package/pipeline/skills/shared/external/pencilkit-drawing/references/pencilkit-patterns.md +161 -226
- package/pipeline/skills/shared/external/permissionkit/SKILL.md +282 -400
- package/pipeline/skills/shared/external/permissionkit/evals/evals.json +27 -30
- package/pipeline/skills/shared/external/permissionkit/references/permissionkit-patterns.md +237 -350
- package/pipeline/skills/shared/external/photos-camera-media/SKILL.md +276 -325
- package/pipeline/skills/shared/external/photos-camera-media/references/av-playback.md +299 -545
- package/pipeline/skills/shared/external/photos-camera-media/references/camera-capture.md +344 -588
- package/pipeline/skills/shared/external/photos-camera-media/references/image-loading-caching.md +316 -660
- package/pipeline/skills/shared/external/photos-camera-media/references/photokit-patterns.md +270 -416
- package/pipeline/skills/shared/external/push-notifications/SKILL.md +312 -340
- package/pipeline/skills/shared/external/push-notifications/evals/evals.json +27 -26
- package/pipeline/skills/shared/external/push-notifications/references/notification-patterns.md +328 -485
- package/pipeline/skills/shared/external/push-notifications/references/rich-notifications.md +327 -560
- package/pipeline/skills/shared/external/realitykit-ar/SKILL.md +218 -410
- package/pipeline/skills/shared/external/realitykit-ar/evals/evals.json +24 -27
- package/pipeline/skills/shared/external/realitykit-ar/references/realitykit-patterns.md +221 -348
- package/pipeline/skills/shared/external/shareplay-activities/SKILL.md +222 -393
- package/pipeline/skills/shared/external/shareplay-activities/evals/evals.json +23 -24
- package/pipeline/skills/shared/external/shareplay-activities/references/shareplay-patterns.md +280 -420
- package/pipeline/skills/shared/external/speech-recognition/SKILL.md +217 -421
- package/pipeline/skills/shared/external/speech-recognition/evals/evals.json +23 -26
- package/pipeline/skills/shared/external/speech-recognition/references/speechanalyzer-patterns.md +133 -125
- package/pipeline/skills/shared/external/storekit/SKILL.md +228 -204
- package/pipeline/skills/shared/external/storekit/evals/evals.json +27 -24
- package/pipeline/skills/shared/external/storekit/references/app-review-guidelines.md +98 -109
- package/pipeline/skills/shared/external/storekit/references/core-patterns.md +298 -242
- package/pipeline/skills/shared/external/storekit/references/storekit-advanced.md +356 -649
- package/pipeline/skills/shared/external/swift-api-design-guidelines/SKILL.md +274 -399
- package/pipeline/skills/shared/external/swift-api-design-guidelines/evals/evals.json +22 -24
- package/pipeline/skills/shared/external/swift-api-design-guidelines/references/argument-labels-and-parameters.md +107 -108
- package/pipeline/skills/shared/external/swift-api-design-guidelines/references/conventions-and-special-rules.md +93 -165
- package/pipeline/skills/shared/external/swift-api-design-guidelines/references/naming-and-clarity.md +99 -137
- package/pipeline/skills/shared/external/swift-api-design-guidelines/references/side-effects-and-mutating-pairs.md +77 -120
- package/pipeline/skills/shared/external/swift-architecture/SKILL.md +334 -350
- package/pipeline/skills/shared/external/swift-architecture/evals/evals.json +22 -22
- package/pipeline/skills/shared/external/swift-charts/SKILL.md +208 -394
- package/pipeline/skills/shared/external/swift-charts/evals/evals.json +27 -30
- package/pipeline/skills/shared/external/swift-charts/references/charts-patterns.md +351 -762
- package/pipeline/skills/shared/external/swift-codable/SKILL.md +339 -343
- package/pipeline/skills/shared/external/swift-codable/evals/evals.json +20 -20
- package/pipeline/skills/shared/external/swift-concurrency/SKILL.md +303 -351
- package/pipeline/skills/shared/external/swift-concurrency/evals/evals.json +27 -24
- package/pipeline/skills/shared/external/swift-concurrency/references/approachable-concurrency.md +65 -80
- package/pipeline/skills/shared/external/swift-concurrency/references/async-algorithms.md +48 -84
- package/pipeline/skills/shared/external/swift-concurrency/references/bridging-interop.md +134 -79
- package/pipeline/skills/shared/external/swift-concurrency/references/concurrency-patterns.md +145 -167
- package/pipeline/skills/shared/external/swift-concurrency/references/diagnostics.md +62 -50
- package/pipeline/skills/shared/external/swift-concurrency/references/swiftui-concurrency.md +92 -121
- package/pipeline/skills/shared/external/swift-concurrency/references/synchronization-primitives.md +177 -241
- package/pipeline/skills/shared/external/swift-formatstyle/SKILL.md +258 -234
- package/pipeline/skills/shared/external/swift-language/SKILL.md +342 -382
- package/pipeline/skills/shared/external/swift-language/evals/evals.json +24 -27
- package/pipeline/skills/shared/external/swift-language/references/swift-attributes-interop.md +79 -56
- package/pipeline/skills/shared/external/swift-language/references/swift-patterns-extended.md +297 -340
- package/pipeline/skills/shared/external/swift-security/SKILL.md +180 -161
- package/pipeline/skills/shared/external/swift-security/evals/evals.json +25 -25
- package/pipeline/skills/shared/external/swift-security/references/biometric-authentication.md +314 -469
- package/pipeline/skills/shared/external/swift-security/references/certificate-trust.md +408 -476
- package/pipeline/skills/shared/external/swift-security/references/common-anti-patterns.md +260 -530
- package/pipeline/skills/shared/external/swift-security/references/compliance-owasp-mapping.md +270 -477
- package/pipeline/skills/shared/external/swift-security/references/credential-storage-patterns.md +573 -571
- package/pipeline/skills/shared/external/swift-security/references/cryptokit-public-key.md +370 -441
- package/pipeline/skills/shared/external/swift-security/references/cryptokit-symmetric.md +332 -433
- package/pipeline/skills/shared/external/swift-security/references/keychain-access-control.md +346 -468
- package/pipeline/skills/shared/external/swift-security/references/keychain-fundamentals.md +352 -472
- package/pipeline/skills/shared/external/swift-security/references/keychain-item-classes.md +431 -432
- package/pipeline/skills/shared/external/swift-security/references/keychain-sharing.md +328 -425
- package/pipeline/skills/shared/external/swift-security/references/migration-legacy-stores.md +341 -579
- package/pipeline/skills/shared/external/swift-security/references/secure-enclave.md +396 -457
- package/pipeline/skills/shared/external/swift-security/references/testing-security-code.md +354 -614
- package/pipeline/skills/shared/external/swift-testing/SKILL.md +188 -175
- package/pipeline/skills/shared/external/swift-testing/evals/evals.json +26 -24
- package/pipeline/skills/shared/external/swift-testing/references/testing-advanced.md +80 -84
- package/pipeline/skills/shared/external/swift-testing/references/testing-patterns.md +317 -433
- package/pipeline/skills/shared/external/swiftdata/SKILL.md +392 -256
- package/pipeline/skills/shared/external/swiftdata/evals/evals.json +24 -24
- package/pipeline/skills/shared/external/swiftdata/references/core-data-coexistence.md +206 -402
- package/pipeline/skills/shared/external/swiftdata/references/indexing.md +59 -52
- package/pipeline/skills/shared/external/swiftdata/references/predicate-pitfalls.md +57 -33
- package/pipeline/skills/shared/external/swiftdata/references/swiftdata-advanced.md +354 -747
- package/pipeline/skills/shared/external/swiftdata/references/swiftdata-queries.md +300 -508
- package/pipeline/skills/shared/external/swiftlint/SKILL.md +175 -226
- package/pipeline/skills/shared/external/swiftlint/references/adoption-and-configuration.md +141 -208
- package/pipeline/skills/shared/external/swiftlint/references/custom-rules-and-analyze.md +100 -109
- package/pipeline/skills/shared/external/swiftlint/references/plugins-run-scripts-and-integrations.md +159 -179
- package/pipeline/skills/shared/external/swiftlint/references/rule-reference.md +383 -18
- package/pipeline/skills/shared/external/swiftlint/references/rules-suppressions-and-baselines.md +143 -229
- package/pipeline/skills/shared/external/swiftui-animation/SKILL.md +283 -366
- package/pipeline/skills/shared/external/swiftui-animation/references/animation-advanced.md +396 -608
- package/pipeline/skills/shared/external/swiftui-animation/references/core-animation-bridge.md +336 -385
- package/pipeline/skills/shared/external/swiftui-gestures/SKILL.md +239 -349
- package/pipeline/skills/shared/external/swiftui-gestures/references/gesture-patterns.md +228 -310
- package/pipeline/skills/shared/external/swiftui-layout-components/SKILL.md +260 -249
- package/pipeline/skills/shared/external/swiftui-layout-components/references/form.md +92 -74
- package/pipeline/skills/shared/external/swiftui-layout-components/references/grids.md +112 -177
- package/pipeline/skills/shared/external/swiftui-layout-components/references/list.md +61 -64
- package/pipeline/skills/shared/external/swiftui-layout-components/references/scrollview.md +94 -134
- package/pipeline/skills/shared/external/swiftui-liquid-glass/SKILL.md +193 -225
- package/pipeline/skills/shared/external/swiftui-liquid-glass/references/liquid-glass.md +173 -327
- package/pipeline/skills/shared/external/swiftui-navigation/SKILL.md +193 -168
- package/pipeline/skills/shared/external/swiftui-navigation/references/deeplinks.md +127 -150
- package/pipeline/skills/shared/external/swiftui-navigation/references/navigationstack.md +132 -133
- package/pipeline/skills/shared/external/swiftui-navigation/references/sheets.md +152 -117
- package/pipeline/skills/shared/external/swiftui-navigation/references/tabview.md +106 -140
- package/pipeline/skills/shared/external/swiftui-patterns/SKILL.md +316 -252
- package/pipeline/skills/shared/external/swiftui-patterns/references/architecture-patterns.md +341 -332
- package/pipeline/skills/shared/external/swiftui-patterns/references/deprecated-migration.md +547 -854
- package/pipeline/skills/shared/external/swiftui-patterns/references/design-polish.md +485 -537
- package/pipeline/skills/shared/external/swiftui-patterns/references/platform-and-sharing.md +417 -499
- package/pipeline/skills/shared/external/swiftui-performance/SKILL.md +213 -376
- package/pipeline/skills/shared/external/swiftui-performance/references/demystify-swiftui-performance-wwdc23.md +86 -175
- package/pipeline/skills/shared/external/swiftui-performance/references/optimizing-swiftui-performance-instruments.md +89 -195
- package/pipeline/skills/shared/external/swiftui-performance/references/understanding-hangs-in-your-app.md +95 -182
- package/pipeline/skills/shared/external/swiftui-performance/references/understanding-improving-swiftui-performance.md +71 -149
- package/pipeline/skills/shared/external/swiftui-performance/references/wwdc-session-sources.md +21 -27
- package/pipeline/skills/shared/external/swiftui-uikit-interop/SKILL.md +303 -295
- package/pipeline/skills/shared/external/swiftui-uikit-interop/references/hosting-migration.md +204 -387
- package/pipeline/skills/shared/external/swiftui-uikit-interop/references/representable-recipes.md +469 -683
- package/pipeline/skills/shared/external/swiftui-webkit/SKILL.md +140 -186
- package/pipeline/skills/shared/external/swiftui-webkit/references/loading-and-observation.md +75 -86
- package/pipeline/skills/shared/external/swiftui-webkit/references/local-content-and-custom-schemes.md +63 -60
- package/pipeline/skills/shared/external/swiftui-webkit/references/migration-and-fallbacks.md +69 -137
- package/pipeline/skills/shared/external/swiftui-webkit/references/navigation-and-javascript.md +95 -67
- package/pipeline/skills/shared/external/tipkit/SKILL.md +220 -335
- package/pipeline/skills/shared/external/tipkit/references/tipkit-patterns.md +356 -494
- package/pipeline/skills/shared/external/vision-framework/SKILL.md +260 -375
- package/pipeline/skills/shared/external/vision-framework/references/vision-requests.md +393 -515
- package/pipeline/skills/shared/external/vision-framework/references/visionkit-scanner.md +363 -539
- package/pipeline/skills/shared/external/weatherkit/SKILL.md +152 -310
- package/pipeline/skills/shared/external/weatherkit/references/weatherkit-patterns.md +288 -407
- package/pipeline/skills/shared/external/widgetkit/SKILL.md +216 -288
- package/pipeline/skills/shared/external/widgetkit/references/widgetkit-advanced.md +414 -719
- package/pipeline/skills/shared/external/NOTICE-swift-ios-skills.md +0 -39
package/pipeline/skills/shared/external/swift-security/references/migration-legacy-stores.md
CHANGED
|
@@ -1,747 +1,509 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Migrating Secrets Out of Legacy Stores
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
>
|
|
5
|
-
> **Applies to:** iOS 15+ (actor support, pre-warming), iOS 17+ (recommended deployment target)
|
|
6
|
-
>
|
|
7
|
-
> **Cross-references:** `keychain-fundamentals.md` (SecItem CRUD), `keychain-access-control.md` (accessibility classes), `common-anti-patterns.md` (UserDefaults secrets anti-pattern), `credential-storage-patterns.md` (token lifecycle post-migration), `testing-security-code.md` (protocol-based mocking)
|
|
3
|
+
Scope: moving credentials that an older release kept in `UserDefaults`, property lists or `NSCoding` archives into the keychain, deleting the old copies safely, wiping keychain leftovers after a reinstall, running migrations by schema version, and surviving a Team ID change after an app transfer.
|
|
8
4
|
|
|
9
|
-
|
|
5
|
+
Availability: the patterns rely on actors and on iOS 15 pre-warming behaviour, so they apply from iOS 15. Write new code against iOS 17 or later where you can.
|
|
10
6
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
- [Why Migrate - The Risk of Legacy Storage](#why-migrate-the-risk-of-legacy-storage)
|
|
14
|
-
- [The Five Correctness Traps](#the-five-correctness-traps)
|
|
15
|
-
- [First-Launch Keychain Cleanup](#first-launch-keychain-cleanup)
|
|
16
|
-
- [Atomic Migration: Read → Write → Verify → Delete](#atomic-migration-read-write-verify-delete)
|
|
17
|
-
- [Versioned Migration with Schema Tracking](#versioned-migration-with-schema-tracking)
|
|
18
|
-
- [Orphaned Items: Why You Must Never Rename kSecAttrService](#orphaned-items-why-you-must-never-rename-ksecattrservice)
|
|
19
|
-
- [Background Launch and the Locked-Device Trap](#background-launch-and-the-locked-device-trap)
|
|
20
|
-
- [The Phantom Mismatch Bug](#the-phantom-mismatch-bug)
|
|
21
|
-
- [Team ID Change: The App Transfer Edge Case](#team-id-change-the-app-transfer-edge-case)
|
|
22
|
-
- [Deferred Legacy Cleanup with Rollback Window](#deferred-legacy-cleanup-with-rollback-window)
|
|
23
|
-
- [Complete App Launch Sequence](#complete-app-launch-sequence)
|
|
24
|
-
- [Thread Safety Note](#thread-safety-note)
|
|
25
|
-
- [Testing Migration Paths](#testing-migration-paths)
|
|
26
|
-
- [Handling Very Old Versions and Collapse Strategy](#handling-very-old-versions-and-collapse-strategy)
|
|
27
|
-
- [Secure Deletion: Trust Cryptographic Erasure](#secure-deletion-trust-cryptographic-erasure)
|
|
28
|
-
- [Conclusion](#conclusion)
|
|
29
|
-
- [Summary Checklist](#summary-checklist)
|
|
30
|
-
|
|
31
|
-
## Why Migrate - The Risk of Legacy Storage
|
|
32
|
-
|
|
33
|
-
UserDefaults, `.plist` files, and NSCoding archives store data as unencrypted plaintext within the app sandbox. This data is readable on jailbroken devices and included in unencrypted iTunes/Finder backups - anyone with backup access can extract tokens, passwords, and PII. OWASP ranks insecure data storage as a top-10 mobile risk (M9).
|
|
7
|
+
Related references: [keychain-fundamentals.md](keychain-fundamentals.md) (add-or-update, OSStatus), [keychain-access-control.md](keychain-access-control.md) (accessibility classes), [common-anti-patterns.md](common-anti-patterns.md) (patterns 1 and 9), [credential-storage-patterns.md](credential-storage-patterns.md) (token lifecycles), [testing-security-code.md](testing-security-code.md) (mocks and device tests).
|
|
34
8
|
|
|
35
|
-
|
|
36
|
-
| ----------------- | ----------------- | ---------------------------------- | ---------------------- | -------------------- |
|
|
37
|
-
| UserDefaults | No | Yes | No | **No** |
|
|
38
|
-
| .plist files | No (default) | Yes | No | **No** |
|
|
39
|
-
| NSCoding archives | No (default) | Yes | No | **No** |
|
|
40
|
-
| Keychain | Yes (AES-256-GCM) | `ThisDeviceOnly` variants excluded | **Yes** | **Yes** |
|
|
41
|
-
|
|
42
|
-
Keychain items are managed by the `securityd` daemon, encrypted with per-row keys protected by the Secure Enclave, and isolated from the app sandbox. This is the only appropriate location for tokens, passwords, API keys, and PII on Apple platforms.
|
|
9
|
+
## Contents
|
|
43
10
|
|
|
44
|
-
|
|
11
|
+
- [Why the old stores are not acceptable](#why-the-old-stores-are-not-acceptable)
|
|
12
|
+
- [Five traps to avoid](#five-traps-to-avoid)
|
|
13
|
+
- [Cleaning up after a reinstall](#cleaning-up-after-a-reinstall)
|
|
14
|
+
- [Atomic migration: read, write, verify, delete](#atomic-migration-read-write-verify-delete)
|
|
15
|
+
- [Versioned migration with a schema number](#versioned-migration-with-a-schema-number)
|
|
16
|
+
- [Orphaned items after a rename](#orphaned-items-after-a-rename)
|
|
17
|
+
- [Locked devices and background launches](#locked-devices-and-background-launches)
|
|
18
|
+
- [The phantom mismatch](#the-phantom-mismatch)
|
|
19
|
+
- [Team ID change after an app transfer](#team-id-change-after-an-app-transfer)
|
|
20
|
+
- [Cleaning up legacy artefacts](#cleaning-up-legacy-artefacts)
|
|
21
|
+
- [Launch order](#launch-order)
|
|
22
|
+
- [Concurrency](#concurrency)
|
|
23
|
+
- [Testing migrations](#testing-migrations)
|
|
24
|
+
- [Very old versions](#very-old-versions)
|
|
25
|
+
- [Deleting files](#deleting-files)
|
|
26
|
+
- [Key decisions](#key-decisions)
|
|
27
|
+
- [Checklist](#checklist)
|
|
45
28
|
|
|
46
|
-
##
|
|
29
|
+
## Why the old stores are not acceptable
|
|
47
30
|
|
|
48
|
-
|
|
31
|
+
`UserDefaults`, a hand-written `.plist` and a keyed archive are all plain files inside the app container. Nothing encrypts their contents beyond the file's data protection class, so anyone with a jailbroken device or an unencrypted Finder or iTunes backup can open them. OWASP files this under M9, Insecure Data Storage.
|
|
49
32
|
|
|
50
|
-
|
|
33
|
+
| Property | UserDefaults / plist / NSCoding archive | Keychain |
|
|
34
|
+
| --- | --- | --- |
|
|
35
|
+
| Encryption of the value | None beyond file protection | AES-256-GCM per item |
|
|
36
|
+
| Included in backups | Yes | Yes, except `...ThisDeviceOnly` classes |
|
|
37
|
+
| Removed when the app is deleted | Yes | No, items outlive the app |
|
|
38
|
+
| Fit for secrets | No | Yes |
|
|
51
39
|
|
|
52
|
-
|
|
40
|
+
Keychain rows live in a database owned by the `securityd` daemon, outside the app sandbox. Each row is encrypted with its own key, and those keys are wrapped by class keys that the Secure Enclave protects.
|
|
53
41
|
|
|
54
|
-
|
|
42
|
+
## Five traps to avoid
|
|
55
43
|
|
|
56
|
-
**
|
|
44
|
+
1. **Believing deletion is the risk.** `removeObject(forKey:)` does not scrub flash storage, but it does not need to: iOS protects each file with its own AES-256 key and destroys that key through Effaceable Storage when the file goes away, which is cryptographic erasure. The exposure that remains is backups taken, unencrypted, before the migration ran. Delete each legacy key explicitly once its keychain copy is verified.
|
|
45
|
+
2. **Forgetting that keychain items outlive the app.** An iOS 10.3 beta briefly removed items on uninstall; the change was reverted and items still persist. After a reinstall, stale entries cause confusing authentication failures or silently resume the previous owner's session.
|
|
46
|
+
3. **Migrating on every launch.** Besides wasted work, a pre-warmed launch on iOS 15 and later can start before first unlock, when the encrypted `UserDefaults` file cannot be read and returns nothing. Code that reads "empty" as "nothing to migrate" skips data or writes nil over good keychain values.
|
|
47
|
+
4. **Treating write and delete as unrelated steps.** If the process dies between them, or the write failed silently, the secret is gone.
|
|
48
|
+
5. **Renaming the item's identity.** Changing `kSecAttrService` or `kSecAttrAccount` produces a new item and strands the old one as a duplicate. `SecItemUpdate` cannot change primary-key attributes, so a rename is always read old, write new, verify, delete old.
|
|
57
49
|
|
|
58
|
-
|
|
50
|
+
## Cleaning up after a reinstall
|
|
59
51
|
|
|
60
|
-
|
|
52
|
+
The two stores behave differently on uninstall: `UserDefaults` is removed, the keychain is not. A missing marker in `UserDefaults` together with keychain content therefore means "fresh install over old data".
|
|
61
53
|
|
|
62
|
-
|
|
54
|
+
Run this before anything else touches the keychain. Analytics, crash reporting, backend-as-a-service and authentication SDKs often read the keychain while they initialise, and would pick up the old session.
|
|
63
55
|
|
|
64
|
-
|
|
56
|
+
Wait for protected data before reading the marker. During pre-warm both `UserDefaults` and `WhenUnlocked` keychain items are unreadable, so the marker looks unset and the guard would wipe a signed-in user. Widely used apps mass-logged-out their users on iOS 15 by treating that empty read as "no credentials".
|
|
65
57
|
|
|
66
58
|
```swift
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
actor
|
|
71
|
-
|
|
72
|
-
private let
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
return
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
// Wipe stale keychain items from a previous installation
|
|
86
|
-
deleteAllKeychainItems()
|
|
87
|
-
|
|
88
|
-
// Set flag so this only runs once per install
|
|
89
|
-
UserDefaults.standard.set(true, forKey: hasRunKey)
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
private func deleteAllKeychainItems() {
|
|
93
|
-
let classes: [CFString] = [
|
|
94
|
-
kSecClassGenericPassword, kSecClassInternetPassword,
|
|
95
|
-
kSecClassCertificate, kSecClassKey, kSecClassIdentity
|
|
96
|
-
]
|
|
97
|
-
for itemClass in classes {
|
|
98
|
-
let query: NSDictionary = [
|
|
59
|
+
import Security
|
|
60
|
+
import UIKit
|
|
61
|
+
|
|
62
|
+
actor ReinstallSweeper {
|
|
63
|
+
private let markerKey = "vault.installMarker"
|
|
64
|
+
private let itemClasses: [CFString] = [
|
|
65
|
+
kSecClassGenericPassword, kSecClassInternetPassword,
|
|
66
|
+
kSecClassCertificate, kSecClassKey, kSecClassIdentity
|
|
67
|
+
]
|
|
68
|
+
|
|
69
|
+
func sweepIfFreshInstall() async {
|
|
70
|
+
await Self.waitUntilProtectedDataIsReadable()
|
|
71
|
+
guard !UserDefaults.standard.bool(forKey: markerKey) else { return }
|
|
72
|
+
for itemClass in itemClasses {
|
|
73
|
+
let scope: [CFString: Any] = [
|
|
99
74
|
kSecClass: itemClass,
|
|
100
75
|
kSecAttrSynchronizable: kSecAttrSynchronizableAny
|
|
101
76
|
]
|
|
102
|
-
SecItemDelete(
|
|
77
|
+
let status = SecItemDelete(scope as CFDictionary)
|
|
78
|
+
guard status == errSecSuccess || status == errSecItemNotFound else { return }
|
|
103
79
|
}
|
|
80
|
+
UserDefaults.standard.set(true, forKey: markerKey)
|
|
104
81
|
}
|
|
105
82
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
await withCheckedContinuation { continuation in
|
|
114
|
-
NotificationCenter.default.addObserver(
|
|
115
|
-
forName: UIApplication.protectedDataDidBecomeAvailableNotification,
|
|
116
|
-
object: nil, queue: .main
|
|
117
|
-
) { _ in
|
|
118
|
-
Task {
|
|
119
|
-
self.deleteAllKeychainItems()
|
|
120
|
-
UserDefaults.standard.set(true, forKey: self.hasRunKey)
|
|
121
|
-
continuation.resume()
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
}
|
|
83
|
+
@MainActor
|
|
84
|
+
static func waitUntilProtectedDataIsReadable() async {
|
|
85
|
+
guard !UIApplication.shared.isProtectedDataAvailable else { return }
|
|
86
|
+
let signals = NotificationCenter.default.notifications(
|
|
87
|
+
named: UIApplication.protectedDataDidBecomeAvailableNotification
|
|
88
|
+
)
|
|
89
|
+
for await _ in signals { return }
|
|
125
90
|
}
|
|
126
91
|
}
|
|
127
92
|
```
|
|
128
93
|
|
|
94
|
+
`kSecAttrSynchronizable: kSecAttrSynchronizableAny` is required in every cleanup query. Without it `SecItemDelete` only matches non-synchronized items and leaves iCloud Keychain entries behind. If any delete fails, the marker stays unset and the sweep runs again next launch.
|
|
95
|
+
|
|
96
|
+
What goes wrong without it:
|
|
97
|
+
|
|
129
98
|
```swift
|
|
130
|
-
// ❌ INCORRECT: No first-launch cleanup - stale keychain from previous install
|
|
131
99
|
@main
|
|
132
|
-
struct
|
|
100
|
+
struct LedgerApp: App {
|
|
133
101
|
init() {
|
|
134
|
-
|
|
135
|
-
if let token = try? keychainRead(service: "com.myapp", account: "authToken") {
|
|
136
|
-
// This token might be from a PREVIOUS user who deleted the app.
|
|
137
|
-
// The new user inherits someone else's session.
|
|
138
|
-
AuthManager.shared.restoreSession(token: token)
|
|
139
|
-
}
|
|
102
|
+
SessionStore.shared.restoreTokenFromKeychain()
|
|
140
103
|
}
|
|
141
|
-
var body: some Scene { WindowGroup {
|
|
104
|
+
var body: some Scene { WindowGroup { RootView() } }
|
|
142
105
|
}
|
|
143
106
|
```
|
|
144
107
|
|
|
145
|
-
|
|
108
|
+
Here the token is restored with no reinstall check, so a new owner of a resold device, or a different person on a reinstalled app, inherits the previous session.
|
|
146
109
|
|
|
147
|
-
|
|
110
|
+
## Atomic migration: read, write, verify, delete
|
|
148
111
|
|
|
149
|
-
|
|
112
|
+
The order never changes. Removing the plaintext before the keychain write is confirmed is the single most dangerous mistake in this area.
|
|
150
113
|
|
|
151
|
-
|
|
114
|
+
```swift
|
|
115
|
+
import Foundation
|
|
116
|
+
import Security
|
|
152
117
|
|
|
153
|
-
|
|
118
|
+
protocol MigrationKeychainProtocol: Actor {
|
|
119
|
+
func save(_ data: Data, account: String, accessibility: String) throws
|
|
120
|
+
func read(account: String) throws -> Data?
|
|
121
|
+
func delete(account: String) throws
|
|
122
|
+
func deleteAll() throws
|
|
123
|
+
}
|
|
154
124
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
let key: String
|
|
160
|
-
let succeeded: Bool
|
|
161
|
-
let error: Error?
|
|
162
|
-
}
|
|
125
|
+
struct KeychainFailure: Error {
|
|
126
|
+
let status: OSStatus
|
|
127
|
+
init(_ status: OSStatus) { self.status = status }
|
|
128
|
+
}
|
|
163
129
|
|
|
164
|
-
|
|
130
|
+
enum KeyMigrationResult: Sendable, Equatable {
|
|
131
|
+
case moved, nothingToMove, failed(reason: String)
|
|
132
|
+
}
|
|
165
133
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
134
|
+
actor DefaultsToKeychainMover {
|
|
135
|
+
private let vault: any MigrationKeychainProtocol
|
|
136
|
+
private let defaults: UserDefaults
|
|
169
137
|
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
accessible: CFString = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
|
|
175
|
-
) async -> [MigrationResult] {
|
|
176
|
-
var results: [MigrationResult] = []
|
|
138
|
+
init(vault: any MigrationKeychainProtocol, defaults: UserDefaults = .standard) {
|
|
139
|
+
self.vault = vault
|
|
140
|
+
self.defaults = defaults
|
|
141
|
+
}
|
|
177
142
|
|
|
143
|
+
func move(keys: [String]) async -> [String: KeyMigrationResult] {
|
|
144
|
+
var report: [String: KeyMigrationResult] = [:]
|
|
178
145
|
for key in keys {
|
|
146
|
+
guard let plaintext = defaults.string(forKey: key) else {
|
|
147
|
+
report[key] = .nothingToMove
|
|
148
|
+
continue
|
|
149
|
+
}
|
|
150
|
+
let payload = Data(plaintext.utf8)
|
|
179
151
|
do {
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
152
|
+
try await vault.save(payload, account: key,
|
|
153
|
+
accessibility: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly as String)
|
|
154
|
+
guard try await vault.read(account: key) == payload else {
|
|
155
|
+
report[key] = .failed(reason: "read-back mismatch")
|
|
184
156
|
continue
|
|
185
157
|
}
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
try await keychain.save(data, service: service,
|
|
189
|
-
account: key, accessible: accessible)
|
|
190
|
-
|
|
191
|
-
// STEP 3: Verify by reading back
|
|
192
|
-
let readBack = try await keychain.read(service: service, account: key)
|
|
193
|
-
guard readBack == data else {
|
|
194
|
-
throw MigrationError.verificationFailed(key: key)
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
// STEP 4: Delete from UserDefaults ONLY after verified write
|
|
198
|
-
UserDefaults.standard.removeObject(forKey: key)
|
|
199
|
-
results.append(.init(key: key, succeeded: true, error: nil))
|
|
200
|
-
|
|
158
|
+
defaults.removeObject(forKey: key)
|
|
159
|
+
report[key] = .moved
|
|
201
160
|
} catch {
|
|
202
|
-
|
|
203
|
-
results.append(.init(key: key, succeeded: false, error: error))
|
|
161
|
+
report[key] = .failed(reason: String(describing: error))
|
|
204
162
|
}
|
|
205
163
|
}
|
|
206
|
-
return
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
enum MigrationError: Error {
|
|
210
|
-
case verificationFailed(key: String)
|
|
211
|
-
case corruptArchive(path: String)
|
|
164
|
+
return report
|
|
212
165
|
}
|
|
213
166
|
}
|
|
214
167
|
```
|
|
215
168
|
|
|
169
|
+
`save` is the add-or-update helper from [keychain-fundamentals.md](keychain-fundamentals.md): `SecItemAdd` first, and on `errSecDuplicateItem` a `SecItemUpdate` with the new data. A failed key keeps its `UserDefaults` value, so the result is idempotent: moved keys read as absent next time and are skipped, failed keys are retried after a crash, a force quit or a memory kill.
|
|
170
|
+
|
|
171
|
+
The protocol takes the accessibility class as a `String`. The `kSecAttrAccessible*` constants are `CFString` globals, which are not `Sendable`, and Swift 6 rejects handing one to another actor; `as String` makes a copy that is. The conforming type turns it back with `as CFString` when it builds the query.
|
|
172
|
+
|
|
173
|
+
The version to avoid:
|
|
174
|
+
|
|
216
175
|
```swift
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
// Deletes FIRST - if keychain write fails, data is gone forever
|
|
224
|
-
UserDefaults.standard.removeObject(forKey: key) // ← CATASTROPHIC
|
|
225
|
-
|
|
226
|
-
let query: [String: Any] = [
|
|
227
|
-
kSecClass as String: kSecClassGenericPassword,
|
|
228
|
-
kSecAttrService as String: "com.myapp",
|
|
229
|
-
kSecAttrAccount as String: key,
|
|
230
|
-
kSecValueData as String: value.data(using: .utf8)!
|
|
231
|
-
]
|
|
232
|
-
let status = SecItemAdd(query as CFDictionary, nil)
|
|
233
|
-
// If status != errSecSuccess, the token is permanently lost.
|
|
234
|
-
}
|
|
235
|
-
}
|
|
176
|
+
let token = UserDefaults.standard.string(forKey: "authToken") ?? ""
|
|
177
|
+
UserDefaults.standard.removeObject(forKey: "authToken")
|
|
178
|
+
let item: [CFString: Any] = [kSecClass: kSecClassGenericPassword,
|
|
179
|
+
kSecAttrAccount: "authToken",
|
|
180
|
+
kSecValueData: Data(token.utf8)]
|
|
181
|
+
SecItemAdd(item as CFDictionary, nil)
|
|
236
182
|
```
|
|
237
183
|
|
|
238
|
-
The
|
|
239
|
-
|
|
240
|
-
---
|
|
184
|
+
The plaintext is gone before the add, and the add's status is ignored, so any failure loses the token.
|
|
241
185
|
|
|
242
|
-
## Versioned
|
|
186
|
+
## Versioned migration with a schema number
|
|
243
187
|
|
|
244
|
-
|
|
188
|
+
Store the schema version in the keychain, not in `UserDefaults`, so it survives a reinstall together with the items it describes.
|
|
245
189
|
|
|
246
190
|
```swift
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
case upToDate
|
|
257
|
-
case migrated(from: Int, to: Int)
|
|
258
|
-
case deferred(reason: String)
|
|
259
|
-
case failed(Error)
|
|
260
|
-
}
|
|
191
|
+
import OSLog
|
|
192
|
+
import UIKit
|
|
193
|
+
|
|
194
|
+
enum MigrationState: Sendable, Equatable {
|
|
195
|
+
case upToDate
|
|
196
|
+
case migrated(from: Int, to: Int)
|
|
197
|
+
case deferred(reason: String)
|
|
198
|
+
case failed(reason: String)
|
|
199
|
+
}
|
|
261
200
|
|
|
262
|
-
|
|
263
|
-
// Guard: protected data must be available (pre-warming defense)
|
|
264
|
-
let dataAvailable = await MainActor.run {
|
|
265
|
-
UIApplication.shared.isProtectedDataAvailable
|
|
266
|
-
}
|
|
267
|
-
guard dataAvailable else {
|
|
268
|
-
return .deferred(reason: "Device locked - protected data unavailable")
|
|
269
|
-
}
|
|
201
|
+
enum MigrationError: Error { case keysLeftBehind(Int), corruptArchive, readBackMismatch }
|
|
270
202
|
|
|
271
|
-
|
|
272
|
-
|
|
203
|
+
actor SchemaMigrator {
|
|
204
|
+
static let targetVersion = 3
|
|
205
|
+
private let vault: any MigrationKeychainProtocol
|
|
206
|
+
private let versionAccount = "vault.schemaVersion"
|
|
207
|
+
private let log = OSLog(subsystem: "com.example.ledger", category: "KeychainMigration")
|
|
273
208
|
|
|
209
|
+
init(vault: any MigrationKeychainProtocol) { self.vault = vault }
|
|
210
|
+
|
|
211
|
+
func migrateIfNeeded() async -> MigrationState {
|
|
212
|
+
let readable = await MainActor.run { UIApplication.shared.isProtectedDataAvailable }
|
|
213
|
+
guard readable else { return .deferred(reason: "device locked") }
|
|
274
214
|
do {
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
if storedVersion < 2 {
|
|
280
|
-
try await migrateV1toV2_NSCodingArchivesToKeychain()
|
|
281
|
-
}
|
|
282
|
-
if storedVersion < 3 {
|
|
283
|
-
try await migrateV2toV3_UpgradeAccessibilityClass()
|
|
215
|
+
let current = try await storedVersion()
|
|
216
|
+
guard current < Self.targetVersion else { return .upToDate }
|
|
217
|
+
for step in current..<Self.targetVersion {
|
|
218
|
+
try await run(stepFrom: step)
|
|
284
219
|
}
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
return .migrated(from: storedVersion, to: Self.currentSchemaVersion)
|
|
220
|
+
try await vault.save(Data(String(Self.targetVersion).utf8), account: versionAccount,
|
|
221
|
+
accessibility: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly as String)
|
|
222
|
+
return .migrated(from: current, to: Self.targetVersion)
|
|
289
223
|
} catch {
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
return .failed(error)
|
|
224
|
+
os_log("migration stopped: %{public}@", log: log, type: .error,
|
|
225
|
+
String(describing: type(of: error)))
|
|
226
|
+
return .failed(reason: String(describing: error))
|
|
294
227
|
}
|
|
295
228
|
}
|
|
296
229
|
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
guard let data = try? keychainRead(
|
|
301
|
-
service: serviceName, account: schemaVersionAccount),
|
|
302
|
-
let str = String(data: data, encoding: .utf8),
|
|
303
|
-
let version = Int(str) else { return 0 }
|
|
304
|
-
return version
|
|
305
|
-
}
|
|
306
|
-
|
|
307
|
-
private func saveSchemaVersion(_ version: Int) throws {
|
|
308
|
-
let data = "\(version)".data(using: .utf8)!
|
|
309
|
-
try keychainSave(data, service: serviceName,
|
|
310
|
-
account: schemaVersionAccount)
|
|
230
|
+
private func storedVersion() async throws -> Int {
|
|
231
|
+
guard let raw = try await vault.read(account: versionAccount) else { return 0 }
|
|
232
|
+
return Int(String(decoding: raw, as: UTF8.self)) ?? 0
|
|
311
233
|
}
|
|
312
234
|
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
service: serviceName
|
|
320
|
-
)
|
|
321
|
-
// Check for critical failures (non-nil keys that didn't migrate)
|
|
322
|
-
let failures = results.filter { !$0.succeeded }
|
|
323
|
-
if !failures.isEmpty {
|
|
324
|
-
os_log(.error, log: .migration,
|
|
325
|
-
"V1 migration: %d keys failed", failures.count)
|
|
235
|
+
private func run(stepFrom version: Int) async throws {
|
|
236
|
+
switch version {
|
|
237
|
+
case 0: try await moveDefaults()
|
|
238
|
+
case 1: try await moveArchive()
|
|
239
|
+
case 2: try await tightenAccessibility()
|
|
240
|
+
default: break
|
|
326
241
|
}
|
|
327
|
-
// Force-sync UserDefaults deletions to disk
|
|
328
|
-
UserDefaults.standard.synchronize()
|
|
329
242
|
}
|
|
243
|
+
}
|
|
244
|
+
```
|
|
330
245
|
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
private func migrateV1toV2_NSCodingArchivesToKeychain() async throws {
|
|
334
|
-
let documentsURL = FileManager.default.urls(
|
|
335
|
-
for: .documentDirectory, in: .userDomainMask).first!
|
|
336
|
-
let archiveURL = documentsURL.appendingPathComponent("UserSession.archive")
|
|
337
|
-
|
|
338
|
-
guard FileManager.default.fileExists(atPath: archiveURL.path) else { return }
|
|
339
|
-
|
|
340
|
-
let archiveData = try Data(contentsOf: archiveURL)
|
|
341
|
-
guard let session = try NSKeyedUnarchiver.unarchivedObject(
|
|
342
|
-
ofClass: LegacySession.self, from: archiveData) else {
|
|
343
|
-
throw AtomicMigrator.MigrationError.corruptArchive(path: archiveURL.path)
|
|
344
|
-
}
|
|
345
|
-
|
|
346
|
-
let sessionData = try JSONEncoder().encode(session.toModernSession())
|
|
347
|
-
try keychainSave(sessionData, service: serviceName, account: "userSession")
|
|
246
|
+
The steps, one per version:
|
|
348
247
|
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
throw AtomicMigrator.MigrationError.verificationFailed(key: "userSession")
|
|
353
|
-
}
|
|
354
|
-
try FileManager.default.removeItem(at: archiveURL)
|
|
355
|
-
}
|
|
248
|
+
- **v0 to v1, UserDefaults.** Run `DefaultsToKeychainMover` over the named keys, log how many failed, and throw if any did so the version does not advance. Older samples call `UserDefaults.standard.synchronize()` afterwards; it is harmless and no longer needed.
|
|
249
|
+
- **v1 to v2, NSCoding archive.** If the archive file exists, decode it with `NSKeyedUnarchiver.unarchivedObject(ofClass:from:)` (a corrupt file throws), encode the modern session value as JSON, save it to the keychain, read it back, and only then call `FileManager.default.removeItem(at:)`.
|
|
250
|
+
- **v2 to v3, stricter accessibility.** Re-save every existing item with `kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly` through add-or-update; the duplicate branch runs `SecItemUpdate` with the new `kSecAttrAccessible` in the attributes dictionary. Items that carry a `SecAccessControl` are the exception: changing their access control means delete and re-add.
|
|
356
251
|
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
private func
|
|
360
|
-
let
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
// updates the accessibility class via SecItemUpdate
|
|
366
|
-
try keychainSave(data, service: serviceName, account: account,
|
|
367
|
-
accessible: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly)
|
|
252
|
+
```swift
|
|
253
|
+
extension SchemaMigrator {
|
|
254
|
+
private func moveArchive() async throws {
|
|
255
|
+
let file = URL.applicationSupportDirectory.appending(path: "Session.archive")
|
|
256
|
+
guard FileManager.default.fileExists(atPath: file.path()) else { return }
|
|
257
|
+
let raw = try Data(contentsOf: file)
|
|
258
|
+
guard let old = try NSKeyedUnarchiver.unarchivedObject(ofClass: LegacySession.self, from: raw) else {
|
|
259
|
+
throw MigrationError.corruptArchive
|
|
368
260
|
}
|
|
261
|
+
let modern = try JSONEncoder().encode(SessionRecord(userID: old.userID, accessToken: old.token))
|
|
262
|
+
try await vault.save(modern, account: "session",
|
|
263
|
+
accessibility: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly as String)
|
|
264
|
+
guard try await vault.read(account: "session") == modern else { throw MigrationError.readBackMismatch }
|
|
265
|
+
try FileManager.default.removeItem(at: file)
|
|
369
266
|
}
|
|
370
267
|
}
|
|
371
|
-
|
|
372
|
-
private extension OSLog {
|
|
373
|
-
static let migration = OSLog(
|
|
374
|
-
subsystem: Bundle.main.bundleIdentifier ?? "com.myapp",
|
|
375
|
-
category: "KeychainMigration"
|
|
376
|
-
)
|
|
377
|
-
}
|
|
378
268
|
```
|
|
379
269
|
|
|
380
|
-
|
|
381
|
-
// ❌ INCORRECT: Runs every launch, no version check, no verification, no legacy delete
|
|
382
|
-
func brokenMigration() {
|
|
383
|
-
// No version check - runs every single launch
|
|
384
|
-
// No isProtectedDataAvailable check - fails during pre-warm
|
|
385
|
-
if let token = UserDefaults.standard.string(forKey: "authToken") {
|
|
386
|
-
let query: [String: Any] = [
|
|
387
|
-
kSecClass as String: kSecClassGenericPassword,
|
|
388
|
-
kSecAttrService as String: "com.myapp",
|
|
389
|
-
kSecAttrAccount as String: "authToken",
|
|
390
|
-
kSecValueData as String: token.data(using: .utf8)!
|
|
391
|
-
]
|
|
392
|
-
// No errSecDuplicateItem handling - crashes on second launch
|
|
393
|
-
SecItemAdd(query as CFDictionary, nil)
|
|
394
|
-
// Never deletes from UserDefaults - plaintext secret persists
|
|
395
|
-
// No verification that write succeeded
|
|
396
|
-
}
|
|
397
|
-
}
|
|
398
|
-
```
|
|
270
|
+
`LegacySession` stands for the app's existing `NSSecureCoding` class and `SessionRecord` for a new `Codable` struct.
|
|
399
271
|
|
|
400
|
-
|
|
272
|
+
A migration that runs every launch, skips the protected-data check, ignores `errSecDuplicateItem`, never reads back and never removes the `UserDefaults` copy shows every defect this file warns about.
|
|
401
273
|
|
|
402
|
-
|
|
274
|
+
Why a chain of single steps instead of direct jumps: each step is tested once and reused, a user skipping several releases simply runs every step in order, and a crash in the middle leaves the stored version at its old value so the next launch retries cleanly. Log through a dedicated `OSLog` subsystem with a `KeychainMigration` category so the steps can be filtered in Console.
|
|
403
275
|
|
|
404
|
-
## Orphaned
|
|
276
|
+
## Orphaned items after a rename
|
|
405
277
|
|
|
406
|
-
|
|
407
|
-
// ❌ INCORRECT: SecItemUpdate CANNOT change primary key attributes
|
|
408
|
-
let query: [String: Any] = [
|
|
409
|
-
kSecClass as String: kSecClassGenericPassword,
|
|
410
|
-
kSecAttrService as String: "OldServiceName",
|
|
411
|
-
kSecAttrAccount as String: "authToken"
|
|
412
|
-
]
|
|
413
|
-
let update: [String: Any] = [
|
|
414
|
-
kSecAttrService as String: "com.mycompany.myapp" // ERROR: primary key
|
|
415
|
-
]
|
|
416
|
-
// SecItemUpdate returns an error - primary keys are immutable via Update
|
|
417
|
-
SecItemUpdate(query as CFDictionary, update as CFDictionary)
|
|
418
|
-
```
|
|
278
|
+
`SecItemUpdate` with a new `kSecAttrService` in the attributes dictionary fails, because the service is part of the item's primary key. Rekey instead:
|
|
419
279
|
|
|
420
280
|
```swift
|
|
421
|
-
|
|
422
|
-
func migrateServiceName() async throws {
|
|
423
|
-
let oldService = "OldServiceName"
|
|
424
|
-
let newService = "com.mycompany.myapp"
|
|
425
|
-
let accounts = ["authToken", "refreshToken"]
|
|
426
|
-
|
|
281
|
+
func rekey(accounts: [String], from oldService: String, to newService: String) throws {
|
|
427
282
|
for account in accounts {
|
|
428
|
-
let
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
guard
|
|
438
|
-
throw
|
|
283
|
+
let lookup: [CFString: Any] = [kSecClass: kSecClassGenericPassword,
|
|
284
|
+
kSecAttrService: oldService,
|
|
285
|
+
kSecAttrAccount: account,
|
|
286
|
+
kSecReturnData: true]
|
|
287
|
+
var found: CFTypeRef?
|
|
288
|
+
let status = SecItemCopyMatching(lookup as CFDictionary, &found)
|
|
289
|
+
if status == errSecItemNotFound { continue }
|
|
290
|
+
guard status == errSecSuccess, let secret = found as? Data else { throw KeychainFailure(status) }
|
|
291
|
+
try KeychainVault.upsert(secret, service: newService, account: account)
|
|
292
|
+
guard try KeychainVault.read(service: newService, account: account) == secret else {
|
|
293
|
+
throw KeychainFailure(errSecDecode)
|
|
439
294
|
}
|
|
440
|
-
|
|
295
|
+
let oldItem: [CFString: Any] = [kSecClass: kSecClassGenericPassword,
|
|
296
|
+
kSecAttrService: oldService,
|
|
297
|
+
kSecAttrAccount: account]
|
|
298
|
+
SecItemDelete(oldItem as CFDictionary)
|
|
441
299
|
}
|
|
442
300
|
}
|
|
443
301
|
```
|
|
444
302
|
|
|
445
|
-
|
|
303
|
+
`KeychainVault` stands for the app's add-or-update helper described in [keychain-fundamentals.md](keychain-fundamentals.md). The better fix is to never need this: choose `kSecAttrService` once, typically the bundle identifier, and keep it forever.
|
|
446
304
|
|
|
447
|
-
|
|
305
|
+
## Locked devices and background launches
|
|
448
306
|
|
|
449
|
-
|
|
307
|
+
Pre-warming and background work (remote notifications, background fetch, Live Activity updates) can run while the device is locked. The item's accessibility class decides whether reads succeed.
|
|
450
308
|
|
|
451
|
-
|
|
309
|
+
| Class | Readable in the background | Notes |
|
|
310
|
+
| --- | --- | --- |
|
|
311
|
+
| `WhenUnlocked` (default) | No | Foreground only, backed up and eligible for sync |
|
|
312
|
+
| `AfterFirstUnlockThisDeviceOnly` | Yes, after first unlock | Recommended; stays on this device |
|
|
313
|
+
| `AfterFirstUnlock` | Yes, after first unlock | Also backed up and eligible for sync; use only when needed |
|
|
314
|
+
| `WhenPasscodeSetThisDeviceOnly` | No | For biometric-gated items; removed if the passcode is removed |
|
|
315
|
+
| `Always` | Yes | Deprecated in iOS 12; do not use |
|
|
452
316
|
|
|
453
|
-
|
|
317
|
+
Migrated credentials default to `kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly`: usable from background code, never backed up, never synced.
|
|
454
318
|
|
|
455
|
-
|
|
456
|
-
| -------------------------------------------------- | --------------------- | --------------- | ---------------------------------------------------- |
|
|
457
|
-
| `kSecAttrAccessibleWhenUnlocked` (default) | No | No | Foreground only |
|
|
458
|
-
| `kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly` | After first unlock | Yes | **Recommended** - background + device-bound |
|
|
459
|
-
| `kSecAttrAccessibleAfterFirstUnlock` | After first unlock | Yes | Background + backup migration (use only when needed) |
|
|
460
|
-
| `kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly` | No | No | Biometric-gated items |
|
|
461
|
-
| `kSecAttrAccessibleAlways` | Yes | Yes | **Deprecated iOS 12** - do not use |
|
|
319
|
+
On sync: every `...ThisDeviceOnly` class keeps the item on this device. Such an item is never restored to another device and never syncs through iCloud Keychain, and asking for both (`kSecAttrSynchronizable: true` with a `ThisDeviceOnly` class) is rejected by `SecItemAdd` with `errSecParam`. Pick a non-ThisDeviceOnly class only when the item is meant to sync.
|
|
462
320
|
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
A critical trap: **`SecItemDelete` does NOT require the item's protection-class key material** - it succeeds even when the item's data is unreadable due to lock state. This enables a devastating anti-pattern:
|
|
321
|
+
`SecItemDelete` does not need the class key, so it succeeds even while the data is unreadable. That makes this pattern destructive:
|
|
466
322
|
|
|
467
323
|
```swift
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
var result: AnyObject?
|
|
471
|
-
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
|
472
|
-
|
|
473
|
-
if status != errSecSuccess {
|
|
474
|
-
// "Can't read? Must be corrupted. Delete and start fresh."
|
|
475
|
-
SecItemDelete(query as CFDictionary) // ← DESTROYS VALID TOKEN
|
|
476
|
-
// During background launch with WhenUnlocked, the read fails
|
|
477
|
-
// with -25308 (interaction not allowed), but delete succeeds.
|
|
478
|
-
}
|
|
479
|
-
}
|
|
480
|
-
|
|
481
|
-
// ✅ CORRECT: Distinguish "not found" from "device locked"
|
|
482
|
-
func safeTokenRead() throws -> Data? {
|
|
483
|
-
var result: AnyObject?
|
|
484
|
-
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
|
485
|
-
|
|
486
|
-
switch status {
|
|
487
|
-
case errSecSuccess:
|
|
488
|
-
return result as? Data
|
|
489
|
-
case errSecItemNotFound:
|
|
490
|
-
return nil // Genuinely absent
|
|
491
|
-
case errSecInteractionNotAllowed:
|
|
492
|
-
// Device locked - item exists but unreadable right now.
|
|
493
|
-
// Do NOT delete. Do NOT treat as missing. Retry later.
|
|
494
|
-
throw KeychainError.interactionNotAllowed
|
|
495
|
-
default:
|
|
496
|
-
throw KeychainError.unexpectedStatus(status)
|
|
497
|
-
}
|
|
324
|
+
if SecItemCopyMatching(query as CFDictionary, &out) != errSecSuccess {
|
|
325
|
+
SecItemDelete(query as CFDictionary)
|
|
498
326
|
}
|
|
499
327
|
```
|
|
500
328
|
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
---
|
|
504
|
-
|
|
505
|
-
## The Phantom Mismatch Bug
|
|
506
|
-
|
|
507
|
-
Including `kSecAttrAccessible` in a search query causes a "not-found then duplicate" paradox. The search filters by accessibility class, but the item was stored with a different class - so `SecItemCopyMatching` returns `errSecItemNotFound` while `SecItemAdd` sees the item via primary key and returns `errSecDuplicateItem`.
|
|
329
|
+
During a locked background launch the read returns `errSecInteractionNotAllowed` (-25308), the delete succeeds, and a valid token is gone. Map the statuses instead:
|
|
508
330
|
|
|
509
331
|
```swift
|
|
510
|
-
|
|
511
|
-
let query: [String: Any] = [
|
|
512
|
-
kSecClass as String: kSecClassGenericPassword,
|
|
513
|
-
kSecAttrService as String: service,
|
|
514
|
-
kSecAttrAccount as String: account,
|
|
515
|
-
kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlocked, // ← BUG
|
|
516
|
-
kSecReturnData as String: kCFBooleanTrue as Any
|
|
517
|
-
]
|
|
518
|
-
// If stored with AfterFirstUnlock, query returns errSecItemNotFound.
|
|
519
|
-
// But SecItemAdd sees the item via primary key → errSecDuplicateItem. Deadlock.
|
|
520
|
-
```
|
|
332
|
+
enum VaultError: Error { case lockedTryLater, unexpected(OSStatus) }
|
|
521
333
|
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
334
|
+
func readToken(_ query: [CFString: Any]) throws -> Data? {
|
|
335
|
+
var out: CFTypeRef?
|
|
336
|
+
let status = SecItemCopyMatching(query as CFDictionary, &out)
|
|
337
|
+
switch status {
|
|
338
|
+
case errSecSuccess: return out as? Data
|
|
339
|
+
case errSecItemNotFound: return nil
|
|
340
|
+
case errSecInteractionNotAllowed: throw VaultError.lockedTryLater
|
|
341
|
+
default: throw VaultError.unexpected(status)
|
|
342
|
+
}
|
|
343
|
+
}
|
|
532
344
|
```
|
|
533
345
|
|
|
534
|
-
|
|
346
|
+
`errSecInteractionNotAllowed` is neither "missing" nor a reason to delete; retry once protected data is available. Gate migration on `isProtectedDataAvailable`, defer it through `protectedDataDidBecomeAvailableNotification`, and never interpret an empty read on a locked device as "nothing to migrate".
|
|
535
347
|
|
|
536
|
-
##
|
|
348
|
+
## The phantom mismatch
|
|
537
349
|
|
|
538
|
-
|
|
350
|
+
Putting `kSecAttrAccessible` into a search query turns it into a filter. If the stored item has a different class, `SecItemCopyMatching` reports `errSecItemNotFound` while `SecItemAdd` reports `errSecDuplicateItem` for the same item, and the code loops between the two. Search with primary-key attributes only (`kSecClass`, `kSecAttrService`, `kSecAttrAccount`). Accessibility belongs in the `SecItemAdd` dictionary or in the attributes-to-update dictionary of `SecItemUpdate`.
|
|
539
351
|
|
|
540
|
-
|
|
352
|
+
## Team ID change after an app transfer
|
|
541
353
|
|
|
542
|
-
|
|
543
|
-
2. Transfer the app to the new developer account
|
|
544
|
-
3. First release under the new Team ID reads from the temporary store, writes to the new keychain, verifies, and deletes the temporary data
|
|
354
|
+
Moving the app to another developer account changes the Team ID, which is part of every keychain access group. All existing items become unreadable and every user is signed out. There is no recovery afterwards, so plan a bridge well before the transfer:
|
|
545
355
|
|
|
546
|
-
|
|
356
|
+
1. Ship an update under the old Team ID that exports the needed values into a temporary app-group container or an encrypted file in the sandbox.
|
|
357
|
+
2. Transfer the app.
|
|
358
|
+
3. In the first release under the new team, import the values, write them to the keychain, verify them, then delete the temporary copy.
|
|
547
359
|
|
|
548
|
-
|
|
360
|
+
## Cleaning up legacy artefacts
|
|
549
361
|
|
|
550
|
-
|
|
362
|
+
Secret values are deleted as soon as their keychain copy is verified, in the same step that moved them. Keeping plaintext credentials around "just in case" preserves exactly the exposure the migration exists to remove, and Apple's guidance is that `UserDefaults` and plain files are not for sensitive data.
|
|
551
363
|
|
|
552
|
-
|
|
364
|
+
A rollback window is still useful for what is left: non-secret legacy files and preference domains that an older build or a support engineer might want. Record the migration date in the keychain and clear those artefacts after roughly one release cycle, such as 30 days.
|
|
553
365
|
|
|
554
366
|
```swift
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
private let
|
|
558
|
-
private let
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
let
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
guard days >= cleanupDelayDays else { return }
|
|
570
|
-
|
|
571
|
-
// Past rollback window - safe to permanently delete legacy files
|
|
572
|
-
let documentsURL = FileManager.default.urls(
|
|
573
|
-
for: .documentDirectory, in: .userDomainMask).first!
|
|
574
|
-
for file in ["UserSession.archive", "Credentials.plist", "TokenCache.dat"] {
|
|
575
|
-
try? FileManager.default.removeItem(
|
|
576
|
-
at: documentsURL.appendingPathComponent(file))
|
|
367
|
+
actor LegacyArtefactJanitor {
|
|
368
|
+
private let vault: any MigrationKeychainProtocol
|
|
369
|
+
private let window: TimeInterval = 30 * 24 * 60 * 60
|
|
370
|
+
private let leftovers = ["SessionCache.archive", "Preferences.plist", "FeedCache.dat"]
|
|
371
|
+
|
|
372
|
+
init(vault: any MigrationKeychainProtocol) { self.vault = vault }
|
|
373
|
+
|
|
374
|
+
func sweepIfWindowElapsed(now: Date = .now) async throws {
|
|
375
|
+
guard let stamp = try await vault.read(account: "vault.migratedAt"),
|
|
376
|
+
let migratedAt = ISO8601DateFormatter().date(from: String(decoding: stamp, as: UTF8.self)),
|
|
377
|
+
now.timeIntervalSince(migratedAt) > window else { return }
|
|
378
|
+
let base = URL.documentsDirectory
|
|
379
|
+
for name in leftovers {
|
|
380
|
+
try? FileManager.default.removeItem(at: base.appending(path: name))
|
|
577
381
|
}
|
|
578
|
-
if let
|
|
579
|
-
UserDefaults.standard.removePersistentDomain(forName:
|
|
382
|
+
if let domain = Bundle.main.bundleIdentifier {
|
|
383
|
+
UserDefaults.standard.removePersistentDomain(forName: domain)
|
|
580
384
|
}
|
|
581
385
|
}
|
|
582
386
|
}
|
|
583
387
|
```
|
|
584
388
|
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
## Complete App Launch Sequence
|
|
588
|
-
|
|
589
|
-
The correct ordering at app startup is critical. Keychain cleanup must happen before SDK initialization, migration must wait for protected data, and schema version gates all logic.
|
|
389
|
+
## Launch order
|
|
590
390
|
|
|
591
391
|
```swift
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
struct MyApp: App {
|
|
595
|
-
@UIApplicationDelegateAdaptor(AppDelegate.self) var delegate
|
|
596
|
-
var body: some Scene { WindowGroup { ContentView() } }
|
|
597
|
-
}
|
|
392
|
+
import SwiftUI
|
|
393
|
+
import OSLog
|
|
598
394
|
|
|
599
|
-
class
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
|
603
|
-
) -> Bool {
|
|
604
|
-
Task {
|
|
605
|
-
// 1. First-launch cleanup (stale keychain from previous install)
|
|
606
|
-
await FirstLaunchGuard.shared.performCleanupIfNeeded()
|
|
607
|
-
|
|
608
|
-
// 2. Versioned migration
|
|
609
|
-
let state = await MigrationCoordinator.shared.migrateIfNeeded()
|
|
610
|
-
switch state {
|
|
611
|
-
case .upToDate: break
|
|
612
|
-
case .migrated(let from, let to):
|
|
613
|
-
os_log(.info, "Migrated schema v%d → v%d", from, to)
|
|
614
|
-
case .deferred(let reason):
|
|
615
|
-
os_log(.info, "Migration deferred: %{public}@", reason)
|
|
616
|
-
case .failed(let error):
|
|
617
|
-
os_log(.error, "Migration failed: %{public}@",
|
|
618
|
-
error.localizedDescription)
|
|
619
|
-
}
|
|
620
|
-
|
|
621
|
-
// 3. Deferred cleanup of legacy files past rollback window
|
|
622
|
-
await DeferredCleanup().cleanupIfExpired()
|
|
395
|
+
final class LaunchDelegate: NSObject, UIApplicationDelegate {
|
|
396
|
+
private let vault = LiveMigrationKeychain()
|
|
397
|
+
private let log = Logger(subsystem: "com.example.ledger", category: "KeychainMigration")
|
|
623
398
|
|
|
624
|
-
|
|
625
|
-
|
|
399
|
+
func application(_ application: UIApplication,
|
|
400
|
+
didFinishLaunchingWithOptions options: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool {
|
|
401
|
+
Task {
|
|
402
|
+
await ReinstallSweeper().sweepIfFreshInstall()
|
|
403
|
+
let state = await SchemaMigrator(vault: vault).migrateIfNeeded()
|
|
404
|
+
log.info("migration state: \(String(describing: state), privacy: .public)")
|
|
405
|
+
try? await LegacyArtefactJanitor(vault: vault).sweepIfWindowElapsed()
|
|
406
|
+
ThirdPartyServices.start()
|
|
626
407
|
}
|
|
627
408
|
return true
|
|
628
409
|
}
|
|
629
410
|
}
|
|
630
|
-
```
|
|
631
|
-
|
|
632
|
-
---
|
|
633
411
|
|
|
634
|
-
|
|
412
|
+
@main
|
|
413
|
+
struct LedgerApp: App {
|
|
414
|
+
@UIApplicationDelegateAdaptor(LaunchDelegate.self) private var delegate
|
|
415
|
+
var body: some Scene { WindowGroup { RootView() } }
|
|
416
|
+
}
|
|
417
|
+
```
|
|
635
418
|
|
|
636
|
-
|
|
419
|
+
The order is fixed: reinstall sweep, schema migration, artefact cleanup, and only then the analytics, crash reporting and authentication SDKs.
|
|
637
420
|
|
|
638
|
-
|
|
421
|
+
## Concurrency
|
|
639
422
|
|
|
640
|
-
|
|
423
|
+
The `SecItem*` functions are safe to call from any thread. The wrapper's own state (caches, flags, the stored version) is not, so protect it; an actor is the simplest choice when the deployment target allows it, otherwise a serial queue.
|
|
641
424
|
|
|
642
|
-
|
|
425
|
+
## Testing migrations
|
|
643
426
|
|
|
644
|
-
| Aspect
|
|
645
|
-
|
|
|
646
|
-
| Data
|
|
647
|
-
|
|
|
648
|
-
| `errSecInteractionNotAllowed` |
|
|
649
|
-
| Lock
|
|
427
|
+
| Aspect | Simulator | Device |
|
|
428
|
+
| --- | --- | --- |
|
|
429
|
+
| Data protection | Not enforced | Enforced |
|
|
430
|
+
| Entitlement checks | Lenient | Strict |
|
|
431
|
+
| `errSecInteractionNotAllowed` | Rare | Returned while locked |
|
|
432
|
+
| Lock-state testing | Not possible | Required |
|
|
650
433
|
|
|
651
|
-
|
|
434
|
+
An in-memory mock that conforms to the same protocol lets unit tests inject failures:
|
|
652
435
|
|
|
653
436
|
```swift
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
accessible: CFString) throws
|
|
658
|
-
func read(service: String, account: String) throws -> Data
|
|
659
|
-
func delete(service: String, account: String) throws
|
|
660
|
-
func deleteAll()
|
|
661
|
-
}
|
|
437
|
+
actor InMemoryMigrationKeychain: MigrationKeychainProtocol {
|
|
438
|
+
private var rows: [String: [String: Data]] = [:]
|
|
439
|
+
var injectedStatus: OSStatus?
|
|
662
440
|
|
|
663
|
-
|
|
664
|
-
actor MockMigrationKeychain: MigrationKeychainProtocol {
|
|
665
|
-
var store: [String: [String: Data]] = [:]
|
|
666
|
-
var simulatedError: KeychainError?
|
|
441
|
+
func inject(_ status: OSStatus?) { injectedStatus = status }
|
|
667
442
|
|
|
668
|
-
func save(_ data: Data,
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
store[service, default: [:]][account] = data
|
|
443
|
+
func save(_ data: Data, account: String, accessibility: String) throws {
|
|
444
|
+
if let status = injectedStatus { throw KeychainFailure(status) }
|
|
445
|
+
rows[accessibility, default: [:]][account] = data
|
|
672
446
|
}
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
if let error = simulatedError { throw error }
|
|
676
|
-
guard let data = store[service]?[account] else {
|
|
677
|
-
throw KeychainError.itemNotFound
|
|
678
|
-
}
|
|
679
|
-
return data
|
|
447
|
+
func read(account: String) throws -> Data? {
|
|
448
|
+
rows.values.lazy.compactMap { $0[account] }.first
|
|
680
449
|
}
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
store[service]?[account] = nil
|
|
450
|
+
func delete(account: String) throws {
|
|
451
|
+
for key in rows.keys { rows[key]?[account] = nil }
|
|
684
452
|
}
|
|
685
|
-
|
|
686
|
-
func deleteAll() { store.removeAll() }
|
|
453
|
+
func deleteAll() throws { rows.removeAll() }
|
|
687
454
|
}
|
|
688
455
|
```
|
|
689
456
|
|
|
690
457
|
```swift
|
|
691
|
-
|
|
692
|
-
@Test func migrationPreservesLegacyDataOnKeychainFailure() async {
|
|
693
|
-
let mock = MockMigrationKeychain()
|
|
694
|
-
mock.simulatedError = .unexpectedStatus(-25308) // Simulate locked device
|
|
458
|
+
import Testing
|
|
695
459
|
|
|
696
|
-
|
|
697
|
-
|
|
460
|
+
@Test func lockedWriteLeavesPlaintextForRetry() async throws {
|
|
461
|
+
let suite = try #require(UserDefaults(suiteName: "migration-test"))
|
|
462
|
+
suite.removePersistentDomain(forName: "migration-test")
|
|
463
|
+
suite.set("refresh-abc", forKey: "refreshToken")
|
|
464
|
+
let vault = InMemoryMigrationKeychain()
|
|
465
|
+
await vault.inject(errSecInteractionNotAllowed)
|
|
698
466
|
|
|
699
|
-
let
|
|
700
|
-
let
|
|
701
|
-
["authToken"], service: "com.myapp"
|
|
702
|
-
)
|
|
467
|
+
let mover = DefaultsToKeychainMover(vault: vault, defaults: try #require(UserDefaults(suiteName: "migration-test")))
|
|
468
|
+
let report = await mover.move(keys: ["refreshToken"])
|
|
703
469
|
|
|
704
|
-
#expect(
|
|
705
|
-
#expect(
|
|
470
|
+
#expect(suite.string(forKey: "refreshToken") == "refresh-abc")
|
|
471
|
+
#expect(report["refreshToken"] != .moved)
|
|
706
472
|
}
|
|
707
473
|
```
|
|
708
474
|
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
---
|
|
712
|
-
|
|
713
|
-
## Handling Very Old Versions and Collapse Strategy
|
|
714
|
-
|
|
715
|
-
The App Store always delivers the latest binary - a user jumping from v1.0 to v3.0 never installs v2.0. Your v3.0 binary must contain migration logic for every historical schema version.
|
|
716
|
-
|
|
717
|
-
Pragmatically, after sufficient time (when analytics show <1% of users on legacy versions), **collapse old migrations into a single mega-migration** from v0 to current, reducing code maintenance. For users on versions so old that the legacy format is unknown or corrupted, the migration should **fail gracefully** and prompt a fresh login rather than crashing.
|
|
475
|
+
The mover gets its own `UserDefaults` instance for the same suite. `UserDefaults` is not `Sendable`, so once `suite` has been handed to the actor the test could not read it afterwards.
|
|
718
476
|
|
|
719
|
-
|
|
477
|
+
Clear the keychain in `setUp` and `tearDown` for any test that touches the real one, because Simulator keeps items between runs. Integration tests against the real keychain need a host app target with the Keychain Sharing capability. More in [testing-security-code.md](testing-security-code.md).
|
|
720
478
|
|
|
721
|
-
##
|
|
479
|
+
## Very old versions
|
|
722
480
|
|
|
723
|
-
|
|
481
|
+
The App Store only serves the newest binary, so it must still carry every migration from every schema a user could be on. Once analytics show fewer than about 1% of users on the oldest versions, collapse the early steps into a single v0-to-current migration. An unknown or corrupt legacy format fails gracefully and sends the user to a fresh sign-in; it never crashes.
|
|
724
482
|
|
|
725
|
-
|
|
483
|
+
## Deleting files
|
|
726
484
|
|
|
727
|
-
|
|
485
|
+
Do not overwrite a file with zeros or random bytes before deleting it. Wear levelling on NAND means the overwrite lands elsewhere, and it only burns write cycles. `FileManager.removeItem(at:)` and `UserDefaults.removeObject(forKey:)` are enough, because the per-file key is destroyed through Effaceable Storage. The residual risk is an unencrypted backup made before migration; encourage encrypted backups and remove the plaintext promptly once the migration is verified.
|
|
728
486
|
|
|
729
|
-
##
|
|
487
|
+
## Key decisions
|
|
730
488
|
|
|
731
|
-
The
|
|
489
|
+
The irreversible step is the delete, not the write: verify first, defer when in doubt, and assume keychain items outlive the app. The five decisions that matter most:
|
|
732
490
|
|
|
733
|
-
|
|
491
|
+
1. `kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly` as the default class for migrated credentials.
|
|
492
|
+
2. Reinstall cleanup before any SDK initialises.
|
|
493
|
+
3. Schema version stored in the keychain.
|
|
494
|
+
4. Migration gated on `isProtectedDataAvailable`.
|
|
495
|
+
5. `kSecAttrService` never changes after the first release.
|
|
734
496
|
|
|
735
|
-
##
|
|
497
|
+
## Checklist
|
|
736
498
|
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
499
|
+
- [ ] Reinstall cleanup runs before SDK setup, after protected data is readable, and uses `kSecAttrSynchronizableAny`.
|
|
500
|
+
- [ ] Each key moves read, write, verify, delete; failed keys keep their source value.
|
|
501
|
+
- [ ] Schema version lives in the keychain and advances only after every step succeeds.
|
|
502
|
+
- [ ] Migration checks protected-data availability and defers through the notification.
|
|
503
|
+
- [ ] `errSecInteractionNotAllowed` (-25308) is never read as "missing" and never triggers a delete.
|
|
504
|
+
- [ ] Service and account never change after shipping; an unavoidable rename uses a rekey migration.
|
|
505
|
+
- [ ] Search queries never include `kSecAttrAccessible`.
|
|
506
|
+
- [ ] Migrated credentials use `AfterFirstUnlockThisDeviceOnly` unless they are meant to sync.
|
|
507
|
+
- [ ] Plaintext secrets are deleted right after verification; only non-secret artefacts wait for a 30-day window whose start date lives in the keychain.
|
|
508
|
+
- [ ] A Team ID change is preceded by a bridge release.
|
|
509
|
+
- [ ] Unit tests use a protocol mock; accessibility is tested on a device; real-keychain tests clean up in `setUp` and `tearDown`.
|