@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
|
@@ -1,655 +1,462 @@
|
|
|
1
|
-
# Testing
|
|
1
|
+
# Testing Security Code
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The pattern that makes keychain code testable is a protocol between the app and the Security framework. Unit tests run against an in-memory implementation; tests that need the real keychain, the Secure Enclave or biometric hardware run on devices.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
The keychain does not behave the same on Simulator, on a headless CI runner and on a phone. Tests written without that in mind fail intermittently, crash on runners, or pass while proving nothing.
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
Targets: Swift 5.9 and Swift 6, iOS 17 and 18, with notes for the iOS 26 post-quantum APIs. Background: TN3137 (macOS keychain APIs and implementations), WWDC19 session 413 "Testing in Xcode", the WWDC24 Swift Testing sessions (10179 and 10195), Apple Platform Security, and the OWASP MASTG.
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
---
|
|
9
|
+
Related: [keychain-fundamentals.md](keychain-fundamentals.md), [cryptokit-public-key.md](cryptokit-public-key.md), [migration-legacy-stores.md](migration-legacy-stores.md), [common-anti-patterns.md](common-anti-patterns.md).
|
|
12
10
|
|
|
13
11
|
## Contents
|
|
14
12
|
|
|
15
|
-
- [
|
|
16
|
-
|
|
17
|
-
- [
|
|
18
|
-
- [
|
|
19
|
-
|
|
20
|
-
- [
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
- [
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
- [Protocol-Based Approach (Preferred)](#protocol-based-approach-preferred)
|
|
30
|
-
- [Biometric Scenarios to Cover](#biometric-scenarios-to-cover)
|
|
31
|
-
- [CI/CD Pipeline Configuration](#cicd-pipeline-configuration)
|
|
32
|
-
- [GitHub Actions](#github-actions)
|
|
33
|
-
- [Common CI Error Reference](#common-ci-error-reference)
|
|
34
|
-
- [Test Host App Requirement](#test-host-app-requirement)
|
|
35
|
-
- [Xcode Test Plans - Separating Simulator from Device](#xcode-test-plans-separating-simulator-from-device)
|
|
36
|
-
- [Swift Testing Framework Patterns](#swift-testing-framework-patterns)
|
|
37
|
-
- [Advanced Patterns](#advanced-patterns)
|
|
38
|
-
- [Migration Testing: UserDefaults to Keychain](#migration-testing-userdefaults-to-keychain)
|
|
39
|
-
- [Performance Testing](#performance-testing)
|
|
40
|
-
- [Mutation Testing](#mutation-testing)
|
|
41
|
-
- [OWASP MASTG Keychain Validation](#owasp-mastg-keychain-validation)
|
|
42
|
-
- [Conclusion](#conclusion)
|
|
43
|
-
- [Summary Checklist](#summary-checklist)
|
|
44
|
-
|
|
45
|
-
## Protocol-Based Keychain Abstraction
|
|
46
|
-
|
|
47
|
-
The foundation of testable keychain code is a protocol abstracting the four Security framework operations. Every view model, service, or manager that touches the keychain depends on this protocol, never on the Security framework directly.
|
|
48
|
-
|
|
49
|
-
### KeychainServiceProtocol with Real and Mock Implementations
|
|
13
|
+
- [A protocol in front of the keychain](#a-protocol-in-front-of-the-keychain)
|
|
14
|
+
- [Seven mistakes in generated keychain tests](#seven-mistakes-in-generated-keychain-tests)
|
|
15
|
+
- [Simulator versus device](#simulator-versus-device)
|
|
16
|
+
- [Core test patterns](#core-test-patterns)
|
|
17
|
+
- [Secure Enclave tests](#secure-enclave-tests)
|
|
18
|
+
- [Biometric flows](#biometric-flows)
|
|
19
|
+
- [CI configuration](#ci-configuration)
|
|
20
|
+
- [Test plans](#test-plans)
|
|
21
|
+
- [Swift Testing](#swift-testing)
|
|
22
|
+
- [Further techniques](#further-techniques)
|
|
23
|
+
- [Takeaways](#takeaways)
|
|
24
|
+
- [Checklist](#checklist)
|
|
25
|
+
|
|
26
|
+
## A protocol in front of the keychain
|
|
50
27
|
|
|
51
28
|
```swift
|
|
52
29
|
import Foundation
|
|
53
30
|
import Security
|
|
54
31
|
|
|
55
|
-
enum
|
|
56
|
-
case duplicateItem
|
|
57
|
-
case itemNotFound
|
|
58
|
-
case authFailed
|
|
59
|
-
case interactionNotAllowed
|
|
32
|
+
enum VaultStoreError: Error, Equatable {
|
|
33
|
+
case duplicateItem, itemNotFound, authFailed, userCanceled, interactionNotAllowed
|
|
60
34
|
case unexpectedData
|
|
61
|
-
case
|
|
35
|
+
case unhandled(status: OSStatus)
|
|
62
36
|
|
|
63
37
|
init(status: OSStatus) {
|
|
64
38
|
switch status {
|
|
65
|
-
case errSecDuplicateItem:
|
|
66
|
-
case errSecItemNotFound:
|
|
67
|
-
case errSecAuthFailed:
|
|
68
|
-
case
|
|
69
|
-
|
|
39
|
+
case errSecDuplicateItem: self = .duplicateItem
|
|
40
|
+
case errSecItemNotFound: self = .itemNotFound
|
|
41
|
+
case errSecAuthFailed: self = .authFailed
|
|
42
|
+
case errSecUserCanceled: self = .userCanceled
|
|
43
|
+
case errSecInteractionNotAllowed: self = .interactionNotAllowed
|
|
44
|
+
default: self = .unhandled(status: status)
|
|
70
45
|
}
|
|
71
46
|
}
|
|
72
47
|
}
|
|
73
48
|
|
|
74
|
-
protocol
|
|
75
|
-
func
|
|
76
|
-
func
|
|
77
|
-
func
|
|
78
|
-
func
|
|
79
|
-
func
|
|
49
|
+
protocol SecretStore: Sendable {
|
|
50
|
+
func put(_ secret: Data, for name: String) throws
|
|
51
|
+
func value(for name: String) throws -> Data?
|
|
52
|
+
func replace(_ secret: Data, for name: String) throws
|
|
53
|
+
func remove(for name: String) throws
|
|
54
|
+
func removeAll() throws
|
|
80
55
|
}
|
|
81
56
|
```
|
|
82
57
|
|
|
83
|
-
The
|
|
58
|
+
The production implementation, `KeychainSecretStore`, follows the rules in [keychain-fundamentals.md](keychain-fundamentals.md): `put` calls `SecItemAdd` and, on `errSecDuplicateItem`, `SecItemUpdate` with the new data; `remove` treats `errSecItemNotFound` as success; it is a final class whose stored properties are all `let`, marked `@unchecked Sendable`.
|
|
84
59
|
|
|
85
|
-
The
|
|
60
|
+
The test double keeps everything in a dictionary, counts calls and throws on request. It runs on Simulator, on CI and even on Linux, and needs no entitlements.
|
|
86
61
|
|
|
87
62
|
```swift
|
|
88
|
-
final class
|
|
89
|
-
|
|
90
|
-
var
|
|
91
|
-
var
|
|
92
|
-
var
|
|
93
|
-
var
|
|
94
|
-
|
|
95
|
-
func
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
func
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
func update(_ data: Data, forKey key: String) throws {
|
|
108
|
-
if let error = errorToThrow { throw error }
|
|
109
|
-
guard storage[key] != nil else { throw KeychainError.itemNotFound }
|
|
110
|
-
storage[key] = data
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
func delete(forKey key: String) throws {
|
|
114
|
-
if let error = errorToThrow { throw error }
|
|
115
|
-
storage.removeValue(forKey: key)
|
|
116
|
-
deleteCallCount += 1
|
|
63
|
+
final class InMemorySecretStore: SecretStore, @unchecked Sendable {
|
|
64
|
+
private let lock = NSLock()
|
|
65
|
+
private var rows: [String: Data] = [:]
|
|
66
|
+
private(set) var saveCount = 0
|
|
67
|
+
private(set) var readCount = 0
|
|
68
|
+
var failure: VaultStoreError?
|
|
69
|
+
|
|
70
|
+
func put(_ secret: Data, for name: String) throws {
|
|
71
|
+
try locked { saveCount += 1; rows[name] = secret }
|
|
72
|
+
}
|
|
73
|
+
func value(for name: String) throws -> Data? {
|
|
74
|
+
try locked { readCount += 1; return rows[name] }
|
|
75
|
+
}
|
|
76
|
+
func replace(_ secret: Data, for name: String) throws {
|
|
77
|
+
try locked {
|
|
78
|
+
guard rows[name] != nil else { throw VaultStoreError.itemNotFound }
|
|
79
|
+
rows[name] = secret
|
|
80
|
+
}
|
|
117
81
|
}
|
|
82
|
+
func remove(for name: String) throws { try locked { rows[name] = nil } }
|
|
83
|
+
func removeAll() throws { try locked { rows.removeAll() } }
|
|
118
84
|
|
|
119
|
-
func
|
|
120
|
-
|
|
121
|
-
|
|
85
|
+
private func locked<T>(_ body: () throws -> T) throws -> T {
|
|
86
|
+
lock.lock(); defer { lock.unlock() }
|
|
87
|
+
if let failure { throw failure }
|
|
88
|
+
return try body()
|
|
122
89
|
}
|
|
123
90
|
}
|
|
124
91
|
```
|
|
125
92
|
|
|
126
|
-
|
|
93
|
+
Code under test depends only on the protocol:
|
|
127
94
|
|
|
128
95
|
```swift
|
|
129
|
-
|
|
130
|
-
|
|
96
|
+
struct SessionKeeper {
|
|
97
|
+
let store: any SecretStore
|
|
131
98
|
|
|
132
|
-
|
|
133
|
-
|
|
99
|
+
func remember(token: String) throws {
|
|
100
|
+
try store.put(Data(token.utf8), for: "session.token")
|
|
134
101
|
}
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
guard let data = token.data(using: .utf8) else {
|
|
138
|
-
throw KeychainError.unexpectedData
|
|
139
|
-
}
|
|
140
|
-
try keychain.save(data, forKey: "auth_token")
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
func retrieveToken() throws -> String? {
|
|
144
|
-
guard let data = try keychain.read(forKey: "auth_token") else { return nil }
|
|
145
|
-
return String(data: data, encoding: .utf8)
|
|
102
|
+
func currentToken() throws -> String? {
|
|
103
|
+
try store.value(for: "session.token").map { String(decoding: $0, as: UTF8.self) }
|
|
146
104
|
}
|
|
147
105
|
}
|
|
148
106
|
```
|
|
149
107
|
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
## Seven Mistakes AI Generators Make in Keychain Tests
|
|
153
|
-
|
|
154
|
-
Both research providers independently identified overlapping anti-patterns. This merged list covers the full set:
|
|
155
|
-
|
|
156
|
-
**1. Tests that use the real keychain without cleanup.** Tests calling `SecItemAdd` directly leave state across runs. Second run fails with `errSecDuplicateItem` (-25299). AI generators rarely include `setUp`/`tearDown` cleanup.
|
|
157
|
-
|
|
158
|
-
**2. Assuming Secure Enclave exists on simulator.** `SecureEnclave.isAvailable` returns `false` on every simulator. Tests calling `SecureEnclave.P256.Signing.PrivateKey()` directly throw `CryptoKitError` on simulator and crash CI.
|
|
108
|
+
## Seven mistakes in generated keychain tests
|
|
159
109
|
|
|
160
|
-
|
|
110
|
+
1. **No cleanup around real-keychain tests.** The first run passes; the second hits `errSecDuplicateItem` (-25299).
|
|
111
|
+
2. **Expecting a Secure Enclave on Simulator.** The Simulator has no Secure Enclave. Branch at compile time with `#if targetEnvironment(simulator)` and treat `SecureEnclave.isAvailable` as the runtime check on real hardware only; do not build Simulator behaviour on what the flag reports there. Creating a Secure Enclave key without that guard throws a `CryptoKitError` or fails the test run.
|
|
112
|
+
3. **Only testing the happy path.** Cover -25299, -25300 (`errSecItemNotFound`), -25293 (`errSecAuthFailed`), -128 (`errSecUserCanceled`) and -25308 (`errSecInteractionNotAllowed`).
|
|
113
|
+
4. **Expecting biometric hardware.** `canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error:)` returns false on Simulator unless enrolment is simulated.
|
|
114
|
+
5. **No host app.** From Xcode 9 on, a logic test bundle on the iOS Simulator has no keychain access of its own. Give it a host application; otherwise expect `SecItemAdd` to fail with -25300 or -34018 (`errSecMissingEntitlement`).
|
|
115
|
+
6. **Unscoped items.** Tests must use their own `kSecAttrService` so they never touch app data or each other's rows.
|
|
116
|
+
7. **Mixing up keychain types.** The `security` command-line tool works on file-based keychains. iOS apps, and macOS apps using the data protection keychain, use a different store, so `security create-keychain` on a CI runner creates the wrong kind of keychain for testing `SecItemAdd`.
|
|
161
117
|
|
|
162
|
-
|
|
118
|
+
## Simulator versus device
|
|
163
119
|
|
|
164
|
-
|
|
120
|
+
| Capability | Simulator | Device |
|
|
121
|
+
| --- | --- | --- |
|
|
122
|
+
| Keychain add, read, update, delete | Works | Works |
|
|
123
|
+
| CryptoKit software algorithms | Works | Works, hardware accelerated |
|
|
124
|
+
| `kSecAttrAccessible` | Accepted, not enforced by hardware | Enforced |
|
|
125
|
+
| Secure Enclave | Not present; key creation fails | Present on A7 and later |
|
|
126
|
+
| Biometric-protected items | Returned without any prompt | Prompt shown |
|
|
127
|
+
| `canEvaluatePolicy` for biometrics | False, unless enrolment is simulated from the Simulator's Features menu | True when enrolled |
|
|
128
|
+
| ML-KEM and ML-DSA (iOS 26) | Works in software | Works |
|
|
165
129
|
|
|
166
|
-
|
|
130
|
+
The biometric row is the dangerous one: a Simulator test for a Face ID protected item passes without any authentication, which proves nothing about the real behaviour.
|
|
167
131
|
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
---
|
|
171
|
-
|
|
172
|
-
## Simulator vs. Device Testing Matrix
|
|
173
|
-
|
|
174
|
-
Understanding exactly what works where prevents entire categories of test failures:
|
|
175
|
-
|
|
176
|
-
| Feature | Simulator | Physical Device |
|
|
177
|
-
| ------------------------------------------------------------- | ------------------------------------- | ---------------------------- |
|
|
178
|
-
| Keychain CRUD (`SecItemAdd`, etc.) | ✅ Works | ✅ Works |
|
|
179
|
-
| CryptoKit software crypto (AES-GCM, ChaChaPoly, P256, SHA256) | ✅ Software | ✅ Hardware-accelerated |
|
|
180
|
-
| `kSecAttrAccessible` values | ✅ Accepted but not hardware-enforced | ✅ Hardware-enforced |
|
|
181
|
-
| `SecureEnclave.isAvailable` | Returns **false** | Returns **true** (A7+) |
|
|
182
|
-
| `SecureEnclave.P256.Signing.PrivateKey()` | ❌ Throws | ✅ Works |
|
|
183
|
-
| Biometric prompt on protected items | ❌ Skipped - value returned silently | ✅ Shows prompt |
|
|
184
|
-
| `LAContext.canEvaluatePolicy(.biometrics)` | Returns **false** | Returns **true** if enrolled |
|
|
185
|
-
| Face ID simulation via Xcode menu | ✅ Manual only | N/A (real hardware) |
|
|
186
|
-
| Post-quantum (ML-KEM, ML-DSA) iOS 26+ | ✅ Software (iOS 26 runtime) | ✅ Works |
|
|
187
|
-
|
|
188
|
-
**Critical subtlety:** On simulator, keychain items protected with `kSecAttrAccessControl` and biometric flags return their value without showing a biometric prompt. Simulator tests that store biometric-protected items and read them succeed silently, giving false confidence the biometric gate works.
|
|
189
|
-
|
|
190
|
-
### Conditional Compilation and Runtime Guards
|
|
132
|
+
Guards:
|
|
191
133
|
|
|
192
134
|
```swift
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
let signingKey = SecureEnclave.isAvailable
|
|
198
|
-
? try SecureEnclaveSigningKey()
|
|
199
|
-
: SoftwareSigningKey()
|
|
200
|
-
#endif
|
|
201
|
-
|
|
202
|
-
// Runtime skip in XCTest
|
|
203
|
-
func testDeviceOnlyFeature() throws {
|
|
135
|
+
import CryptoKit
|
|
136
|
+
import XCTest
|
|
137
|
+
|
|
138
|
+
func makeSigner() throws -> any SigningKeyProvider {
|
|
204
139
|
#if targetEnvironment(simulator)
|
|
205
|
-
|
|
140
|
+
return SoftwareSigningKey()
|
|
141
|
+
#else
|
|
142
|
+
return SecureEnclave.isAvailable ? try SecureEnclaveSigningKey() : SoftwareSigningKey()
|
|
206
143
|
#endif
|
|
207
|
-
// Device-only test code here
|
|
208
144
|
}
|
|
209
145
|
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
}
|
|
215
|
-
static var isRunningTests: Bool {
|
|
216
|
-
ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] != nil
|
|
217
|
-
}
|
|
146
|
+
func requireHardware() throws {
|
|
147
|
+
#if targetEnvironment(simulator)
|
|
148
|
+
throw XCTSkip("Requires a physical device")
|
|
149
|
+
#endif
|
|
218
150
|
}
|
|
219
|
-
```
|
|
220
151
|
|
|
221
|
-
|
|
152
|
+
let runningInSimulator = ProcessInfo.processInfo.environment["SIMULATOR_DEVICE_NAME"] != nil
|
|
153
|
+
let runningUnderTests = ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] != nil
|
|
154
|
+
```
|
|
222
155
|
|
|
223
|
-
##
|
|
156
|
+
## Core test patterns
|
|
224
157
|
|
|
225
|
-
|
|
158
|
+
Real-keychain integration test, with its own service and cleanup on both sides:
|
|
226
159
|
|
|
227
160
|
```swift
|
|
228
|
-
final class
|
|
229
|
-
private
|
|
230
|
-
private var keychain: KeychainService!
|
|
231
|
-
|
|
232
|
-
override func setUp() {
|
|
233
|
-
super.setUp()
|
|
234
|
-
keychain = KeychainService(service: testService)
|
|
235
|
-
try? keychain.deleteAll() // Clean slate
|
|
236
|
-
}
|
|
161
|
+
final class KeychainSecretStoreTests: XCTestCase {
|
|
162
|
+
private var store: KeychainSecretStore!
|
|
237
163
|
|
|
238
|
-
override func
|
|
239
|
-
|
|
240
|
-
|
|
164
|
+
override func setUpWithError() throws {
|
|
165
|
+
store = KeychainSecretStore(service: "tests.vault.\(name)")
|
|
166
|
+
try store.removeAll()
|
|
241
167
|
}
|
|
242
168
|
|
|
243
|
-
func
|
|
244
|
-
|
|
245
|
-
try keychain.save(token.data(using: .utf8)!, forKey: "access_token")
|
|
246
|
-
let retrieved = try keychain.read(forKey: "access_token")
|
|
247
|
-
XCTAssertEqual(String(data: retrieved!, encoding: .utf8), token)
|
|
169
|
+
override func tearDownWithError() throws {
|
|
170
|
+
try store.removeAll()
|
|
248
171
|
}
|
|
249
|
-
}
|
|
250
|
-
```
|
|
251
172
|
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
final class BadKeychainTests: XCTestCase {
|
|
257
|
-
func testSaveToken() {
|
|
258
|
-
let query: [String: Any] = [
|
|
259
|
-
kSecClass as String: kSecClassGenericPassword,
|
|
260
|
-
kSecAttrAccount as String: "token",
|
|
261
|
-
kSecValueData as String: "secret".data(using: .utf8)!
|
|
262
|
-
]
|
|
263
|
-
let status = SecItemAdd(query as CFDictionary, nil)
|
|
264
|
-
XCTAssertEqual(status, errSecSuccess)
|
|
265
|
-
// First run: passes ✅
|
|
266
|
-
// Second run: FAILS with errSecDuplicateItem (-25299) ❌
|
|
173
|
+
func testSaveThenRead_ReturnsSameBytes() throws {
|
|
174
|
+
let secret = Data("pin-4821".utf8)
|
|
175
|
+
try store.put(secret, for: "pin")
|
|
176
|
+
XCTAssertEqual(try store.value(for: "pin"), secret)
|
|
267
177
|
}
|
|
268
178
|
}
|
|
269
179
|
```
|
|
270
180
|
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
```swift
|
|
274
|
-
final class KeychainErrorPathTests: XCTestCase {
|
|
275
|
-
var mockKeychain: MockKeychainService!
|
|
276
|
-
var authManager: AuthenticationManager!
|
|
277
|
-
|
|
278
|
-
override func setUp() {
|
|
279
|
-
mockKeychain = MockKeychainService()
|
|
280
|
-
authManager = AuthenticationManager(keychain: mockKeychain)
|
|
281
|
-
}
|
|
181
|
+
What not to write: a test that calls `SecItemAdd` directly with no service attribute and no cleanup. It passes exactly once and then fails with -25299 forever.
|
|
282
182
|
|
|
283
|
-
|
|
284
|
-
mockKeychain.errorToThrow = .duplicateItem
|
|
285
|
-
XCTAssertThrowsError(try authManager.storeToken("token")) { error in
|
|
286
|
-
XCTAssertEqual(error as? KeychainError, .duplicateItem)
|
|
287
|
-
}
|
|
288
|
-
}
|
|
183
|
+
Error paths through the mock:
|
|
289
184
|
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
}
|
|
185
|
+
```swift
|
|
186
|
+
func testSaveWhileLocked_SurfacesInteractionNotAllowed() {
|
|
187
|
+
let mock = InMemorySecretStore()
|
|
188
|
+
mock.failure = .interactionNotAllowed
|
|
189
|
+
let keeper = SessionKeeper(store: mock)
|
|
296
190
|
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
mockKeychain.errorToThrow = .interactionNotAllowed
|
|
300
|
-
XCTAssertThrowsError(try authManager.retrieveToken()) { error in
|
|
301
|
-
XCTAssertEqual(error as? KeychainError, .interactionNotAllowed)
|
|
302
|
-
}
|
|
191
|
+
XCTAssertThrowsError(try keeper.remember(token: "abc")) { error in
|
|
192
|
+
XCTAssertEqual(error as? VaultStoreError, .interactionNotAllowed)
|
|
303
193
|
}
|
|
304
194
|
}
|
|
305
195
|
```
|
|
306
196
|
|
|
307
|
-
|
|
197
|
+
Repeat for `.duplicateItem`, `.authFailed` and `.userCanceled`. `interactionNotAllowed` deserves particular care: it is the failure CI runs hit most.
|
|
308
198
|
|
|
309
|
-
|
|
199
|
+
CryptoKit tests are Simulator-safe:
|
|
310
200
|
|
|
311
201
|
```swift
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
let sealedBox = try AES.GCM.seal(plaintext, using: key)
|
|
321
|
-
let ciphertext = sealedBox.combined!
|
|
322
|
-
XCTAssertNotEqual(ciphertext, plaintext)
|
|
323
|
-
|
|
324
|
-
let reopened = try AES.GCM.SealedBox(combined: ciphertext)
|
|
325
|
-
let decrypted = try AES.GCM.open(reopened, using: key)
|
|
326
|
-
XCTAssertEqual(decrypted, plaintext)
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
func testAESGCMWrongKeyFails() throws {
|
|
330
|
-
let correctKey = SymmetricKey(size: .bits256)
|
|
331
|
-
let wrongKey = SymmetricKey(size: .bits256)
|
|
332
|
-
let sealed = try AES.GCM.seal("secret".data(using: .utf8)!, using: correctKey)
|
|
333
|
-
XCTAssertThrowsError(try AES.GCM.open(sealed, using: wrongKey))
|
|
334
|
-
}
|
|
335
|
-
|
|
336
|
-
func testP256SignVerify() throws {
|
|
337
|
-
let privateKey = P256.Signing.PrivateKey()
|
|
338
|
-
let data = "Message to authenticate".data(using: .utf8)!
|
|
339
|
-
let signature = try privateKey.signature(for: data)
|
|
340
|
-
XCTAssertTrue(privateKey.publicKey.isValidSignature(signature, for: data))
|
|
341
|
-
|
|
342
|
-
let tampered = "Tampered message".data(using: .utf8)!
|
|
343
|
-
XCTAssertFalse(privateKey.publicKey.isValidSignature(signature, for: tampered))
|
|
344
|
-
}
|
|
345
|
-
|
|
346
|
-
func testCurve25519KeyAgreement() throws {
|
|
347
|
-
let alice = Curve25519.KeyAgreement.PrivateKey()
|
|
348
|
-
let bob = Curve25519.KeyAgreement.PrivateKey()
|
|
349
|
-
let aliceShared = try alice.sharedSecretFromKeyAgreement(with: bob.publicKey)
|
|
350
|
-
let bobShared = try bob.sharedSecretFromKeyAgreement(with: alice.publicKey)
|
|
202
|
+
func testGCMRoundTripAndWrongKey() throws {
|
|
203
|
+
let key = SymmetricKey(size: .bits256)
|
|
204
|
+
let wire = try XCTUnwrap(try AES.GCM.seal(Data("ledger".utf8), using: key).combined)
|
|
205
|
+
let parsed = try AES.GCM.SealedBox(combined: wire)
|
|
206
|
+
XCTAssertEqual(try AES.GCM.open(parsed, using: key), Data("ledger".utf8))
|
|
207
|
+
let stranger = SymmetricKey(size: .bits256)
|
|
208
|
+
XCTAssertThrowsError(try AES.GCM.open(parsed, using: stranger))
|
|
209
|
+
}
|
|
351
210
|
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
211
|
+
func testP256SignatureRejectsTampering() throws {
|
|
212
|
+
let signer = P256.Signing.PrivateKey()
|
|
213
|
+
let signature = try signer.signature(for: Data("amount=10".utf8))
|
|
214
|
+
XCTAssertTrue(signer.publicKey.isValidSignature(signature, for: Data("amount=10".utf8)))
|
|
215
|
+
XCTAssertFalse(signer.publicKey.isValidSignature(signature, for: Data("amount=99".utf8)))
|
|
216
|
+
}
|
|
356
217
|
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
218
|
+
func testX25519PartiesDeriveMatchingKeys() throws {
|
|
219
|
+
let alice = Curve25519.KeyAgreement.PrivateKey()
|
|
220
|
+
let bob = Curve25519.KeyAgreement.PrivateKey()
|
|
221
|
+
let salt = Data("pairing-v1".utf8)
|
|
222
|
+
func derive(_ mine: Curve25519.KeyAgreement.PrivateKey,
|
|
223
|
+
_ theirs: Curve25519.KeyAgreement.PublicKey) throws -> SymmetricKey {
|
|
224
|
+
try mine.sharedSecretFromKeyAgreement(with: theirs)
|
|
225
|
+
.hkdfDerivedSymmetricKey(using: SHA384.self, salt: salt,
|
|
226
|
+
sharedInfo: Data("chat".utf8), outputByteCount: 32)
|
|
227
|
+
}
|
|
228
|
+
let aliceKey = try derive(alice, bob.publicKey)
|
|
229
|
+
let bobKey = try derive(bob, alice.publicKey)
|
|
230
|
+
let sealed = try ChaChaPoly.seal(Data("hello".utf8), using: aliceKey)
|
|
231
|
+
XCTAssertEqual(try ChaChaPoly.open(sealed, using: bobKey), Data("hello".utf8))
|
|
361
232
|
}
|
|
362
233
|
```
|
|
363
234
|
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
---
|
|
235
|
+
Post-quantum tests follow the same round-trip shape and are gated on availability; the software implementations run on the iOS 26 Simulator runtime.
|
|
367
236
|
|
|
368
|
-
|
|
237
|
+
```swift
|
|
238
|
+
@available(iOS 26, *)
|
|
239
|
+
func verifyMLKEMRoundTrip() throws {
|
|
240
|
+
let receiver = try MLKEM768.PrivateKey()
|
|
241
|
+
let result = try receiver.publicKey.encapsulate()
|
|
242
|
+
let recovered = try receiver.decapsulate(result.encapsulated)
|
|
243
|
+
let bytes = { (key: SymmetricKey) in key.withUnsafeBytes { Data($0) } }
|
|
244
|
+
XCTAssertEqual(bytes(recovered), bytes(result.sharedSecret))
|
|
245
|
+
}
|
|
246
|
+
```
|
|
369
247
|
|
|
370
|
-
|
|
248
|
+
## Secure Enclave tests
|
|
371
249
|
|
|
372
|
-
|
|
250
|
+
The enclave-backed signing key type and the software `P256.Signing.PrivateKey` share no common type, so a factory cannot return "one of them" directly. Put both behind a protocol.
|
|
373
251
|
|
|
374
252
|
```swift
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
func sign(_ data: Data) throws -> Data
|
|
379
|
-
func publicKeyData() -> Data
|
|
253
|
+
protocol SigningKeyProvider: Sendable {
|
|
254
|
+
func signature(over payload: Data) throws -> Data
|
|
255
|
+
var publicKeyDER: Data { get }
|
|
380
256
|
}
|
|
381
257
|
|
|
382
|
-
|
|
383
|
-
private let
|
|
258
|
+
struct SecureEnclaveSigningKey: SigningKeyProvider {
|
|
259
|
+
private let enclaveKey: SecureEnclave.P256.Signing.PrivateKey
|
|
384
260
|
|
|
385
261
|
init() throws {
|
|
386
|
-
guard SecureEnclave.isAvailable else {
|
|
387
|
-
|
|
262
|
+
guard SecureEnclave.isAvailable else { throw VaultStoreError.unhandled(status: errSecUnimplemented) }
|
|
263
|
+
guard let access = SecAccessControlCreateWithFlags(nil,
|
|
264
|
+
kSecAttrAccessibleWhenUnlockedThisDeviceOnly, [.privateKeyUsage], nil) else {
|
|
265
|
+
throw VaultStoreError.unhandled(status: errSecParam)
|
|
388
266
|
}
|
|
389
|
-
|
|
267
|
+
enclaveKey = try .init(accessControl: access)
|
|
390
268
|
}
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
try key.signature(for: data).derRepresentation
|
|
269
|
+
func signature(over payload: Data) throws -> Data {
|
|
270
|
+
try enclaveKey.signature(for: payload).derRepresentation
|
|
394
271
|
}
|
|
395
|
-
|
|
396
|
-
func publicKeyData() -> Data { key.publicKey.derRepresentation }
|
|
272
|
+
var publicKeyDER: Data { enclaveKey.publicKey.derRepresentation }
|
|
397
273
|
}
|
|
398
274
|
|
|
399
|
-
|
|
400
|
-
private let
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
try key.signature(for: data).derRepresentation
|
|
275
|
+
struct SoftwareSigningKey: SigningKeyProvider {
|
|
276
|
+
private let softwareKey = P256.Signing.PrivateKey()
|
|
277
|
+
func signature(over payload: Data) throws -> Data {
|
|
278
|
+
try softwareKey.signature(for: payload).derRepresentation
|
|
404
279
|
}
|
|
405
|
-
|
|
406
|
-
func publicKeyData() -> Data { key.publicKey.derRepresentation }
|
|
280
|
+
var publicKeyDER: Data { softwareKey.publicKey.derRepresentation }
|
|
407
281
|
}
|
|
408
282
|
|
|
409
|
-
|
|
410
|
-
static func make() -> SigningKeyProvider {
|
|
411
|
-
|
|
412
|
-
let seKey = try? SecureEnclaveSigningKey() {
|
|
413
|
-
return seKey
|
|
414
|
-
}
|
|
415
|
-
return SoftwareSigningKey()
|
|
283
|
+
enum SignerFactory {
|
|
284
|
+
static func make() -> any SigningKeyProvider {
|
|
285
|
+
(try? makeSigner()) ?? SoftwareSigningKey()
|
|
416
286
|
}
|
|
417
287
|
}
|
|
418
288
|
```
|
|
419
289
|
|
|
420
|
-
|
|
290
|
+
`.privateKeyUsage` is always part of a Secure Enclave signing key's access control flags; add `.biometryCurrentSet` or `.userPresence` next to it when the key should require authentication.
|
|
291
|
+
|
|
292
|
+
A test that creates a Secure Enclave key unconditionally crashes or fails on Simulator and CI. Skip it, or test through the protocol:
|
|
421
293
|
|
|
422
294
|
```swift
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
let
|
|
427
|
-
|
|
295
|
+
func testEnclaveSignature_VerifiesWithPublicKey() throws {
|
|
296
|
+
try requireHardware()
|
|
297
|
+
try XCTSkipUnless(SecureEnclave.isAvailable, "No Secure Enclave on this machine")
|
|
298
|
+
let signer = try SecureEnclaveSigningKey()
|
|
299
|
+
try assertVerifies(signer)
|
|
428
300
|
}
|
|
429
301
|
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
try XCTSkipUnless(SecureEnclave.isAvailable,
|
|
433
|
-
"Secure Enclave not available - skipping on simulator")
|
|
434
|
-
let key = try SecureEnclave.P256.Signing.PrivateKey()
|
|
435
|
-
let data = "authenticated payload".data(using: .utf8)!
|
|
436
|
-
let sig = try key.signature(for: data)
|
|
437
|
-
XCTAssertTrue(key.publicKey.isValidSignature(sig, for: data))
|
|
302
|
+
func testAnySigner_ProducesVerifiableSignatures() throws {
|
|
303
|
+
try assertVerifies(SignerFactory.make())
|
|
438
304
|
}
|
|
439
305
|
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
let
|
|
443
|
-
let
|
|
444
|
-
|
|
445
|
-
XCTAssertFalse(sigBytes.isEmpty)
|
|
446
|
-
|
|
447
|
-
let publicKey = try P256.Signing.PublicKey(derRepresentation: signer.publicKeyData())
|
|
448
|
-
let signature = try P256.Signing.ECDSASignature(derRepresentation: sigBytes)
|
|
449
|
-
XCTAssertTrue(publicKey.isValidSignature(signature, for: data))
|
|
306
|
+
private func assertVerifies(_ provider: any SigningKeyProvider) throws {
|
|
307
|
+
let challenge = Data("challenge-17".utf8)
|
|
308
|
+
let verifier = try P256.Signing.PublicKey(derRepresentation: provider.publicKeyDER)
|
|
309
|
+
let der = try provider.signature(over: challenge)
|
|
310
|
+
XCTAssertTrue(verifier.isValidSignature(try .init(derRepresentation: der), for: challenge))
|
|
450
311
|
}
|
|
451
312
|
```
|
|
452
313
|
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
## Biometric Flow Testing - LAContext Mocking
|
|
314
|
+
## Biometric flows
|
|
456
315
|
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
### Protocol-Based Approach (Preferred)
|
|
316
|
+
Put `LAContext` behind a protocol so tests control the outcome. The quickest version copies `LAContext`'s own `canEvaluatePolicy(_:error:)` and `evaluatePolicy(_:localizedReason:reply:)` requirements into a `BiometricAuthContext` protocol and declares `extension LAContext: BiometricAuthContext {}`. A small adapter, shown here, keeps the protocol in your own vocabulary instead. Subclassing `LAContext` also works and is shorter, at the cost of coupling tests to the real class.
|
|
460
317
|
|
|
461
318
|
```swift
|
|
462
319
|
import LocalAuthentication
|
|
463
320
|
|
|
464
321
|
protocol BiometricAuthContext {
|
|
465
|
-
func
|
|
466
|
-
func
|
|
467
|
-
reply: @escaping (Bool, Error?) -> Void)
|
|
322
|
+
func biometricsReady() -> Bool
|
|
323
|
+
func authenticate(reason: String, reply: @escaping @Sendable (Bool, Error?) -> Void)
|
|
468
324
|
}
|
|
469
325
|
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
private let context: BiometricAuthContext
|
|
474
|
-
|
|
475
|
-
init(context: BiometricAuthContext = LAContext()) {
|
|
476
|
-
self.context = context
|
|
477
|
-
}
|
|
478
|
-
|
|
479
|
-
var isBiometricsAvailable: Bool {
|
|
326
|
+
struct SystemBiometricContext: BiometricAuthContext {
|
|
327
|
+
private let context = LAContext()
|
|
328
|
+
func biometricsReady() -> Bool {
|
|
480
329
|
context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: nil)
|
|
481
330
|
}
|
|
331
|
+
func authenticate(reason: String, reply: @escaping @Sendable (Bool, Error?) -> Void) {
|
|
332
|
+
context.evaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, localizedReason: reason, reply: reply)
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
```
|
|
336
|
+
|
|
337
|
+
```swift
|
|
338
|
+
final class BiometricPrompt {
|
|
339
|
+
private let context: BiometricAuthContext
|
|
340
|
+
init(context: BiometricAuthContext) { self.context = context }
|
|
482
341
|
|
|
483
|
-
func
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
completion(.failure(LAError(.biometryNotAvailable)))
|
|
342
|
+
func confirm(reason: String, then finish: @escaping @Sendable (Result<Void, Error>) -> Void) {
|
|
343
|
+
guard context.biometricsReady() else {
|
|
344
|
+
finish(.failure(LAError(.biometryNotAvailable)))
|
|
487
345
|
return
|
|
488
346
|
}
|
|
489
|
-
context.
|
|
490
|
-
|
|
491
|
-
completion(success ? .success(()) : .failure(error ?? LAError(.authenticationFailed)))
|
|
347
|
+
context.authenticate(reason: reason) { ok, error in
|
|
348
|
+
finish(ok ? .success(()) : .failure(error ?? LAError(.authenticationFailed)))
|
|
492
349
|
}
|
|
493
350
|
}
|
|
494
351
|
}
|
|
495
352
|
|
|
496
|
-
final class
|
|
497
|
-
var
|
|
498
|
-
var
|
|
499
|
-
var
|
|
500
|
-
var evaluateCalled = false
|
|
353
|
+
final class StubBiometricContext: BiometricAuthContext {
|
|
354
|
+
var available = true
|
|
355
|
+
var succeeds = true
|
|
356
|
+
var error: Error?
|
|
357
|
+
private(set) var evaluateCalled = false
|
|
501
358
|
|
|
502
|
-
func
|
|
503
|
-
|
|
504
|
-
}
|
|
505
|
-
|
|
506
|
-
func evaluatePolicy(_ policy: LAPolicy, localizedReason: String,
|
|
507
|
-
reply: @escaping (Bool, Error?) -> Void) {
|
|
359
|
+
func biometricsReady() -> Bool { available }
|
|
360
|
+
func authenticate(reason: String, reply: @escaping @Sendable (Bool, Error?) -> Void) {
|
|
508
361
|
evaluateCalled = true
|
|
509
|
-
reply(
|
|
362
|
+
reply(succeeds, self.error)
|
|
510
363
|
}
|
|
511
364
|
}
|
|
512
365
|
```
|
|
513
366
|
|
|
514
|
-
|
|
367
|
+
This tests UI gating only. A secret behind the prompt still has to live in a keychain item bound with `SecAccessControl`, as [biometric-authentication.md](biometric-authentication.md) explains.
|
|
515
368
|
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
|
519
|
-
|
|
|
520
|
-
|
|
|
521
|
-
|
|
|
369
|
+
Scenarios to cover:
|
|
370
|
+
|
|
371
|
+
| Case | Error | Expected handling |
|
|
372
|
+
| --- | --- | --- |
|
|
373
|
+
| Success | none | Proceed |
|
|
374
|
+
| User cancels | `.userCancel` | Offer retry or abort |
|
|
375
|
+
| Too many failures | `.biometryLockout` | Fall back to the device passcode |
|
|
376
|
+
| Nothing enrolled | `.biometryNotEnrolled` | Explain how to enrol |
|
|
522
377
|
|
|
523
378
|
```swift
|
|
524
|
-
func
|
|
525
|
-
let
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
}
|
|
535
|
-
waitForExpectations(timeout: 1)
|
|
536
|
-
XCTAssertTrue(mock.evaluateCalled)
|
|
379
|
+
func testUnavailableBiometrics_NeverEvaluates() async {
|
|
380
|
+
let stub = StubBiometricContext()
|
|
381
|
+
stub.available = false
|
|
382
|
+
let done = expectation(description: "completion")
|
|
383
|
+
BiometricPrompt(context: stub).confirm(reason: "Approve transfer") { outcome in
|
|
384
|
+
if case .success = outcome { XCTFail("Expected a failure") }
|
|
385
|
+
done.fulfill()
|
|
386
|
+
}
|
|
387
|
+
await fulfillment(of: [done], timeout: 1)
|
|
388
|
+
XCTAssertFalse(stub.evaluateCalled)
|
|
537
389
|
}
|
|
390
|
+
```
|
|
538
391
|
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
392
|
+
The completion checks the result with `if case` rather than
|
|
393
|
+
`XCTAssertThrowsError(try outcome.get())`: a `try` anywhere in a closure body
|
|
394
|
+
makes Swift infer the closure as throwing, and `finish` is non-throwing. The
|
|
395
|
+
test is `async` and awaits `fulfillment(of:)` because `waitForExpectations` is
|
|
396
|
+
main-actor isolated.
|
|
543
397
|
|
|
544
|
-
|
|
545
|
-
manager.authenticate(reason: "Test") { result in
|
|
546
|
-
if case .success = result { XCTFail("Expected failure") }
|
|
547
|
-
exp.fulfill()
|
|
548
|
-
}
|
|
549
|
-
waitForExpectations(timeout: 1)
|
|
550
|
-
XCTAssertFalse(mock.evaluateCalled) // Should not attempt auth
|
|
551
|
-
}
|
|
552
|
-
```
|
|
398
|
+
## CI configuration
|
|
553
399
|
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
|
|
573
|
-
run: |
|
|
574
|
-
KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db
|
|
575
|
-
security create-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
|
|
576
|
-
security set-keychain-settings -lut 21600 $KEYCHAIN_PATH
|
|
577
|
-
security unlock-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
|
|
578
|
-
security list-keychain -d user -s $KEYCHAIN_PATH
|
|
579
|
-
# Import cert + CRITICAL partition list step
|
|
580
|
-
echo -n "$BUILD_CERTIFICATE_BASE64" | base64 --decode -o $RUNNER_TEMP/cert.p12
|
|
581
|
-
security import $RUNNER_TEMP/cert.p12 -P "$P12_PASSWORD" \
|
|
582
|
-
-A -t cert -f pkcs12 -k $KEYCHAIN_PATH
|
|
583
|
-
security set-key-partition-list -S apple-tool:,apple: \
|
|
584
|
-
-k "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
|
|
585
|
-
- name: Run simulator-safe tests
|
|
586
|
-
run: |
|
|
587
|
-
xcodebuild test -scheme MyApp \
|
|
588
|
-
-destination 'platform=iOS Simulator,name=iPhone 16' \
|
|
589
|
-
-testPlan CITests
|
|
590
|
-
- name: Cleanup
|
|
591
|
-
if: always()
|
|
592
|
-
run: security delete-keychain $RUNNER_TEMP/app-signing.keychain-db
|
|
400
|
+
`errSecInteractionNotAllowed` (-25308) is the error CI runs hit most: the runner's keychain is locked, or the operation wants a GUI that a headless session does not have.
|
|
401
|
+
|
|
402
|
+
Signing setup on a hosted macOS runner:
|
|
403
|
+
|
|
404
|
+
```sh
|
|
405
|
+
KC="${RUNNER_TEMP:-$TMPDIR}/ci-signing.keychain-db"
|
|
406
|
+
security create-keychain -p "$KC_PASSWORD" "$KC"
|
|
407
|
+
security set-keychain-settings -l -u -t 21600 "$KC"
|
|
408
|
+
security unlock-keychain -p "$KC_PASSWORD" "$KC"
|
|
409
|
+
security list-keychains -s "$KC" -d user
|
|
410
|
+
security import signing.p12 -k "$KC" -f pkcs12 -t cert -A -P "$P12_PASSWORD"
|
|
411
|
+
security set-key-partition-list -s -k "$KC_PASSWORD" -S apple-tool:,apple: "$KC"
|
|
412
|
+
|
|
413
|
+
xcodebuild test -scheme Vault -testPlan CITests \
|
|
414
|
+
-destination "platform=iOS Simulator,name=$SIM_NAME"
|
|
415
|
+
|
|
416
|
+
# always-run cleanup step
|
|
417
|
+
security delete-keychain "$KC"
|
|
593
418
|
```
|
|
594
419
|
|
|
595
|
-
|
|
420
|
+
`set-key-partition-list` after the import is the step people miss. Without it `codesign` waits for a GUI confirmation that never comes and the job hangs. `-A` on the import grants every application access to the key, which is acceptable only for a throwaway CI keychain.
|
|
596
421
|
|
|
597
|
-
|
|
422
|
+
Xcode Cloud builds on ephemeral machines with managed signing, so there is no keychain to configure by hand. Enable Keychain Sharing on the test host, and expect -25308 when Swift Package Manager tries to save repository credentials.
|
|
598
423
|
|
|
599
|
-
|
|
424
|
+
fastlane's `setup_ci` creates a temporary keychain named `fastlane_tmp_keychain` and makes it the default, which can disturb the login keychain on a self-hosted runner.
|
|
600
425
|
|
|
601
426
|
```ruby
|
|
602
|
-
lane :
|
|
427
|
+
lane :ci_tests do
|
|
603
428
|
setup_ci(timeout: 3600)
|
|
604
429
|
sync_code_signing(type: "development", readonly: is_ci)
|
|
605
|
-
run_tests(scheme: "
|
|
430
|
+
run_tests(scheme: "Vault", testplan: "CITests", device: "iPhone 16")
|
|
606
431
|
end
|
|
607
432
|
```
|
|
608
433
|
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
|
612
|
-
|
|
|
613
|
-
|
|
|
614
|
-
| `
|
|
615
|
-
|
|
|
616
|
-
| `errSecInternalComponent` | -67585 | Partition list not set after import | Call `set-key-partition-list` after cert import |
|
|
617
|
-
| Default keychain not found | -25307 | No default keychain on CI runner | Create and set default keychain |
|
|
618
|
-
|
|
619
|
-
### Test Host App Requirement
|
|
620
|
-
|
|
621
|
-
Since Xcode 9, test bundles on iOS simulator require a host app to access the keychain. Without one, `SecItemAdd` returns `-25300` or `-34018`. Create a minimal iOS app target, enable the Keychain Sharing capability, and set the test target's **Test Host** and **Bundle Loader** build settings to point at it.
|
|
622
|
-
|
|
623
|
-
---
|
|
434
|
+
| Status | Meaning on CI | Fix |
|
|
435
|
+
| --- | --- | --- |
|
|
436
|
+
| -25308 | Keychain locked or needs UI | Unlock it and set the partition list |
|
|
437
|
+
| -34018 | Missing entitlement | Give the test host the keychain entitlement |
|
|
438
|
+
| -25300 | Item not found, often no host app | Run tests inside a host app with the Keychain capability |
|
|
439
|
+
| -67585 | `errSecInternalComponent` | The import happened but no partition list was applied |
|
|
440
|
+
| -25307 | No default keychain | Create one and set it as default |
|
|
624
441
|
|
|
625
|
-
|
|
442
|
+
For the test host, add an almost empty iOS app target and turn on Keychain Sharing for it. Then set `TEST_HOST` and `BUNDLE_LOADER` on the test target so the bundle loads inside that app.
|
|
626
443
|
|
|
627
|
-
|
|
444
|
+
## Test plans
|
|
628
445
|
|
|
629
|
-
-
|
|
630
|
-
-
|
|
446
|
+
- `CITests.xctestplan`: mock keychain and Simulator-safe CryptoKit tests only.
|
|
447
|
+
- `DeviceTests.xctestplan`: real keychain, Secure Enclave, biometric hardware; runs on a physical device.
|
|
631
448
|
|
|
632
|
-
```
|
|
633
|
-
|
|
634
|
-
xcodebuild test -scheme
|
|
635
|
-
-destination 'platform=iOS Simulator,name=iPhone 16' \
|
|
636
|
-
-testPlan CITests
|
|
637
|
-
|
|
638
|
-
# Nightly: device farm runs everything
|
|
639
|
-
xcodebuild test -scheme MyApp \
|
|
640
|
-
-destination 'platform=iOS,id=DEVICE_UDID' \
|
|
641
|
-
-testPlan DeviceTests
|
|
449
|
+
```sh
|
|
450
|
+
xcodebuild test -scheme Vault -testPlan CITests -destination "platform=iOS Simulator,name=$SIM_NAME"
|
|
451
|
+
xcodebuild test -scheme Vault -testPlan DeviceTests -destination 'platform=iOS,id=<UDID>'
|
|
642
452
|
```
|
|
643
453
|
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
## Swift Testing Framework Patterns
|
|
454
|
+
Run the first on every push and the second nightly on a device farm.
|
|
647
455
|
|
|
648
|
-
Swift Testing
|
|
456
|
+
## Swift Testing
|
|
649
457
|
|
|
650
458
|
```swift
|
|
651
459
|
import Testing
|
|
652
|
-
@testable import MyApp
|
|
653
460
|
|
|
654
461
|
extension Tag {
|
|
655
462
|
@Tag static var keychain: Self
|
|
@@ -658,156 +465,89 @@ extension Tag {
|
|
|
658
465
|
}
|
|
659
466
|
|
|
660
467
|
@Suite(.serialized, .tags(.keychain))
|
|
661
|
-
struct
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
let
|
|
667
|
-
|
|
668
|
-
let result = try #require(try manager.retrieveToken())
|
|
669
|
-
#expect(result == "test-token")
|
|
468
|
+
struct VaultTests {
|
|
469
|
+
@Test(.tags(.ciSafe))
|
|
470
|
+
func rememberedTokenIsReadable() throws {
|
|
471
|
+
let keeper = SessionKeeper(store: InMemorySecretStore())
|
|
472
|
+
try keeper.remember(token: "t-1")
|
|
473
|
+
let token = try #require(try keeper.currentToken())
|
|
474
|
+
#expect(token == "t-1")
|
|
670
475
|
}
|
|
671
476
|
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
func
|
|
676
|
-
let
|
|
677
|
-
try
|
|
678
|
-
defer { try?
|
|
679
|
-
try
|
|
680
|
-
|
|
681
|
-
#expect(String(data: data, encoding: .utf8) == "token")
|
|
477
|
+
static let onBuildServer = ProcessInfo.processInfo.environment["CI"] != nil
|
|
478
|
+
|
|
479
|
+
@Test(.tags(.deviceOnly), .enabled(if: !onBuildServer))
|
|
480
|
+
func realKeychainRoundTrip() throws {
|
|
481
|
+
let store = KeychainSecretStore(service: "tests.vault.swift-testing")
|
|
482
|
+
try store.removeAll()
|
|
483
|
+
defer { try? store.removeAll() }
|
|
484
|
+
try store.put(Data("x".utf8), for: "k")
|
|
485
|
+
#expect(try store.value(for: "k") == Data("x".utf8))
|
|
682
486
|
}
|
|
683
487
|
|
|
684
|
-
@Test(
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
KeychainError.interactionNotAllowed
|
|
690
|
-
])
|
|
691
|
-
func errorPathHandling(expectedError: KeychainError) {
|
|
692
|
-
let mock = MockKeychainService()
|
|
693
|
-
mock.errorToThrow = expectedError
|
|
694
|
-
#expect(throws: KeychainError.self) {
|
|
695
|
-
try mock.read(forKey: "any-key")
|
|
696
|
-
}
|
|
488
|
+
@Test(arguments: [VaultStoreError.duplicateItem, .authFailed, .userCanceled, .interactionNotAllowed])
|
|
489
|
+
func injectedFailuresPropagate(_ failure: VaultStoreError) {
|
|
490
|
+
let mock = InMemorySecretStore()
|
|
491
|
+
mock.failure = failure
|
|
492
|
+
#expect(throws: VaultStoreError.self) { try SessionKeeper(store: mock).remember(token: "t") }
|
|
697
493
|
}
|
|
698
494
|
}
|
|
699
495
|
```
|
|
700
496
|
|
|
701
|
-
|
|
497
|
+
`.serialized` keeps tests that share keychain state from running in parallel. Test plans can include or exclude by tag: `ciSafe` on CI, `deviceOnly` on the device farm.
|
|
702
498
|
|
|
703
|
-
|
|
499
|
+
## Further techniques
|
|
704
500
|
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
### Migration Testing: UserDefaults to Keychain
|
|
708
|
-
|
|
709
|
-
Migration code is security-critical - silent failure leaves credentials in UserDefaults (see `migration-legacy-stores.md`). The class under test accepts injected dependencies for both stores:
|
|
501
|
+
**Migration tests.** Inject both stores: an isolated `UserDefaults(suiteName:)` cleared with `removePersistentDomain(forName:)` and the mock keychain. Assert the value arrived, was read back, and is gone from the source.
|
|
710
502
|
|
|
711
503
|
```swift
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
self.defaults = defaults
|
|
719
|
-
self.keychain = keychain
|
|
720
|
-
}
|
|
504
|
+
func testMigrationMovesAndVerifies() throws {
|
|
505
|
+
let suiteName = "tests.migration"
|
|
506
|
+
let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName))
|
|
507
|
+
defaults.removePersistentDomain(forName: suiteName)
|
|
508
|
+
defaults.set("legacy-token", forKey: "token")
|
|
509
|
+
let store = InMemorySecretStore()
|
|
721
510
|
|
|
722
|
-
|
|
723
|
-
let version = defaults.integer(forKey: "migration_version")
|
|
724
|
-
if version < 1 {
|
|
725
|
-
if let token = defaults.string(forKey: "auth_token"),
|
|
726
|
-
let data = token.data(using: .utf8) {
|
|
727
|
-
try keychain.save(data, forKey: "auth_token")
|
|
728
|
-
defaults.removeObject(forKey: "auth_token")
|
|
729
|
-
}
|
|
730
|
-
}
|
|
731
|
-
defaults.set(1, forKey: "migration_version")
|
|
732
|
-
}
|
|
733
|
-
}
|
|
734
|
-
```
|
|
735
|
-
|
|
736
|
-
Test with isolated `UserDefaults(suiteName:)` and mock keychain:
|
|
511
|
+
try LegacyTokenMigrator(defaults: defaults, store: store).run()
|
|
737
512
|
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
let defaults = UserDefaults(suiteName: "migration-test")!
|
|
741
|
-
defaults.removePersistentDomain(forName: "migration-test")
|
|
742
|
-
defaults.set("my-secret", forKey: "auth_token")
|
|
743
|
-
defaults.set(0, forKey: "migration_version")
|
|
744
|
-
|
|
745
|
-
let mock = MockKeychainService()
|
|
746
|
-
let migrator = StorageMigrationManager(defaults: defaults, keychain: mock)
|
|
747
|
-
try migrator.migrateIfNeeded()
|
|
748
|
-
|
|
749
|
-
// Token moved to keychain, removed from UserDefaults
|
|
750
|
-
XCTAssertEqual(String(data: mock.storage["auth_token"]!, encoding: .utf8), "my-secret")
|
|
751
|
-
XCTAssertNil(defaults.string(forKey: "auth_token"))
|
|
513
|
+
XCTAssertEqual(try store.value(for: "token"), Data("legacy-token".utf8))
|
|
514
|
+
XCTAssertNil(defaults.string(forKey: "token"))
|
|
752
515
|
}
|
|
753
516
|
```
|
|
754
517
|
|
|
755
|
-
|
|
518
|
+
`LegacyTokenMigrator` saves, reads back and compares, and removes the `UserDefaults` value only after the comparison succeeds, as in [migration-legacy-stores.md](migration-legacy-stores.md).
|
|
756
519
|
|
|
757
|
-
|
|
758
|
-
func testKeychainWritePerformance() {
|
|
759
|
-
let keychain = KeychainService(service: "com.test.perf")
|
|
760
|
-
let options = XCTMeasureOptions()
|
|
761
|
-
options.iterationCount = 20
|
|
762
|
-
|
|
763
|
-
measure(metrics: [XCTClockMetric(), XCTCPUMetric()], options: options) {
|
|
764
|
-
let data = UUID().uuidString.data(using: .utf8)!
|
|
765
|
-
try? keychain.save(data, forKey: "perf-key")
|
|
766
|
-
try? keychain.delete(forKey: "perf-key")
|
|
767
|
-
}
|
|
768
|
-
}
|
|
769
|
-
```
|
|
770
|
-
|
|
771
|
-
### Mutation Testing
|
|
772
|
-
|
|
773
|
-
Mutation testing introduces deliberate bugs (flipping `==` to `!=`, removing `SecItemDelete` calls, swapping `&&` to `||`) and checks whether your tests catch them. A project can have 81% code coverage but only 16% mutation score - tests execute security code without validating it does the right thing.
|
|
774
|
-
|
|
775
|
-
**Muter** (`brew install muter-mutation-testing/muter/muter`) is the primary Swift mutation testing tool. Its `RelationalOperatorReplacement` operator catches authentication bypasses; `RemoveSideEffects` catches missing `SecItemDelete` calls in logout flows. For security code, target mutation score above **80%**.
|
|
776
|
-
|
|
777
|
-
### OWASP MASTG Keychain Validation
|
|
778
|
-
|
|
779
|
-
MASTG-TEST-0052 requires that sensitive data use the Keychain, not `NSUserDefaults` or `.plist` files. OWASP also documents that keychain data persists after app uninstallation - the app sandbox is wiped but keychain items remain. Standard mitigation is a fresh-install detector (see `common-anti-patterns.md`):
|
|
520
|
+
**Performance.**
|
|
780
521
|
|
|
781
522
|
```swift
|
|
782
|
-
|
|
783
|
-
let
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
523
|
+
func testSaveDeleteCost() {
|
|
524
|
+
let runs = XCTMeasureOptions()
|
|
525
|
+
runs.iterationCount = 20
|
|
526
|
+
measure(metrics: [XCTCPUMetric(), XCTClockMetric()], options: runs) {
|
|
527
|
+
try? store.put(Data(count: 64), for: "perf")
|
|
528
|
+
try? store.remove(for: "perf")
|
|
787
529
|
}
|
|
788
530
|
}
|
|
789
531
|
```
|
|
790
532
|
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
## Conclusion
|
|
533
|
+
**Mutation testing.** Coverage says code ran, not that a test would notice it changing. Mutations such as flipping `==` and `!=`, deleting a `SecItemDelete` call or swapping `&&` and `||` expose the gap; a project can show 81% line coverage and a 16% mutation score. Muter, installed with Homebrew from the `muter-mutation-testing/muter` tap, provides the operators: `RelationalOperatorReplacement` finds inverted checks that would become authentication bypasses, and `RemoveSideEffects` finds logout paths whose deletes nobody verifies. Aim for a mutation score above 80%.
|
|
794
534
|
|
|
795
|
-
|
|
535
|
+
**MASTG-TEST-0052.** Sensitive data must be in the keychain, not in `NSUserDefaults` or property lists, and because the keychain survives uninstall the app needs a fresh-install detector: a `has_launched` marker that triggers `removeAll()` when missing, run after protected data is available.
|
|
796
536
|
|
|
797
|
-
|
|
537
|
+
## Takeaways
|
|
798
538
|
|
|
799
|
-
|
|
539
|
+
Aim for more than 95% of the suite running against the mock with no entitlements at all, and keep real-keychain tests in the device test plan. Three facts are easy to miss: on Simulator a Face ID or Touch ID protected item comes back with no prompt at all, `security create-keychain` makes a keychain of the wrong type for iOS-style tests, and mutation testing finds inverted conditions and removed side effects that coverage does not.
|
|
800
540
|
|
|
801
|
-
##
|
|
541
|
+
## Checklist
|
|
802
542
|
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
543
|
+
- [ ] All keychain access goes through the `SecretStore` protocol.
|
|
544
|
+
- [ ] The mock can inject duplicate, auth-failed, user-canceled, interaction-not-allowed and not-found failures.
|
|
545
|
+
- [ ] Real-keychain tests use their own service and clean up in `setUp` and `tearDown`.
|
|
546
|
+
- [ ] Secure Enclave tests are compiled out or skipped on Simulator, check `SecureEnclave.isAvailable` on hardware, or go through the protocol fallback.
|
|
547
|
+
- [ ] `LAContext` is behind a protocol; success, cancel, lockout and not-enrolled are covered.
|
|
548
|
+
- [ ] Two test plans: `CITests` and `DeviceTests`.
|
|
549
|
+
- [ ] CI sets the key partition list after importing certificates; the test host has Keychain Sharing.
|
|
550
|
+
- [ ] CryptoKit round trips for AES-GCM, ChaChaPoly, P256 and Curve25519, each with a wrong-key or tampered-input case.
|
|
551
|
+
- [ ] Every reachable `OSStatus` has an injected-failure test.
|
|
552
|
+
- [ ] Migration tests use an isolated suite and the mock, and check the source is cleared after verification.
|
|
553
|
+
- [ ] Muter reports at least 80% with `RelationalOperatorReplacement` and `RemoveSideEffects`.
|