@mmerterden/multi-agent-pipeline 20.7.0 → 20.8.1
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 +21 -0
- package/LICENSE +0 -10
- package/docs/facts.json +1 -1
- package/manifest.json +285 -285
- package/package.json +3 -3
- package/pipeline/lib/redact.mjs +3 -2
- package/pipeline/scripts/_notices.mjs +1 -1
- package/pipeline/scripts/gen-skills-index.mjs +13 -1
- package/pipeline/scripts/pre-commit-check.sh +4 -0
- package/pipeline/skills/.skill-manifest.json +69 -69
- 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 +345 -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 +107 -121
- package/pipeline/skills/shared/external/app-store-optimization/references/product-page-variants.md +145 -165
- package/pipeline/skills/shared/external/app-store-review/SKILL.md +306 -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 +335 -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 +277 -381
- package/pipeline/skills/shared/external/authentication/evals/evals.json +25 -25
- package/pipeline/skills/shared/external/authentication/references/keychain-biometric.md +135 -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 +274 -384
- package/pipeline/skills/shared/external/background-processing/evals/evals.json +22 -22
- package/pipeline/skills/shared/external/background-processing/references/background-task-patterns.md +173 -321
- 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 +228 -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 +302 -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 +236 -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/localization-reuse-map/SKILL.md +3 -3
- package/pipeline/skills/shared/external/localization-reuse-map/example-mapping.json +1 -1
- package/pipeline/skills/shared/external/localization-reuse-map/reference/format-and-output.md +8 -7
- package/pipeline/skills/shared/external/localization-reuse-map/reference/publish-and-snapshot.md +2 -2
- package/pipeline/skills/shared/external/localization-reuse-map/reference/sources-and-recipes.md +5 -2
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/_shared.py +100 -0
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/build-artifact.py +45 -26
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/build-spreadsheet.py +14 -16
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/fetch-annotations.py +12 -5
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/fetch-legacy-labels.py +2 -1
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/publish-confluence.py +6 -5
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/render-overlay.py +44 -18
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/resolve-legacy-values.py +5 -2
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/resolve-new-values.py +10 -11
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/scan-screen-keys.py +4 -33
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/verify-map.py +12 -59
- package/pipeline/skills/shared/external/mapkit-location/SKILL.md +297 -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/skill-creator/template.md +7 -1
- 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 +302 -241
- 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 +304 -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 +183 -162
- 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 +411 -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 +375 -491
- 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 +397 -457
- package/pipeline/skills/shared/external/swift-security/references/testing-security-code.md +354 -614
- package/pipeline/skills/shared/external/swift-testing/SKILL.md +191 -175
- package/pipeline/skills/shared/external/swift-testing/evals/evals.json +26 -24
- package/pipeline/skills/shared/external/swift-testing/references/testing-advanced.md +81 -84
- package/pipeline/skills/shared/external/swift-testing/references/testing-patterns.md +317 -433
- package/pipeline/skills/shared/external/swiftdata/SKILL.md +394 -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 +201 -168
- package/pipeline/skills/shared/external/swiftui-navigation/references/deeplinks.md +127 -150
- package/pipeline/skills/shared/external/swiftui-navigation/references/navigationstack.md +134 -133
- package/pipeline/skills/shared/external/swiftui-navigation/references/sheets.md +152 -117
- package/pipeline/skills/shared/external/swiftui-navigation/references/tabview.md +111 -138
- 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 +160 -315
- package/pipeline/skills/shared/external/weatherkit/references/weatherkit-patterns.md +288 -407
- package/pipeline/skills/shared/external/widgetkit/SKILL.md +224 -288
- package/pipeline/skills/shared/external/widgetkit/references/widgetkit-advanced.md +416 -719
- package/pipeline/skills/shared/external/NOTICE-swift-ios-skills.md +0 -39
package/pipeline/skills/shared/external/swift-security/references/credential-storage-patterns.md
CHANGED
|
@@ -1,752 +1,754 @@
|
|
|
1
1
|
# Credential Storage Patterns
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
3
|
+
OAuth tokens, API keys and passwords have exactly one place Apple sanctions for
|
|
4
|
+
them on the device: the Keychain. When a 2025 study pulled apart App Store
|
|
5
|
+
binaries, 71% of the iOS apps it examined exposed one or more embedded secrets, most
|
|
6
|
+
often through `UserDefaults`,
|
|
7
|
+
`Info.plist` or `.xcconfig` values, all of which can be read out of a backup or an
|
|
8
|
+
unzipped IPA.
|
|
9
|
+
|
|
10
|
+
This file covers the storage lifecycle: where credentials go, how they are written
|
|
11
|
+
and refreshed, how they are cleared, rotated and migrated. The sign-in UI itself
|
|
12
|
+
(Sign in with Apple, passkeys, `ASAuthorizationController`) belongs to the
|
|
13
|
+
`authentication` skill, and App Attest belongs to `device-integrity`; both appear
|
|
14
|
+
here only as far as token storage depends on them.
|
|
15
|
+
|
|
16
|
+
Grounding: Apple's Keychain Services and Authentication Services documentation; the
|
|
17
|
+
December 2024 edition of the Platform Security Guide; WWDC sessions 516 (2019), 10105
|
|
18
|
+
(2021) and 10125 (2024); on the OWASP side the 2024 Mobile Top 10, the January 2024
|
|
19
|
+
MASVS release 2.1.0 and MASTG version 2; version 2.0 of the joint CISA and FBI list of
|
|
20
|
+
product security bad practices, published January 2025; and a March 2025 industry
|
|
21
|
+
survey of secrets in iOS apps.
|
|
10
22
|
|
|
11
23
|
## Contents
|
|
12
24
|
|
|
13
|
-
- [
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
- [
|
|
22
|
-
- [
|
|
23
|
-
- [
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
- [
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
`
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
CISA and the FBI classify hardcoded credentials as a formal "bad security practice" (CWE-798, ranked in 2024 CWE Top 25). The Cybernews research team found 815,000+ hardcoded secrets across 156,080 iOS apps simply by unzipping IPA files and scanning plaintext - no decompilation required.
|
|
66
|
-
|
|
67
|
-
```swift
|
|
68
|
-
// ❌ INCORRECT - Hardcoded API key discoverable via `strings` on the Mach-O binary
|
|
69
|
-
struct APIConfig {
|
|
70
|
-
static let stripeSecretKey = "sk_live_51ABC123DEF456..."
|
|
71
|
-
static let firebaseAPIKey = "AIzaSyB1234567890abcdefg"
|
|
72
|
-
}
|
|
73
|
-
// Attacker runs: strings MyApp.app/MyApp | grep "sk_live"
|
|
74
|
-
```
|
|
75
|
-
|
|
76
|
-
**OWASP mapping:** Violates M1 (Improper Credential Usage), MASWE-0005, and CISA/FBI advisory item #8.
|
|
77
|
-
|
|
78
|
-
### Anti-Pattern 3 - Production Secrets in .xcconfig
|
|
79
|
-
|
|
80
|
-
The `.xcconfig` pattern solves only the git-commit problem. When you reference `$(MY_API_KEY)` in Info.plist, Xcode resolves the variable at build time and embeds the literal plaintext value in the compiled Info.plist inside the `.app` bundle. Extraction takes seconds: rename `.ipa` to `.zip`, unzip, open Info.plist.
|
|
25
|
+
- [Six ways credentials leak](#six-ways-credentials-leak)
|
|
26
|
+
- [Choosing the protection class](#choosing-the-protection-class)
|
|
27
|
+
- [One actor in front of the Keychain](#one-actor-in-front-of-the-keychain)
|
|
28
|
+
- [OAuth tokens: from sign-in to Keychain](#oauth-tokens-from-sign-in-to-keychain)
|
|
29
|
+
- [Refresh that survives crashes and races](#refresh-that-survives-crashes-and-races)
|
|
30
|
+
- [API keys fetched at runtime](#api-keys-fetched-at-runtime)
|
|
31
|
+
- [Logout clears everything](#logout-clears-everything)
|
|
32
|
+
- [Rotation and versioned storage](#rotation-and-versioned-storage)
|
|
33
|
+
- [Device binding and restores](#device-binding-and-restores)
|
|
34
|
+
- [Biometrics on high-value credentials](#biometrics-on-high-value-credentials)
|
|
35
|
+
- [What iOS 17 and 18 changed](#what-ios-17-and-18-changed)
|
|
36
|
+
- [Guardrails in CI](#guardrails-in-ci)
|
|
37
|
+
- [OWASP mapping](#owasp-mapping)
|
|
38
|
+
- [The three decisions that matter](#the-three-decisions-that-matter)
|
|
39
|
+
- [Checklist](#checklist)
|
|
40
|
+
|
|
41
|
+
## Six ways credentials leak
|
|
42
|
+
|
|
43
|
+
**1. UserDefaults.** Values are written as plain XML into the app container's
|
|
44
|
+
`Library/Preferences` folder, in a plist named after the bundle identifier
|
|
45
|
+
(under `/var/mobile/Containers/Data/Application/` on the device).
|
|
46
|
+
That file is part of Finder and iTunes backups and desktop backup browsers read it
|
|
47
|
+
from a non-jailbroken phone; on a jailbroken one, `objection` reads it with
|
|
48
|
+
`ios nsuserdefaults get`. Apple's documentation says not to keep sensitive data
|
|
49
|
+
there. OWASP mapping: Top 10 M9, control MASVS-STORAGE-1, weakness MASWE-0002; it
|
|
50
|
+
fails MASTG-TEST-0300 and MASTG-TEST-0301. Code
|
|
51
|
+
samples are in [common-anti-patterns.md](common-anti-patterns.md) (#1).
|
|
52
|
+
|
|
53
|
+
**2. Keys in source.** CISA and the FBI list hardcoded credentials as a bad practice
|
|
54
|
+
(item 8 of their list), and CWE-798 sits in the 2024 CWE Top 25. The 2025 study pulled
|
|
55
|
+
more than 815,000 secrets from 156,080 iOS apps just by unzipping IPAs; payment and
|
|
56
|
+
backend API keys fall out of `strings MyApp | grep sk_live`. Maps to M1 and
|
|
57
|
+
MASWE-0005.
|
|
58
|
+
|
|
59
|
+
**3. `.xcconfig` as a hiding place.** It keeps a secret out of git, nothing more. A
|
|
60
|
+
`$(API_TOKEN)` reference in `Info.plist` is substituted at build time, so the
|
|
61
|
+
plaintext value ships in the bundle; `unzip` the IPA and run `plutil -p` on
|
|
62
|
+
`Info.plist` to see it.
|
|
63
|
+
|
|
64
|
+
**4. No explicit accessibility.** Without `kSecAttrAccessible` the item gets
|
|
65
|
+
`kSecAttrAccessibleWhenUnlocked` (the default since iOS 4). That class migrates to a
|
|
66
|
+
new device through an encrypted backup, and on a device without a passcode it is
|
|
67
|
+
effectively always unlocked. Set `kSecAttrAccessibleWhenUnlockedThisDeviceOnly` to
|
|
68
|
+
keep the item on this hardware.
|
|
69
|
+
|
|
70
|
+
**5. Non-atomic refresh.** Deleting the old tokens, then crashing before the new ones
|
|
71
|
+
are stored, leaves the user signed out or half signed in. Two refreshes racing can
|
|
72
|
+
store a refresh token the server already rotated away; with refresh token rotation
|
|
73
|
+
(RTR) replaying it can revoke the whole token family.
|
|
74
|
+
|
|
75
|
+
**6. Partial logout.** Removing the access token but leaving the refresh token behind
|
|
76
|
+
keeps a longer-lived credential on the device that can mint new access tokens.
|
|
81
77
|
|
|
82
78
|
```swift
|
|
83
|
-
//
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
### Anti-Pattern 4 - Missing kSecAttrAccessible Specification
|
|
92
|
-
|
|
93
|
-
When you add a Keychain item without specifying `kSecAttrAccessible`, the system applies the default: `kSecAttrAccessibleWhenUnlocked` (iOS 4.0+). While reasonable, this default allows Keychain items to migrate to new devices via encrypted backups and treats devices without a passcode as "always unlocked." Explicitly setting `kSecAttrAccessibleWhenUnlockedThisDeviceOnly` prevents backup migration and confines the credential to the original hardware.
|
|
94
|
-
|
|
95
|
-
### Anti-Pattern 5 - Non-Atomic Token Refresh
|
|
96
|
-
|
|
97
|
-
When an access token expires, the app must delete the old token and store the new one. If the app crashes between these operations, the Keychain enters an inconsistent state. Concurrent refresh attempts compound the problem: two threads can both detect expiry, both call the refresh endpoint, and one writes a stale or already-rotated refresh token. With Refresh Token Rotation (RTR), this race can invalidate the entire token family.
|
|
98
|
-
|
|
99
|
-
### Anti-Pattern 6 - Incomplete Credential Clearing on Logout
|
|
100
|
-
|
|
101
|
-
The most common partial-cleanup bug is deleting the access token while leaving the refresh token in the Keychain. A refresh token is often longer-lived and more powerful - it can silently generate new access tokens.
|
|
102
|
-
|
|
103
|
-
```swift
|
|
104
|
-
// ❌ INCORRECT - Partial cleanup leaves refresh token behind
|
|
105
|
-
func logout() {
|
|
106
|
-
let query: [String: Any] = [
|
|
107
|
-
kSecClass as String: kSecClassGenericPassword,
|
|
108
|
-
kSecAttrService as String: "com.myapp.auth",
|
|
109
|
-
kSecAttrAccount as String: "access_token"
|
|
110
|
-
]
|
|
111
|
-
SecItemDelete(query as CFDictionary)
|
|
112
|
-
// BUG: refresh_token, user_profile, cached API keys all remain
|
|
113
|
-
}
|
|
79
|
+
// Broken logout: only one of two credentials removed
|
|
80
|
+
let query: [String: Any] = [
|
|
81
|
+
kSecClass as String: kSecClassGenericPassword,
|
|
82
|
+
kSecAttrService as String: "com.example.bank.auth",
|
|
83
|
+
kSecAttrAccount as String: "access_token"
|
|
84
|
+
]
|
|
85
|
+
SecItemDelete(query as CFDictionary)
|
|
114
86
|
```
|
|
115
87
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
✅ Set `kSecAttrAccessible` explicitly for each item based on access pattern.
|
|
120
|
-
✅ Use add-or-update semantics and handle all `OSStatus` outcomes.
|
|
121
|
-
✅ Delete all credential artifacts (access token, refresh token, derived caches) on logout.
|
|
88
|
+
The baseline that avoids all six: Keychain only, an explicit accessibility class on
|
|
89
|
+
every item, add-then-update-on-duplicate writes that check every `OSStatus`, and a
|
|
90
|
+
logout that removes every credential artifact.
|
|
122
91
|
|
|
123
|
-
|
|
92
|
+
## Choosing the protection class
|
|
124
93
|
|
|
125
|
-
|
|
94
|
+
Every Keychain row is protected twice with AES-256-GCM. Its attributes use a key the
|
|
95
|
+
system keeps cached; its secret value uses a key specific to that row, and unwrapping
|
|
96
|
+
that one means asking the Secure Enclave (details in [keychain-fundamentals.md](keychain-fundamentals.md)).
|
|
97
|
+
Accessibility decides when that unwrap is allowed.
|
|
126
98
|
|
|
127
|
-
|
|
99
|
+
| Class | Behaviour | Use for |
|
|
100
|
+
| --- | --- | --- |
|
|
101
|
+
| `WhenUnlockedThisDeviceOnly` | Device-bound, unavailable while locked | Recommended default: OAuth tokens, API keys |
|
|
102
|
+
| `WhenPasscodeSetThisDeviceOnly` | Highest assurance; destroyed if the passcode is removed | High-value secrets |
|
|
103
|
+
| `AfterFirstUnlockThisDeviceOnly` | Readable in the background after first unlock; longer exposure | Only when background work needs it, such as a silent push handler |
|
|
104
|
+
| `AfterFirstUnlock` | Not device-bound; moves through backups | Avoid for sensitive tokens |
|
|
128
105
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
| `kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly` | Yes | No | Highest-assurance secrets | Item permanently destroyed if user removes passcode |
|
|
133
|
-
| `kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly` | Yes | Yes (post-first unlock) | Background token refresh | Larger exposure window; use only when background access is required |
|
|
134
|
-
| `kSecAttrAccessibleAfterFirstUnlock` | No | Yes | Background + backup migration | Transfers via encrypted backup; avoid for sensitive tokens |
|
|
106
|
+
Do not set `kSecAttrSynchronizable` on session tokens. Syncing through iCloud
|
|
107
|
+
Keychain is meant for passwords the user sees and manages, not for credentials an app
|
|
108
|
+
minted for itself.
|
|
135
109
|
|
|
136
|
-
|
|
110
|
+
More detail on classes and flags: [keychain-access-control.md](keychain-access-control.md).
|
|
137
111
|
|
|
138
|
-
|
|
112
|
+
## One actor in front of the Keychain
|
|
139
113
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
114
|
+
The `SecItem*` functions (present since iOS 2.0) can be called from any thread when
|
|
115
|
+
each call touches a different item. Two calls aimed at one item, however, race: two simultaneous adds end with one
|
|
116
|
+
`errSecDuplicateItem`, and a read between a delete and an add sees nothing. An actor
|
|
117
|
+
(iOS 13+, and the idiomatic choice in code targeting iOS 17 and later) serializes
|
|
118
|
+
every operation.
|
|
145
119
|
|
|
146
120
|
```swift
|
|
147
|
-
// ✅ CORRECT - Actor-based KeychainManager with proper kSecAttrAccessible
|
|
148
|
-
// Requires: iOS 13+ (actors), recommended iOS 17+ for mature concurrency
|
|
149
121
|
import Foundation
|
|
150
122
|
import Security
|
|
151
123
|
|
|
152
|
-
public actor
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
case
|
|
124
|
+
public actor CredentialVault {
|
|
125
|
+
public enum Failure: Error {
|
|
126
|
+
case status(OSStatus)
|
|
127
|
+
case missing
|
|
128
|
+
case encoding
|
|
129
|
+
case decoding
|
|
156
130
|
}
|
|
157
131
|
|
|
158
|
-
let service: String
|
|
132
|
+
private let service: String
|
|
159
133
|
private let accessGroup: String?
|
|
160
134
|
private let accessibility: CFString
|
|
161
135
|
|
|
162
|
-
public init(service: String,
|
|
136
|
+
public init(service: String,
|
|
137
|
+
accessGroup: String? = nil,
|
|
163
138
|
accessibility: CFString = kSecAttrAccessibleWhenUnlockedThisDeviceOnly) {
|
|
164
|
-
self.service = service
|
|
139
|
+
self.service = service
|
|
140
|
+
self.accessGroup = accessGroup
|
|
141
|
+
self.accessibility = accessibility
|
|
165
142
|
}
|
|
166
143
|
|
|
167
|
-
func
|
|
168
|
-
var
|
|
169
|
-
kSecClass
|
|
170
|
-
|
|
144
|
+
private func identity(for account: String) -> [String: Any] {
|
|
145
|
+
var query: [String: Any] = [
|
|
146
|
+
kSecClass as String: kSecClassGenericPassword,
|
|
147
|
+
kSecAttrService as String: service,
|
|
148
|
+
kSecAttrAccount as String: account
|
|
171
149
|
]
|
|
172
|
-
if let accessGroup {
|
|
150
|
+
if let accessGroup { query[kSecAttrAccessGroup as String] = accessGroup }
|
|
173
151
|
#if os(macOS)
|
|
174
|
-
|
|
152
|
+
query[kSecUseDataProtectionKeychain as String] = true
|
|
175
153
|
#endif
|
|
176
|
-
return
|
|
154
|
+
return query
|
|
177
155
|
}
|
|
178
156
|
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
if
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
157
|
+
public func store(_ secret: Data, account: String) throws {
|
|
158
|
+
var insert = identity(for: account)
|
|
159
|
+
insert[kSecValueData as String] = secret
|
|
160
|
+
insert[kSecAttrAccessible as String] = accessibility
|
|
161
|
+
|
|
162
|
+
let added = SecItemAdd(insert as CFDictionary, nil)
|
|
163
|
+
if added == errSecSuccess { return }
|
|
164
|
+
guard added == errSecDuplicateItem else { throw Failure.status(added) }
|
|
165
|
+
|
|
166
|
+
let changes: [String: Any] = [
|
|
167
|
+
kSecValueData as String: secret,
|
|
168
|
+
kSecAttrAccessible as String: accessibility
|
|
169
|
+
]
|
|
170
|
+
let updated = SecItemUpdate(identity(for: account) as CFDictionary,
|
|
171
|
+
changes as CFDictionary)
|
|
172
|
+
guard updated == errSecSuccess else { throw Failure.status(updated) }
|
|
190
173
|
}
|
|
191
174
|
|
|
192
|
-
public func
|
|
193
|
-
var
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
175
|
+
public func read(account: String) throws -> Data {
|
|
176
|
+
var query = identity(for: account)
|
|
177
|
+
query[kSecReturnData as String] = true
|
|
178
|
+
query[kSecMatchLimit as String] = kSecMatchLimitOne
|
|
179
|
+
|
|
180
|
+
var output: CFTypeRef?
|
|
181
|
+
let outcome = SecItemCopyMatching(query as CFDictionary, &output)
|
|
182
|
+
if outcome == errSecItemNotFound { throw Failure.missing }
|
|
183
|
+
guard outcome == errSecSuccess else { throw Failure.status(outcome) }
|
|
184
|
+
guard let payload = output as? Data else { throw Failure.decoding }
|
|
185
|
+
return payload
|
|
203
186
|
}
|
|
204
187
|
|
|
205
|
-
public func
|
|
206
|
-
|
|
207
|
-
let s = SecItemDelete(q as CFDictionary)
|
|
208
|
-
guard s == errSecSuccess || s == errSecItemNotFound else { throw KeychainError.unexpectedStatus(s) }
|
|
188
|
+
public func remove(account: String) throws {
|
|
189
|
+
try Self.acceptingAbsence(SecItemDelete(identity(for: account) as CFDictionary))
|
|
209
190
|
}
|
|
210
191
|
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
192
|
+
private static func acceptingAbsence(_ outcome: OSStatus) throws {
|
|
193
|
+
if outcome != errSecSuccess && outcome != errSecItemNotFound {
|
|
194
|
+
throw Failure.status(outcome)
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
public func removeEverything() throws {
|
|
199
|
+
var query: [String: Any] = [
|
|
200
|
+
kSecClass as String: kSecClassGenericPassword,
|
|
201
|
+
kSecAttrService as String: service
|
|
202
|
+
]
|
|
203
|
+
if let accessGroup { query[kSecAttrAccessGroup as String] = accessGroup }
|
|
215
204
|
#if os(macOS)
|
|
216
|
-
|
|
205
|
+
query[kSecUseDataProtectionKeychain as String] = true
|
|
217
206
|
#endif
|
|
218
|
-
|
|
219
|
-
guard s == errSecSuccess || s == errSecItemNotFound else { throw KeychainError.unexpectedStatus(s) }
|
|
207
|
+
try Self.acceptingAbsence(SecItemDelete(query as CFDictionary))
|
|
220
208
|
}
|
|
221
209
|
}
|
|
222
210
|
```
|
|
223
211
|
|
|
224
|
-
|
|
212
|
+
Notes on the shape:
|
|
213
|
+
|
|
214
|
+
- `store` adds first and updates only on `errSecDuplicateItem`. The update query is
|
|
215
|
+
the item's identity (class, service, account, group) and carries no accessibility
|
|
216
|
+
or data; the new data and accessibility go in the attributes dictionary. Any other
|
|
217
|
+
status throws. Deleting and re-adding is reserved for changing an item's
|
|
218
|
+
`kSecAttrAccessControl`, which cannot be updated in place.
|
|
219
|
+
- `read` asks for data with a match limit of one and turns not-found into a typed
|
|
220
|
+
error. `remove` treats not-found as success. `removeEverything` deletes by class
|
|
221
|
+
and service (plus group) and is what logout calls.
|
|
222
|
+
- Because this is an actor, none of these can interleave, and callers suspend
|
|
223
|
+
instead of blocking a thread in the cooperative pool.
|
|
225
224
|
|
|
226
|
-
|
|
225
|
+
When the same serialization has to span several types or modules, a global actor
|
|
226
|
+
does the job:
|
|
227
227
|
|
|
228
228
|
```swift
|
|
229
|
-
// ✅ Pattern: Global actor for cross-module Keychain serialization
|
|
230
|
-
// Requires: iOS 13.0+ (global actors via Swift 5.5+)
|
|
231
229
|
@globalActor
|
|
232
|
-
actor
|
|
233
|
-
static let shared =
|
|
230
|
+
actor KeychainIsolation {
|
|
231
|
+
static let shared = KeychainIsolation()
|
|
234
232
|
}
|
|
235
233
|
|
|
236
|
-
@
|
|
237
|
-
func
|
|
238
|
-
let
|
|
239
|
-
|
|
240
|
-
kSecAttrService: "com.myapp.auth" as CFString,
|
|
241
|
-
kSecAttrAccount: account as CFString,
|
|
242
|
-
kSecAttrAccessible: kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
|
|
243
|
-
kSecValueData: data
|
|
244
|
-
]
|
|
245
|
-
let status = SecItemAdd(query as CFDictionary, nil)
|
|
246
|
-
guard status == errSecSuccess else {
|
|
247
|
-
throw NSError(domain: NSOSStatusErrorDomain, code: Int(status))
|
|
248
|
-
}
|
|
234
|
+
@KeychainIsolation
|
|
235
|
+
func rotateDeviceSecret(using vault: CredentialVault) async throws {
|
|
236
|
+
let fresh = Data(UUID().uuidString.utf8)
|
|
237
|
+
try await vault.store(fresh, account: "device_secret")
|
|
249
238
|
}
|
|
250
239
|
```
|
|
251
240
|
|
|
252
|
-
|
|
241
|
+
### Storing CryptoKit keys with the same vault
|
|
242
|
+
|
|
243
|
+
Apple's sample code for keeping CryptoKit keys in the Keychain defines a small protocol for
|
|
244
|
+
keys that have no `SecKey` form (Curve25519, post-quantum keys, Secure Enclave
|
|
245
|
+
blobs), so they can be kept as generic passwords:
|
|
246
|
+
|
|
247
|
+
```swift
|
|
248
|
+
import CryptoKit
|
|
249
|
+
|
|
250
|
+
protocol GenericPasswordConvertible {
|
|
251
|
+
init<Bytes: ContiguousBytes>(rawRepresentation: Bytes) throws
|
|
252
|
+
var rawRepresentation: Data { get }
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
extension Curve25519.Signing.PrivateKey: GenericPasswordConvertible {}
|
|
253
256
|
|
|
254
|
-
|
|
257
|
+
extension CredentialVault {
|
|
258
|
+
func storeKey<K: GenericPasswordConvertible>(_ key: K, account: String) throws {
|
|
259
|
+
try store(key.rawRepresentation, account: account)
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
func loadKey<K: GenericPasswordConvertible>(_ type: K.Type, account: String) throws -> K {
|
|
263
|
+
try K(rawRepresentation: read(account: account))
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
```
|
|
255
267
|
|
|
256
|
-
|
|
268
|
+
The write goes through the same add-then-update-on-duplicate `store`. Which key
|
|
269
|
+
types need this and which go in as `kSecClassKey` is covered in
|
|
270
|
+
[cryptokit-public-key.md](cryptokit-public-key.md).
|
|
257
271
|
|
|
258
|
-
|
|
272
|
+
## OAuth tokens: from sign-in to Keychain
|
|
273
|
+
|
|
274
|
+
Web-based sign-in uses `ASWebAuthenticationSession` (iOS 12+). Running OAuth inside a
|
|
275
|
+
`WKWebView` or `SFSafariViewController` is an anti-pattern: the hosting app can read
|
|
276
|
+
or script the page and capture the password. WWDC 2019 also told apps to leave the
|
|
277
|
+
deprecated `SFAuthenticationSession`. The UI side is the `authentication` skill's
|
|
278
|
+
territory; what matters here is what gets stored.
|
|
259
279
|
|
|
260
280
|
```swift
|
|
261
|
-
|
|
262
|
-
struct OAuthTokens: Codable {
|
|
281
|
+
struct SessionTokens: Codable {
|
|
263
282
|
let accessToken: String
|
|
264
283
|
let refreshToken: String
|
|
265
284
|
let expiresAt: Date
|
|
266
285
|
let tokenType: String
|
|
267
286
|
|
|
268
|
-
var isExpired: Bool {
|
|
269
|
-
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
/// Proactive refresh before expiry.
|
|
273
|
-
/// Both providers agree: refresh at 75-90% of lifetime or with a fixed
|
|
274
|
-
/// buffer (e.g., 60 seconds) to account for network latency and clock skew.
|
|
275
|
-
var shouldRefresh: Bool {
|
|
276
|
-
let buffer: TimeInterval = 60
|
|
277
|
-
return Date() >= expiresAt.addingTimeInterval(-buffer)
|
|
278
|
-
}
|
|
287
|
+
var isExpired: Bool { Date() >= expiresAt }
|
|
288
|
+
var needsRefresh: Bool { Date().addingTimeInterval(60) >= expiresAt }
|
|
279
289
|
}
|
|
280
290
|
```
|
|
281
291
|
|
|
282
|
-
|
|
292
|
+
Refresh before expiry, either with a fixed buffer (60 seconds above) or at 75-90% of
|
|
293
|
+
the token lifetime, to absorb network latency and clock skew.
|
|
294
|
+
|
|
295
|
+
The authorization-code flow with PKCE (RFC 7636) for a public client (no client
|
|
296
|
+
secret on the device):
|
|
283
297
|
|
|
284
298
|
```swift
|
|
285
|
-
// ✅ CORRECT - ASWebAuthenticationSession + PKCE + Keychain storage
|
|
286
|
-
// Requires: iOS 13.0+ (for prefersEphemeralWebBrowserSession)
|
|
287
299
|
import AuthenticationServices
|
|
288
300
|
import CryptoKit
|
|
289
301
|
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
302
|
+
func base64URL(_ data: Data) -> String {
|
|
303
|
+
data.base64EncodedString()
|
|
304
|
+
.replacingOccurrences(of: "+", with: "-")
|
|
305
|
+
.replacingOccurrences(of: "/", with: "_")
|
|
306
|
+
.replacingOccurrences(of: "=", with: "")
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
func makeVerifier() throws -> String {
|
|
310
|
+
var entropy = Data(count: 32)
|
|
311
|
+
let rc = entropy.withUnsafeMutableBytes { buffer in
|
|
312
|
+
buffer.baseAddress.map { SecRandomCopyBytes(kSecRandomDefault, 32, $0) } ?? errSecAllocate
|
|
313
|
+
}
|
|
314
|
+
guard rc == errSecSuccess else { throw CredentialVault.Failure.status(rc) }
|
|
315
|
+
return base64URL(entropy)
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
@MainActor
|
|
319
|
+
final class SignInCoordinator: NSObject, ASWebAuthenticationPresentationContextProviding {
|
|
320
|
+
private let vault: CredentialVault
|
|
321
|
+
private let anchor: ASPresentationAnchor
|
|
322
|
+
|
|
323
|
+
init(vault: CredentialVault, anchor: ASPresentationAnchor) {
|
|
324
|
+
self.vault = vault
|
|
325
|
+
self.anchor = anchor
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
func presentationAnchor(for _: ASWebAuthenticationSession) -> ASPresentationAnchor { anchor }
|
|
329
|
+
|
|
330
|
+
func signIn() async throws {
|
|
331
|
+
let verifier = try makeVerifier()
|
|
332
|
+
let digest = SHA256.hash(data: Data(verifier.utf8))
|
|
333
|
+
let challenge = base64URL(Data(digest))
|
|
334
|
+
let state = UUID().uuidString
|
|
335
|
+
|
|
336
|
+
guard var parts = URLComponents(string: "https://login.example.com/authorize") else { return }
|
|
337
|
+
parts.queryItems = [
|
|
338
|
+
.init(name: "response_type", value: "code"),
|
|
339
|
+
.init(name: "client_id", value: "ios-reader"),
|
|
340
|
+
.init(name: "redirect_uri", value: "exampleapp://oauth"),
|
|
341
|
+
.init(name: "scope", value: ["openid", "profile", "offline_access"].joined(separator: " ")),
|
|
342
|
+
.init(name: "code_challenge", value: challenge),
|
|
343
|
+
.init(name: "code_challenge_method", value: "S256"),
|
|
344
|
+
.init(name: "state", value: state)
|
|
312
345
|
]
|
|
346
|
+
guard let authorizeURL = parts.url else { return }
|
|
313
347
|
|
|
314
|
-
let
|
|
348
|
+
let callback: URL = try await withCheckedThrowingContinuation { waiter in
|
|
315
349
|
let session = ASWebAuthenticationSession(
|
|
316
|
-
url:
|
|
317
|
-
) {
|
|
318
|
-
|
|
319
|
-
|
|
350
|
+
url: authorizeURL, callbackURLScheme: "exampleapp"
|
|
351
|
+
) { redirect, failure in
|
|
352
|
+
guard let redirect else {
|
|
353
|
+
waiter.resume(throwing: failure ?? URLError(.cancelled))
|
|
354
|
+
return
|
|
355
|
+
}
|
|
356
|
+
waiter.resume(returning: redirect)
|
|
320
357
|
}
|
|
321
|
-
session.prefersEphemeralWebBrowserSession = true
|
|
358
|
+
session.prefersEphemeralWebBrowserSession = true
|
|
322
359
|
session.presentationContextProvider = self
|
|
323
360
|
session.start()
|
|
324
361
|
}
|
|
325
362
|
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
363
|
+
let returned = Dictionary(
|
|
364
|
+
(URLComponents(url: callback, resolvingAgainstBaseURL: false)?.queryItems ?? [])
|
|
365
|
+
.map { ($0.name, $0.value ?? "") },
|
|
366
|
+
uniquingKeysWith: { first, _ in first }
|
|
367
|
+
)
|
|
368
|
+
guard returned["state"] == state, let code = returned["code"] else {
|
|
369
|
+
throw URLError(.badServerResponse)
|
|
329
370
|
}
|
|
330
371
|
|
|
331
|
-
let tokens = try await
|
|
332
|
-
try await
|
|
333
|
-
return tokens
|
|
372
|
+
let tokens = try await exchange(code: code, verifier: verifier)
|
|
373
|
+
try await vault.store(try JSONEncoder().encode(tokens), account: "oauth_tokens_v2")
|
|
334
374
|
}
|
|
335
375
|
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
private func generateCodeVerifier() -> String {
|
|
339
|
-
var buffer = [UInt8](repeating: 0, count: 32)
|
|
340
|
-
_ = SecRandomCopyBytes(kSecRandomDefault, buffer.count, &buffer)
|
|
341
|
-
return Data(buffer).base64EncodedString()
|
|
342
|
-
.replacingOccurrences(of: "+", with: "-")
|
|
343
|
-
.replacingOccurrences(of: "/", with: "_")
|
|
344
|
-
.replacingOccurrences(of: "=", with: "")
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
private func generateCodeChallenge(from verifier: String) -> String {
|
|
348
|
-
// CryptoKit SHA256 (iOS 13.0+) - replaces legacy CC_SHA256
|
|
349
|
-
let hash = SHA256.hash(data: Data(verifier.utf8))
|
|
350
|
-
return Data(hash).base64EncodedString()
|
|
351
|
-
.replacingOccurrences(of: "+", with: "-")
|
|
352
|
-
.replacingOccurrences(of: "/", with: "_")
|
|
353
|
-
.replacingOccurrences(of: "=", with: "")
|
|
354
|
-
}
|
|
355
|
-
|
|
356
|
-
private func exchangeCodeForTokens(code: String, codeVerifier: String) async throws -> OAuthTokens {
|
|
357
|
-
// Standard OAuth2 token exchange - implement with your authorization server
|
|
358
|
-
fatalError("Implement token exchange")
|
|
376
|
+
private func exchange(code: String, verifier: String) async throws -> SessionTokens {
|
|
377
|
+
throw URLError(.unsupportedURL) // POST code + code_verifier to the token endpoint here
|
|
359
378
|
}
|
|
360
|
-
|
|
361
|
-
func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor { ASPresentationAnchor() }
|
|
362
|
-
enum OAuthError: Error { case missingAuthorizationCode }
|
|
363
379
|
}
|
|
364
380
|
```
|
|
365
381
|
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
382
|
+
The verifier is 32 random bytes from `SecRandomCopyBytes`, base64url-encoded without
|
|
383
|
+
padding; the challenge is base64url of its SHA-256, sent with method `S256`. The state
|
|
384
|
+
value is checked on return. `prefersEphemeralWebBrowserSession = true` (iOS 13+) shares
|
|
385
|
+
no cookies with Safari, which gives the strongest isolation at the cost of single
|
|
386
|
+
sign-on; decide per product.
|
|
369
387
|
|
|
370
|
-
|
|
388
|
+
From iOS 17.4, `ASWebAuthenticationSession.Callback` can use an HTTPS universal link
|
|
389
|
+
as the redirect. That proves domain ownership and is much harder for another app to
|
|
390
|
+
intercept than a custom scheme (RFC 8252).
|
|
371
391
|
|
|
372
|
-
##
|
|
392
|
+
## Refresh that survives crashes and races
|
|
373
393
|
|
|
374
|
-
|
|
394
|
+
Identity providers that implement refresh token rotation issue a new refresh token
|
|
395
|
+
on every refresh and invalidate the old one. If the app persists the new access token
|
|
396
|
+
and the process dies before the matching refresh token reaches storage, the next
|
|
397
|
+
launch has nothing valid to refresh with and the user must sign in again. Many
|
|
398
|
+
providers keep the previous refresh token valid for a short grace period (often
|
|
399
|
+
configurable from 0 to 60 seconds, 30 is common) so a retried request still works;
|
|
400
|
+
presenting it after the grace period revokes the entire family, which the server
|
|
401
|
+
treats as a sign of theft.
|
|
375
402
|
|
|
376
|
-
|
|
403
|
+
Store both tokens as one encoded item so that a single write replaces them together,
|
|
404
|
+
encode before touching the Keychain, and route the write through the actor:
|
|
377
405
|
|
|
378
406
|
```swift
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
let newData = try JSONEncoder().encode(newTokens) // Encode BEFORE mutation
|
|
384
|
-
|
|
385
|
-
var delQ: [CFString: Any] = [kSecClass: kSecClassGenericPassword,
|
|
386
|
-
kSecAttrService: self.service as CFString,
|
|
387
|
-
kSecAttrAccount: oldAccount as CFString]
|
|
388
|
-
#if os(macOS)
|
|
389
|
-
delQ[kSecUseDataProtectionKeychain] = true
|
|
390
|
-
#endif
|
|
391
|
-
let delStatus = SecItemDelete(delQ as CFDictionary)
|
|
392
|
-
guard delStatus == errSecSuccess || delStatus == errSecItemNotFound else {
|
|
393
|
-
throw KeychainError.unexpectedStatus(delStatus)
|
|
394
|
-
}
|
|
395
|
-
|
|
396
|
-
var addQ = baseQuery(account: oldAccount); addQ[kSecValueData] = newData
|
|
397
|
-
let addStatus = SecItemAdd(addQ as CFDictionary, nil)
|
|
398
|
-
guard addStatus == errSecSuccess else { throw KeychainError.unexpectedStatus(addStatus) }
|
|
407
|
+
extension CredentialVault {
|
|
408
|
+
func replaceTokens(_ tokens: SessionTokens) throws {
|
|
409
|
+
guard let encoded = try? JSONEncoder().encode(tokens) else { throw Failure.encoding }
|
|
410
|
+
try store(encoded, account: "oauth_tokens_v2")
|
|
399
411
|
}
|
|
400
412
|
}
|
|
401
413
|
```
|
|
402
414
|
|
|
403
|
-
|
|
415
|
+
There is no delete step. `store` adds, and on `errSecDuplicateItem` updates the
|
|
416
|
+
existing row in place, so at no point is the item absent.
|
|
404
417
|
|
|
405
|
-
|
|
418
|
+
Concurrent callers must share one refresh:
|
|
406
419
|
|
|
407
420
|
```swift
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
actor TokenRefreshCoordinator {
|
|
411
|
-
|
|
412
|
-
private let keychain: KeychainManager
|
|
421
|
+
actor RefreshGate {
|
|
422
|
+
private let vault: CredentialVault
|
|
413
423
|
private let tokenEndpoint: URL
|
|
414
|
-
private var
|
|
424
|
+
private var inFlight: Task<SessionTokens, Error>?
|
|
415
425
|
|
|
416
|
-
init(
|
|
417
|
-
self.
|
|
426
|
+
init(vault: CredentialVault, tokenEndpoint: URL) {
|
|
427
|
+
self.vault = vault
|
|
428
|
+
self.tokenEndpoint = tokenEndpoint
|
|
418
429
|
}
|
|
419
430
|
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
let tokens = try? JSONDecoder().decode(OAuthTokens.self, from: data) else {
|
|
424
|
-
throw TokenError.notAuthenticated
|
|
425
|
-
}
|
|
426
|
-
guard tokens.shouldRefresh else { return tokens.accessToken }
|
|
427
|
-
|
|
428
|
-
// Coalesce: reuse in-flight refresh if one exists
|
|
429
|
-
if let existing = refreshTask { return try await existing.value.accessToken }
|
|
431
|
+
func validTokens(current: SessionTokens) async throws -> SessionTokens {
|
|
432
|
+
if !current.needsRefresh { return current }
|
|
433
|
+
if let inFlight { return try await inFlight.value }
|
|
430
434
|
|
|
431
|
-
let task = Task
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
refreshTask = task
|
|
436
|
-
return try await task.value.accessToken
|
|
435
|
+
let task = Task { try await self.refresh(using: current) }
|
|
436
|
+
inFlight = task
|
|
437
|
+
defer { inFlight = nil }
|
|
438
|
+
return try await task.value
|
|
437
439
|
}
|
|
438
440
|
|
|
439
|
-
private func
|
|
441
|
+
private func refresh(using old: SessionTokens) async throws -> SessionTokens {
|
|
440
442
|
var request = URLRequest(url: tokenEndpoint)
|
|
441
443
|
request.httpMethod = "POST"
|
|
442
|
-
request.
|
|
443
|
-
request.httpBody = "grant_type=refresh_token&refresh_token=\(
|
|
444
|
+
request.addValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
|
|
445
|
+
request.httpBody = Data("grant_type=refresh_token&refresh_token=\(old.refreshToken)".utf8)
|
|
444
446
|
|
|
445
|
-
let
|
|
446
|
-
|
|
447
|
+
let reply = try await URLSession.shared.data(for: request)
|
|
448
|
+
let body = reply.0
|
|
449
|
+
let code = (reply.1 as? HTTPURLResponse)?.statusCode ?? 0
|
|
447
450
|
|
|
448
|
-
switch
|
|
451
|
+
switch code {
|
|
449
452
|
case 200:
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
let
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
453
|
+
let decoder = JSONDecoder()
|
|
454
|
+
decoder.keyDecodingStrategy = .convertFromSnakeCase
|
|
455
|
+
let payload = try decoder.decode(RefreshPayload.self, from: body)
|
|
456
|
+
let next = SessionTokens(
|
|
457
|
+
accessToken: payload.accessToken,
|
|
458
|
+
refreshToken: payload.refreshToken ?? old.refreshToken,
|
|
459
|
+
expiresAt: Date().addingTimeInterval(payload.expiresIn),
|
|
460
|
+
tokenType: payload.tokenType
|
|
457
461
|
)
|
|
458
|
-
try await
|
|
459
|
-
return
|
|
462
|
+
try await vault.replaceTokens(next)
|
|
463
|
+
return next
|
|
460
464
|
case 400, 401:
|
|
461
|
-
try
|
|
462
|
-
throw
|
|
465
|
+
try await vault.removeEverything()
|
|
466
|
+
throw SessionError.refreshExpired
|
|
463
467
|
default:
|
|
464
|
-
throw
|
|
468
|
+
throw SessionError.server(code)
|
|
465
469
|
}
|
|
466
470
|
}
|
|
471
|
+
}
|
|
467
472
|
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
473
|
+
struct RefreshPayload: Decodable {
|
|
474
|
+
let accessToken: String
|
|
475
|
+
let refreshToken: String?
|
|
476
|
+
let expiresIn: TimeInterval
|
|
477
|
+
let tokenType: String
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
enum SessionError: Error {
|
|
481
|
+
case refreshExpired
|
|
482
|
+
case server(Int)
|
|
471
483
|
}
|
|
472
484
|
```
|
|
473
485
|
|
|
474
|
-
|
|
486
|
+
A token that is not near expiry is returned as is; a refresh already running is
|
|
487
|
+
awaited instead of started again; the task slot clears itself when done. If the
|
|
488
|
+
server does not return a new refresh token, the old one is kept. A 400 or 401 means
|
|
489
|
+
the refresh token is dead, so everything is wiped and the user signs in again.
|
|
475
490
|
|
|
476
|
-
##
|
|
491
|
+
## API keys fetched at runtime
|
|
477
492
|
|
|
478
|
-
The
|
|
493
|
+
The best design keeps third-party API keys off the device entirely behind your own
|
|
494
|
+
backend proxy. If the device really must hold the key, download it when needed and keep
|
|
495
|
+
a Keychain copy stamped with its own expiry, since Keychain items never expire by
|
|
496
|
+
themselves.
|
|
479
497
|
|
|
480
|
-
|
|
498
|
+
Before the backend hands out a secret, have the app prove it is genuine with App
|
|
499
|
+
Attest (`DCAppAttestService`, iOS 14+): the app creates a Secure Enclave key pair,
|
|
500
|
+
Apple vouches for it, and only after checking that attestation on the server does
|
|
501
|
+
your backend hand out keys, and short-lived ones at that. The attestation mechanics belong to `device-integrity`.
|
|
481
502
|
|
|
482
503
|
```swift
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
var isExpired: Bool { Date().timeIntervalSince(fetchedAt) >= ttlSeconds }
|
|
490
|
-
}
|
|
504
|
+
struct CachedSecret: Codable {
|
|
505
|
+
let value: String
|
|
506
|
+
let fetchedAt: Date
|
|
507
|
+
let lifetime: TimeInterval
|
|
508
|
+
var isExpired: Bool { fetchedAt.addingTimeInterval(lifetime) < Date() }
|
|
509
|
+
}
|
|
491
510
|
|
|
492
|
-
|
|
493
|
-
private let
|
|
494
|
-
private let
|
|
495
|
-
private var
|
|
511
|
+
actor RemoteSecretStore {
|
|
512
|
+
private let vault: CredentialVault
|
|
513
|
+
private let fetch: @Sendable (String) async throws -> String
|
|
514
|
+
private var memory: [String: CachedSecret] = [:]
|
|
496
515
|
|
|
497
|
-
init(
|
|
498
|
-
self.
|
|
516
|
+
init(vault: CredentialVault, fetch: @escaping @Sendable (String) async throws -> String) {
|
|
517
|
+
self.vault = vault
|
|
518
|
+
self.fetch = fetch
|
|
499
519
|
}
|
|
500
520
|
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
if let c = memoryCache[key], !c.isExpired { return c.value }
|
|
521
|
+
func secret(named name: String, lifetime: TimeInterval = 3600) async throws -> String {
|
|
522
|
+
if let hit = memory[name], !hit.isExpired { return hit.value }
|
|
504
523
|
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
524
|
+
let account = "secret_\(name)"
|
|
525
|
+
let fromDisk = (try? await vault.read(account: account))
|
|
526
|
+
.flatMap { try? JSONDecoder().decode(CachedSecret.self, from: $0) }
|
|
527
|
+
if let stored = fromDisk, !stored.isExpired {
|
|
528
|
+
memory[name] = stored
|
|
529
|
+
return stored.value
|
|
508
530
|
}
|
|
509
531
|
|
|
510
|
-
let
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
return freshValue
|
|
515
|
-
}
|
|
516
|
-
|
|
517
|
-
private func fetchFromBackend(key: String) async throws -> String {
|
|
518
|
-
var request = URLRequest(url: secretsEndpoint.appendingPathComponent(key))
|
|
519
|
-
// Authenticate with App Attest (iOS 14.0+) before backend issues secret
|
|
520
|
-
let (data, response) = try await URLSession.shared.data(for: request)
|
|
521
|
-
guard let http = response as? HTTPURLResponse, http.statusCode == 200,
|
|
522
|
-
let json = try? JSONDecoder().decode([String: String].self, from: data),
|
|
523
|
-
let value = json["value"] else { throw SecretFetchError.serverError }
|
|
524
|
-
return value
|
|
532
|
+
let fresh = CachedSecret(value: try await fetch(name), fetchedAt: Date(), lifetime: lifetime)
|
|
533
|
+
try await vault.store(try JSONEncoder().encode(fresh), account: account)
|
|
534
|
+
memory[name] = fresh
|
|
535
|
+
return fresh.value
|
|
525
536
|
}
|
|
526
|
-
|
|
527
|
-
enum SecretFetchError: Error { case serverError }
|
|
528
537
|
}
|
|
529
538
|
```
|
|
530
539
|
|
|
531
|
-
|
|
540
|
+
Lookup order is memory, then Keychain, then network, with a one-hour default
|
|
541
|
+
lifetime.
|
|
532
542
|
|
|
533
|
-
##
|
|
543
|
+
## Logout clears everything
|
|
534
544
|
|
|
535
|
-
|
|
545
|
+
Logout removes the access token, the refresh token, cached secrets, stored profile
|
|
546
|
+
data and in-memory caches, and revokes the tokens on the server when it can. Keeping
|
|
547
|
+
every auth item under one `kSecAttrService` means one `SecItemDelete` clears them.
|
|
536
548
|
|
|
537
549
|
```swift
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
550
|
+
struct SignOutService {
|
|
551
|
+
let vault: CredentialVault
|
|
552
|
+
let revokeEndpoint: URL
|
|
553
|
+
let authHost: String
|
|
554
|
+
|
|
555
|
+
// Covers storage controls 1 and 2 of MASVS
|
|
556
|
+
func signOut(tokens: SessionTokens?) async {
|
|
557
|
+
if let tokens {
|
|
558
|
+
for token in [tokens.refreshToken, tokens.accessToken] {
|
|
559
|
+
var request = URLRequest(url: revokeEndpoint)
|
|
560
|
+
request.httpMethod = "POST"
|
|
561
|
+
_ = try? await URLSession.shared.upload(for: request, from: Data("token=\(token)".utf8))
|
|
562
|
+
}
|
|
551
563
|
}
|
|
552
|
-
|
|
553
|
-
try? await keychain.deleteAll()
|
|
554
|
-
// 3. Clear cookies for auth domain
|
|
555
|
-
HTTPCookieStorage.shared.cookies(for: URL(string: "https://auth.example.com")!)?
|
|
556
|
-
.forEach { HTTPCookieStorage.shared.deleteCookie($0) }
|
|
557
|
-
// 4. Clear URL cache
|
|
558
|
-
URLCache.shared.removeAllCachedResponses()
|
|
559
|
-
}
|
|
564
|
+
try? await vault.removeEverything()
|
|
560
565
|
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
req.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
|
|
565
|
-
req.httpBody = "token=\(token)".data(using: .utf8)
|
|
566
|
-
_ = try await URLSession.shared.data(for: req)
|
|
566
|
+
let jar = HTTPCookieStorage.shared
|
|
567
|
+
jar.cookies?.filter { $0.domain.hasSuffix(authHost) }.forEach(jar.deleteCookie)
|
|
568
|
+
URLCache.shared.removeAllCachedResponses()
|
|
567
569
|
}
|
|
568
570
|
}
|
|
569
571
|
```
|
|
570
572
|
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
## Key Rotation and Versioned Migration
|
|
576
|
-
|
|
577
|
-
### Rotation Strategies by Secret Type
|
|
578
|
-
|
|
579
|
-
**OAuth refresh tokens** - rely on server-driven RTR. Okta's model issues a new refresh token on every use with a configurable grace period (0-60 seconds). If a previously invalidated token is reused outside the grace period, the server invalidates the entire token family.
|
|
580
|
-
|
|
581
|
-
**Long-lived API keys** - rotation is a planned event: generate a new least-privilege key, deploy it, verify operation, then revoke the old one. Maintain emergency playbooks for compromise scenarios.
|
|
573
|
+
Revocation is best effort and happens first (refresh token, then access token); local
|
|
574
|
+
cleanup happens regardless. The server can also force a logout: return 401 or 403
|
|
575
|
+
with a reason such as `token_revoked`, or send a silent push that triggers the same
|
|
576
|
+
cleanup in the background.
|
|
582
577
|
|
|
583
|
-
|
|
578
|
+
## Rotation and versioned storage
|
|
584
579
|
|
|
585
|
-
|
|
580
|
+
- Refresh tokens rotate on the server's schedule (RTR); grace periods are typically
|
|
581
|
+
configurable between 0 and 60 seconds.
|
|
582
|
+
- Long-lived API keys need a planned rotation (issue a new least-privilege key,
|
|
583
|
+
ship, verify traffic, revoke the old one) and a written emergency procedure for a
|
|
584
|
+
leak.
|
|
585
|
+
- Version the storage layout through the account name, for example
|
|
586
|
+
`oauth_tokens_v2`, so a format change can be migrated and rolled back.
|
|
586
587
|
|
|
587
588
|
```swift
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
/// Call on app launch to migrate old token formats.
|
|
598
|
-
func migrateIfNeeded() async throws {
|
|
599
|
-
if let _ = try? await keychain.load(account: "oauth_tokens_v2") {
|
|
600
|
-
return // Already current
|
|
601
|
-
}
|
|
602
|
-
if let oldData = try? await keychain.load(account: "oauth_tokens") {
|
|
603
|
-
let migrated = try migrateV1ToV2(oldData)
|
|
604
|
-
try await keychain.save(account: "oauth_tokens_v2", data: migrated)
|
|
605
|
-
try await keychain.delete(account: "oauth_tokens") // Clean up old
|
|
606
|
-
}
|
|
589
|
+
extension CredentialVault {
|
|
590
|
+
func upgradeTokenLayout() throws {
|
|
591
|
+
if (try? read(account: "oauth_tokens_v2")) != nil { return }
|
|
592
|
+
guard let legacy = try? read(account: "oauth_tokens_v1") else { return }
|
|
593
|
+
|
|
594
|
+
let converted = try convertV1ToV2(legacy)
|
|
595
|
+
try store(converted, account: "oauth_tokens_v2")
|
|
596
|
+
guard (try? read(account: "oauth_tokens_v2")) == converted else { return }
|
|
597
|
+
try remove(account: "oauth_tokens_v1")
|
|
607
598
|
}
|
|
608
599
|
|
|
609
|
-
private func
|
|
610
|
-
// Implement format conversion between versions
|
|
611
|
-
return data
|
|
612
|
-
}
|
|
600
|
+
private func convertV1ToV2(_ data: Data) throws -> Data { data }
|
|
613
601
|
}
|
|
614
602
|
```
|
|
615
603
|
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
604
|
+
The old item is removed as soon as the new one has been written and read back.
|
|
605
|
+
Keeping the old copy around "for rollback" only doubles the number of places a
|
|
606
|
+
credential can leak from; the same rule applies with more force to plaintext copies
|
|
607
|
+
in UserDefaults or files, which are deleted in the same step that verifies the
|
|
608
|
+
Keychain write (see [migration-legacy-stores.md](migration-legacy-stores.md)).
|
|
619
609
|
|
|
620
|
-
|
|
610
|
+
Signals of compromise: a rotated refresh token being replayed (revoke the family),
|
|
611
|
+
unusual location or time patterns, and leaked-password checks some identity
|
|
612
|
+
providers run against breach corpora. Refresh proactively at 75-90% of lifetime.
|
|
621
613
|
|
|
622
|
-
## Device
|
|
614
|
+
## Device binding and restores
|
|
623
615
|
|
|
624
|
-
|
|
616
|
+
`ThisDeviceOnly` items cannot be copied to another device, which also means they do
|
|
617
|
+
not come back after restoring an iCloud backup onto a new phone. Detect that on first
|
|
618
|
+
launch and send the user to sign in:
|
|
625
619
|
|
|
626
620
|
```swift
|
|
627
|
-
|
|
628
|
-
func handleAppLaunch() async {
|
|
621
|
+
func routeAtLaunch(vault: CredentialVault) async -> LaunchDestination {
|
|
629
622
|
do {
|
|
630
|
-
let
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
await presentLoginScreen()
|
|
623
|
+
let raw = try await vault.read(account: "oauth_tokens_v2")
|
|
624
|
+
_ = try JSONDecoder().decode(SessionTokens.self, from: raw)
|
|
625
|
+
return .home
|
|
626
|
+
} catch CredentialVault.Failure.missing {
|
|
627
|
+
return .signIn
|
|
636
628
|
} catch {
|
|
637
|
-
//
|
|
638
|
-
|
|
639
|
-
await presentLoginScreen()
|
|
629
|
+
// log the failure without the token contents
|
|
630
|
+
return .signIn
|
|
640
631
|
}
|
|
641
632
|
}
|
|
642
|
-
```
|
|
643
633
|
|
|
644
|
-
|
|
634
|
+
enum LaunchDestination { case home, signIn }
|
|
635
|
+
```
|
|
645
636
|
|
|
646
|
-
|
|
637
|
+
`kSecAttrSynchronizable: true` copies an item to every device on the user's iCloud
|
|
638
|
+
account. That suits website passwords and widens the attack surface for session
|
|
639
|
+
tokens, so leave it out. The two cannot be combined anyway: Apple documents that a
|
|
640
|
+
synchronizable item may not use a `ThisDeviceOnly` accessibility value, and
|
|
641
|
+
`SecItemAdd` rejects that combination with `errSecParam` (-50).
|
|
647
642
|
|
|
648
|
-
##
|
|
643
|
+
## Biometrics on high-value credentials
|
|
649
644
|
|
|
650
|
-
|
|
645
|
+
A biometric `SecAccessControl` fits secrets used in a user-initiated moment, such as
|
|
646
|
+
authorizing a payment or revealing sensitive data. It does not fit a refresh token
|
|
647
|
+
that must renew silently in the background.
|
|
651
648
|
|
|
652
649
|
```swift
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
var error: Unmanaged<CFError>?
|
|
657
|
-
guard let accessControl = SecAccessControlCreateWithFlags(
|
|
658
|
-
kCFAllocatorDefault,
|
|
650
|
+
func protectPaymentPIN(_ pin: Data) throws {
|
|
651
|
+
guard let gate = SecAccessControlCreateWithFlags(
|
|
652
|
+
nil,
|
|
659
653
|
kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly,
|
|
660
654
|
[.biometryCurrentSet, .or, .devicePasscode],
|
|
661
|
-
|
|
662
|
-
) else {
|
|
663
|
-
throw error!.takeRetainedValue() as Error
|
|
664
|
-
}
|
|
655
|
+
nil
|
|
656
|
+
) else { throw CredentialVault.Failure.encoding }
|
|
665
657
|
|
|
666
|
-
let
|
|
667
|
-
kSecClass: kSecClassGenericPassword,
|
|
668
|
-
kSecAttrService: "com.
|
|
669
|
-
kSecAttrAccount
|
|
670
|
-
|
|
671
|
-
kSecAttrAccessControl: accessControl,
|
|
672
|
-
kSecUseDataProtectionKeychain: true
|
|
658
|
+
let identity: [String: Any] = [
|
|
659
|
+
kSecClass as String: kSecClassGenericPassword,
|
|
660
|
+
kSecAttrService as String: "com.example.bank.auth",
|
|
661
|
+
kSecAttrAccount as String: "payment_pin",
|
|
662
|
+
kSecUseDataProtectionKeychain as String: true
|
|
673
663
|
]
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
664
|
+
var insert = identity
|
|
665
|
+
insert[kSecValueData as String] = pin
|
|
666
|
+
insert[kSecAttrAccessControl as String] = gate
|
|
667
|
+
|
|
668
|
+
var status = SecItemAdd(insert as CFDictionary, nil)
|
|
669
|
+
if status == errSecDuplicateItem {
|
|
670
|
+
SecItemDelete(identity as CFDictionary)
|
|
671
|
+
status = SecItemAdd(insert as CFDictionary, nil)
|
|
678
672
|
}
|
|
673
|
+
guard status == errSecSuccess else { throw CredentialVault.Failure.status(status) }
|
|
679
674
|
}
|
|
680
675
|
```
|
|
681
676
|
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
|
706
|
-
|
|
|
707
|
-
|
|
|
708
|
-
|
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
|
721
|
-
|
|
|
722
|
-
|
|
|
723
|
-
|
|
|
724
|
-
|
|
|
725
|
-
|
|
|
726
|
-
|
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
The
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
##
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
677
|
+
`.or` needs iOS 11.3+. This is the MASTG L2 configuration: device-bound, passcode
|
|
678
|
+
required, current biometric set or passcode on every read. It is also the one case
|
|
679
|
+
where a duplicate is handled by delete-and-re-add, because access control cannot be
|
|
680
|
+
changed with `SecItemUpdate`. Reads that the user cancels fail with
|
|
681
|
+
`errSecUserCanceled` (-128). See
|
|
682
|
+
[biometric-authentication.md](biometric-authentication.md).
|
|
683
|
+
|
|
684
|
+
## What iOS 17 and 18 changed
|
|
685
|
+
|
|
686
|
+
- iOS 17: HTTPS callbacks through `ASWebAuthenticationSession.Callback` (17.4+);
|
|
687
|
+
shared password groups synced end-to-end encrypted through iCloud Keychain;
|
|
688
|
+
credential provider extensions can offer passkeys.
|
|
689
|
+
- iOS 18: the Passwords app; automatic passkey upgrades using the `.conditional`
|
|
690
|
+
request style; credential providers can fill verification codes. No new `SecItem*`
|
|
691
|
+
API.
|
|
692
|
+
- WWDC 2024 session 10125 covers automatic passkey upgrades; WWDC 2021 session 10105
|
|
693
|
+
covers on-device verification codes synced through iCloud Keychain.
|
|
694
|
+
- The community package `swift-keychain-kit` experiments with a `~Copyable`
|
|
695
|
+
`SecretData` that `mlock`s its buffer and zeroes it on deallocation. It is not an
|
|
696
|
+
Apple framework, but it shows where secret handling in Swift is heading.
|
|
697
|
+
|
|
698
|
+
## Guardrails in CI
|
|
699
|
+
|
|
700
|
+
| Tool | Stage | Checks |
|
|
701
|
+
| --- | --- | --- |
|
|
702
|
+
| truffleHog, gitleaks | Pre-commit hook, PR | Secrets in source |
|
|
703
|
+
| `strings`, class-dump | After build | Secrets in the binary |
|
|
704
|
+
| SwiftLint custom rule | Lint | `UserDefaults` writes with token-like keys |
|
|
705
|
+
| Frida, Objection | QA, pentest | Actual `kSecAttrAccessible` values at runtime |
|
|
706
|
+
| MobSF | Dynamic scan | Storage and network leakage |
|
|
707
|
+
|
|
708
|
+
A secret found in code or in the binary fails the build.
|
|
709
|
+
|
|
710
|
+
## OWASP mapping
|
|
711
|
+
|
|
712
|
+
OWASP Mobile Top 10 (2024) puts M1, Improper Credential Usage, first. MASVS v2.1.0
|
|
713
|
+
adds MASWE, the weakness list that links controls to MASTG tests.
|
|
714
|
+
|
|
715
|
+
| Control | Top 10 | MASVS | MASWE | MASTG |
|
|
716
|
+
| --- | --- | --- | --- | --- |
|
|
717
|
+
| Keychain, WhenUnlockedThisDeviceOnly | M1, M9 | STORAGE-1 | 0002, 0004, 0036 | TEST-0299, 0300, 0301, 0302 |
|
|
718
|
+
| Actor-serialized access | M9 | STORAGE-1 | 0002 | TEST-0300 |
|
|
719
|
+
| Ephemeral `ASWebAuthenticationSession` | M1 | AUTH-1 | 0032 | TEST-0064 |
|
|
720
|
+
| Atomic refresh | M1 | AUTH-1 | 0038 | |
|
|
721
|
+
| Runtime secret fetch | M1 | STORAGE-1 | 0005 | |
|
|
722
|
+
| Logout cleanup | M9 | STORAGE-2 | 0004 | TEST-0298 |
|
|
723
|
+
| Biometrics plus ThisDeviceOnly | M9 | STORAGE-2 | 0046 | TEST-0298, DEMO-0043 to 0047 |
|
|
724
|
+
|
|
725
|
+
The legacy MSTG-STORAGE-1 and -2 tests (MASTG-TEST-0052 and 0053) are deprecated and
|
|
726
|
+
replaced by MASTG-TEST-0296 through 0314. More in
|
|
727
|
+
[compliance-owasp-mapping.md](compliance-owasp-mapping.md).
|
|
728
|
+
|
|
729
|
+
## The three decisions that matter
|
|
730
|
+
|
|
731
|
+
One actor is the only door to the Keychain. Runtime secrets come from your own
|
|
732
|
+
backend, gated by App Attest. Every auth item shares one `kSecAttrService` so logout
|
|
733
|
+
is a single call.
|
|
734
|
+
|
|
735
|
+
## Checklist
|
|
736
|
+
|
|
737
|
+
- [ ] Credentials only in the Keychain with WhenUnlockedThisDeviceOnly; never
|
|
738
|
+
UserDefaults, `Info.plist`, `.xcconfig` or source.
|
|
739
|
+
- [ ] All Keychain access through one actor or a `@globalActor`.
|
|
740
|
+
- [ ] Sign-in via `ASWebAuthenticationSession`, ephemeral, with PKCE (RFC 7636);
|
|
741
|
+
never `WKWebView` or `SFSafariViewController`.
|
|
742
|
+
- [ ] Saves are add-then-update-on-duplicate; delete-and-re-add only when access
|
|
743
|
+
control changes.
|
|
744
|
+
- [ ] Refresh encodes first, writes both tokens in one item, and coalesces concurrent
|
|
745
|
+
refreshes.
|
|
746
|
+
- [ ] Runtime API keys handed out only after the backend checks an App Attest (or
|
|
747
|
+
DeviceCheck) proof, iOS 14+, and cached with an app-managed expiry; memory, then Keychain, then network.
|
|
748
|
+
- [ ] Logout: delete by service, revoke on the server, clear cookies and `URLCache`.
|
|
749
|
+
- [ ] No `kSecAttrSynchronizable` on app tokens.
|
|
750
|
+
- [ ] Missing ThisDeviceOnly credentials after a restore route to sign-in.
|
|
751
|
+
- [ ] Versioned account names; old versions deleted once the new write is verified.
|
|
752
|
+
- [ ] CI secret scanning (truffleHog, gitleaks, `strings`) fails the build.
|
|
753
|
+
- [ ] M1, M9, MASVS-STORAGE-1 and MASVS-AUTH-1 checked against MASTG-TEST-0298 to
|
|
754
|
+
0302 and with Frida or Objection.
|