@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.
Files changed (284) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/LICENSE +0 -10
  3. package/docs/facts.json +1 -1
  4. package/manifest.json +285 -285
  5. package/package.json +3 -3
  6. package/pipeline/lib/redact.mjs +3 -2
  7. package/pipeline/scripts/_notices.mjs +1 -1
  8. package/pipeline/scripts/gen-skills-index.mjs +13 -1
  9. package/pipeline/scripts/pre-commit-check.sh +4 -0
  10. package/pipeline/skills/.skill-manifest.json +69 -69
  11. package/pipeline/skills/shared/README.md +70 -70
  12. package/pipeline/skills/shared/external/alarmkit/SKILL.md +373 -381
  13. package/pipeline/skills/shared/external/alarmkit/evals/evals.json +23 -18
  14. package/pipeline/skills/shared/external/alarmkit/references/alarmkit-patterns.md +328 -378
  15. package/pipeline/skills/shared/external/app-clips/SKILL.md +260 -160
  16. package/pipeline/skills/shared/external/app-clips/evals/evals.json +27 -27
  17. package/pipeline/skills/shared/external/app-clips/references/data-handoff-notifications-location.md +150 -83
  18. package/pipeline/skills/shared/external/app-clips/references/routing-and-experiences.md +135 -83
  19. package/pipeline/skills/shared/external/app-clips/references/size-capabilities-and-promotion.md +143 -85
  20. package/pipeline/skills/shared/external/app-intents/SKILL.md +302 -304
  21. package/pipeline/skills/shared/external/app-intents/evals/evals.json +21 -21
  22. package/pipeline/skills/shared/external/app-intents/references/appintents-advanced.md +594 -894
  23. package/pipeline/skills/shared/external/app-store-optimization/SKILL.md +345 -277
  24. package/pipeline/skills/shared/external/app-store-optimization/evals/evals.json +27 -23
  25. package/pipeline/skills/shared/external/app-store-optimization/references/keyword-research-methodology.md +107 -121
  26. package/pipeline/skills/shared/external/app-store-optimization/references/product-page-variants.md +145 -165
  27. package/pipeline/skills/shared/external/app-store-review/SKILL.md +306 -326
  28. package/pipeline/skills/shared/external/app-store-review/evals/evals.json +21 -21
  29. package/pipeline/skills/shared/external/app-store-review/references/privacy-manifest.md +105 -67
  30. package/pipeline/skills/shared/external/app-store-review/references/review-checklists.md +114 -101
  31. package/pipeline/skills/shared/external/apple-on-device-ai/SKILL.md +335 -360
  32. package/pipeline/skills/shared/external/apple-on-device-ai/evals/evals.json +24 -27
  33. package/pipeline/skills/shared/external/apple-on-device-ai/references/coreml-conversion.md +215 -322
  34. package/pipeline/skills/shared/external/apple-on-device-ai/references/coreml-optimization.md +161 -256
  35. package/pipeline/skills/shared/external/apple-on-device-ai/references/foundation-models.md +277 -387
  36. package/pipeline/skills/shared/external/apple-on-device-ai/references/mlx-swift.md +196 -210
  37. package/pipeline/skills/shared/external/authentication/SKILL.md +277 -381
  38. package/pipeline/skills/shared/external/authentication/evals/evals.json +25 -25
  39. package/pipeline/skills/shared/external/authentication/references/keychain-biometric.md +135 -178
  40. package/pipeline/skills/shared/external/authentication/references/passkeys.md +111 -147
  41. package/pipeline/skills/shared/external/avkit/SKILL.md +267 -364
  42. package/pipeline/skills/shared/external/avkit/evals/evals.json +26 -26
  43. package/pipeline/skills/shared/external/avkit/references/avkit-patterns.md +375 -493
  44. package/pipeline/skills/shared/external/background-processing/SKILL.md +274 -384
  45. package/pipeline/skills/shared/external/background-processing/evals/evals.json +22 -22
  46. package/pipeline/skills/shared/external/background-processing/references/background-task-patterns.md +173 -321
  47. package/pipeline/skills/shared/external/callkit-voip/SKILL.md +290 -371
  48. package/pipeline/skills/shared/external/callkit-voip/evals/evals.json +24 -24
  49. package/pipeline/skills/shared/external/callkit-voip/references/callkit-patterns.md +175 -343
  50. package/pipeline/skills/shared/external/cloudkit-sync/SKILL.md +292 -381
  51. package/pipeline/skills/shared/external/cloudkit-sync/evals/evals.json +33 -30
  52. package/pipeline/skills/shared/external/cloudkit-sync/references/cloudkit-patterns.md +227 -355
  53. package/pipeline/skills/shared/external/contacts-framework/SKILL.md +197 -346
  54. package/pipeline/skills/shared/external/contacts-framework/evals/evals.json +19 -21
  55. package/pipeline/skills/shared/external/contacts-framework/references/contacts-patterns.md +169 -308
  56. package/pipeline/skills/shared/external/core-bluetooth/SKILL.md +228 -376
  57. package/pipeline/skills/shared/external/core-bluetooth/evals/evals.json +25 -22
  58. package/pipeline/skills/shared/external/core-bluetooth/references/ble-patterns.md +257 -337
  59. package/pipeline/skills/shared/external/core-data/SKILL.md +302 -368
  60. package/pipeline/skills/shared/external/core-data/evals/evals.json +30 -27
  61. package/pipeline/skills/shared/external/core-motion/SKILL.md +235 -324
  62. package/pipeline/skills/shared/external/core-motion/evals/evals.json +31 -27
  63. package/pipeline/skills/shared/external/core-motion/references/motion-patterns.md +210 -310
  64. package/pipeline/skills/shared/external/core-nfc/SKILL.md +292 -366
  65. package/pipeline/skills/shared/external/core-nfc/evals/evals.json +22 -24
  66. package/pipeline/skills/shared/external/core-nfc/references/nfc-patterns.md +233 -329
  67. package/pipeline/skills/shared/external/coreml/SKILL.md +323 -367
  68. package/pipeline/skills/shared/external/coreml/evals/evals.json +24 -21
  69. package/pipeline/skills/shared/external/coreml/references/coreml-swift-integration.md +562 -565
  70. package/pipeline/skills/shared/external/cryptokit/SKILL.md +253 -394
  71. package/pipeline/skills/shared/external/cryptokit/evals/evals.json +20 -18
  72. package/pipeline/skills/shared/external/cryptokit/references/cryptokit-patterns.md +299 -488
  73. package/pipeline/skills/shared/external/debugging-instruments/SKILL.md +270 -323
  74. package/pipeline/skills/shared/external/debugging-instruments/evals/evals.json +27 -30
  75. package/pipeline/skills/shared/external/debugging-instruments/references/instruments-guide.md +167 -315
  76. package/pipeline/skills/shared/external/debugging-instruments/references/lldb-patterns.md +140 -193
  77. package/pipeline/skills/shared/external/device-integrity/SKILL.md +236 -353
  78. package/pipeline/skills/shared/external/device-integrity/evals/evals.json +25 -21
  79. package/pipeline/skills/shared/external/device-integrity/references/device-integrity-patterns.md +159 -197
  80. package/pipeline/skills/shared/external/energykit/SKILL.md +225 -392
  81. package/pipeline/skills/shared/external/energykit/evals/evals.json +29 -28
  82. package/pipeline/skills/shared/external/energykit/references/energykit-patterns.md +174 -470
  83. package/pipeline/skills/shared/external/eventkit-calendar/SKILL.md +261 -383
  84. package/pipeline/skills/shared/external/eventkit-calendar/evals/evals.json +25 -22
  85. package/pipeline/skills/shared/external/eventkit-calendar/references/eventkit-patterns.md +165 -268
  86. package/pipeline/skills/shared/external/healthkit/SKILL.md +252 -303
  87. package/pipeline/skills/shared/external/healthkit/evals/evals.json +24 -23
  88. package/pipeline/skills/shared/external/healthkit/references/healthkit-patterns.md +369 -523
  89. package/pipeline/skills/shared/external/homekit-matter/SKILL.md +233 -348
  90. package/pipeline/skills/shared/external/homekit-matter/evals/evals.json +27 -22
  91. package/pipeline/skills/shared/external/homekit-matter/references/matter-commissioning.md +199 -305
  92. package/pipeline/skills/shared/external/ios-accessibility/SKILL.md +368 -340
  93. package/pipeline/skills/shared/external/ios-accessibility/evals/evals.json +28 -27
  94. package/pipeline/skills/shared/external/ios-accessibility/references/a11y-patterns.md +314 -260
  95. package/pipeline/skills/shared/external/ios-accessibility/references/media-accessibility.md +97 -67
  96. package/pipeline/skills/shared/external/ios-accessibility/references/nutrition-labels.md +165 -101
  97. package/pipeline/skills/shared/external/ios-localization/SKILL.md +258 -371
  98. package/pipeline/skills/shared/external/ios-localization/evals/evals.json +23 -23
  99. package/pipeline/skills/shared/external/ios-localization/references/formatstyle-locale.md +283 -491
  100. package/pipeline/skills/shared/external/ios-localization/references/string-catalogs.md +313 -440
  101. package/pipeline/skills/shared/external/ios-networking/SKILL.md +265 -341
  102. package/pipeline/skills/shared/external/ios-networking/evals/evals.json +24 -24
  103. package/pipeline/skills/shared/external/ios-networking/references/background-websocket.md +425 -652
  104. package/pipeline/skills/shared/external/ios-networking/references/file-storage-patterns.md +143 -285
  105. package/pipeline/skills/shared/external/ios-networking/references/lightweight-clients.md +93 -53
  106. package/pipeline/skills/shared/external/ios-networking/references/network-framework.md +231 -456
  107. package/pipeline/skills/shared/external/ios-networking/references/urlsession-patterns.md +517 -784
  108. package/pipeline/skills/shared/external/ios-simulator/SKILL.md +265 -393
  109. package/pipeline/skills/shared/external/ios-simulator/evals/evals.json +21 -21
  110. package/pipeline/skills/shared/external/ios-simulator/references/simctl-commands.md +177 -270
  111. package/pipeline/skills/shared/external/live-activities/SKILL.md +318 -360
  112. package/pipeline/skills/shared/external/live-activities/evals/evals.json +21 -21
  113. package/pipeline/skills/shared/external/live-activities/references/activitykit-patterns.md +478 -710
  114. package/pipeline/skills/shared/external/localization-reuse-map/SKILL.md +3 -3
  115. package/pipeline/skills/shared/external/localization-reuse-map/example-mapping.json +1 -1
  116. package/pipeline/skills/shared/external/localization-reuse-map/reference/format-and-output.md +8 -7
  117. package/pipeline/skills/shared/external/localization-reuse-map/reference/publish-and-snapshot.md +2 -2
  118. package/pipeline/skills/shared/external/localization-reuse-map/reference/sources-and-recipes.md +5 -2
  119. package/pipeline/skills/shared/external/localization-reuse-map/scripts/_shared.py +100 -0
  120. package/pipeline/skills/shared/external/localization-reuse-map/scripts/build-artifact.py +45 -26
  121. package/pipeline/skills/shared/external/localization-reuse-map/scripts/build-spreadsheet.py +14 -16
  122. package/pipeline/skills/shared/external/localization-reuse-map/scripts/fetch-annotations.py +12 -5
  123. package/pipeline/skills/shared/external/localization-reuse-map/scripts/fetch-legacy-labels.py +2 -1
  124. package/pipeline/skills/shared/external/localization-reuse-map/scripts/publish-confluence.py +6 -5
  125. package/pipeline/skills/shared/external/localization-reuse-map/scripts/render-overlay.py +44 -18
  126. package/pipeline/skills/shared/external/localization-reuse-map/scripts/resolve-legacy-values.py +5 -2
  127. package/pipeline/skills/shared/external/localization-reuse-map/scripts/resolve-new-values.py +10 -11
  128. package/pipeline/skills/shared/external/localization-reuse-map/scripts/scan-screen-keys.py +4 -33
  129. package/pipeline/skills/shared/external/localization-reuse-map/scripts/verify-map.py +12 -59
  130. package/pipeline/skills/shared/external/mapkit-location/SKILL.md +297 -267
  131. package/pipeline/skills/shared/external/mapkit-location/evals/evals.json +28 -24
  132. package/pipeline/skills/shared/external/mapkit-location/references/mapkit-corelocation-patterns.md +378 -532
  133. package/pipeline/skills/shared/external/mapkit-location/references/mapkit-patterns.md +397 -499
  134. package/pipeline/skills/shared/external/metrickit-diagnostics/SKILL.md +165 -348
  135. package/pipeline/skills/shared/external/metrickit-diagnostics/evals/evals.json +26 -23
  136. package/pipeline/skills/shared/external/metrickit-diagnostics/references/metrickit-patterns.md +123 -130
  137. package/pipeline/skills/shared/external/musickit-audio/SKILL.md +189 -315
  138. package/pipeline/skills/shared/external/musickit-audio/evals/evals.json +22 -21
  139. package/pipeline/skills/shared/external/musickit-audio/references/musickit-patterns.md +181 -270
  140. package/pipeline/skills/shared/external/natural-language/SKILL.md +188 -340
  141. package/pipeline/skills/shared/external/natural-language/evals/evals.json +21 -21
  142. package/pipeline/skills/shared/external/natural-language/references/translation-patterns.md +171 -225
  143. package/pipeline/skills/shared/external/passkit-wallet/SKILL.md +258 -392
  144. package/pipeline/skills/shared/external/passkit-wallet/evals/evals.json +30 -29
  145. package/pipeline/skills/shared/external/passkit-wallet/references/wallet-passes.md +164 -231
  146. package/pipeline/skills/shared/external/pdfkit/SKILL.md +312 -344
  147. package/pipeline/skills/shared/external/pdfkit/evals/evals.json +19 -19
  148. package/pipeline/skills/shared/external/pdfkit/references/pdfkit-patterns.md +413 -624
  149. package/pipeline/skills/shared/external/pencilkit-drawing/SKILL.md +242 -358
  150. package/pipeline/skills/shared/external/pencilkit-drawing/evals/evals.json +25 -21
  151. package/pipeline/skills/shared/external/pencilkit-drawing/references/pencilkit-patterns.md +161 -226
  152. package/pipeline/skills/shared/external/permissionkit/SKILL.md +282 -400
  153. package/pipeline/skills/shared/external/permissionkit/evals/evals.json +27 -30
  154. package/pipeline/skills/shared/external/permissionkit/references/permissionkit-patterns.md +237 -350
  155. package/pipeline/skills/shared/external/photos-camera-media/SKILL.md +276 -325
  156. package/pipeline/skills/shared/external/photos-camera-media/references/av-playback.md +299 -545
  157. package/pipeline/skills/shared/external/photos-camera-media/references/camera-capture.md +344 -588
  158. package/pipeline/skills/shared/external/photos-camera-media/references/image-loading-caching.md +316 -660
  159. package/pipeline/skills/shared/external/photos-camera-media/references/photokit-patterns.md +270 -416
  160. package/pipeline/skills/shared/external/push-notifications/SKILL.md +312 -340
  161. package/pipeline/skills/shared/external/push-notifications/evals/evals.json +27 -26
  162. package/pipeline/skills/shared/external/push-notifications/references/notification-patterns.md +328 -485
  163. package/pipeline/skills/shared/external/push-notifications/references/rich-notifications.md +327 -560
  164. package/pipeline/skills/shared/external/realitykit-ar/SKILL.md +218 -410
  165. package/pipeline/skills/shared/external/realitykit-ar/evals/evals.json +24 -27
  166. package/pipeline/skills/shared/external/realitykit-ar/references/realitykit-patterns.md +221 -348
  167. package/pipeline/skills/shared/external/shareplay-activities/SKILL.md +222 -393
  168. package/pipeline/skills/shared/external/shareplay-activities/evals/evals.json +23 -24
  169. package/pipeline/skills/shared/external/shareplay-activities/references/shareplay-patterns.md +280 -420
  170. package/pipeline/skills/shared/external/skill-creator/template.md +7 -1
  171. package/pipeline/skills/shared/external/speech-recognition/SKILL.md +217 -421
  172. package/pipeline/skills/shared/external/speech-recognition/evals/evals.json +23 -26
  173. package/pipeline/skills/shared/external/speech-recognition/references/speechanalyzer-patterns.md +133 -125
  174. package/pipeline/skills/shared/external/storekit/SKILL.md +228 -204
  175. package/pipeline/skills/shared/external/storekit/evals/evals.json +27 -24
  176. package/pipeline/skills/shared/external/storekit/references/app-review-guidelines.md +98 -109
  177. package/pipeline/skills/shared/external/storekit/references/core-patterns.md +302 -241
  178. package/pipeline/skills/shared/external/storekit/references/storekit-advanced.md +356 -649
  179. package/pipeline/skills/shared/external/swift-api-design-guidelines/SKILL.md +274 -399
  180. package/pipeline/skills/shared/external/swift-api-design-guidelines/evals/evals.json +22 -24
  181. package/pipeline/skills/shared/external/swift-api-design-guidelines/references/argument-labels-and-parameters.md +107 -108
  182. package/pipeline/skills/shared/external/swift-api-design-guidelines/references/conventions-and-special-rules.md +93 -165
  183. package/pipeline/skills/shared/external/swift-api-design-guidelines/references/naming-and-clarity.md +99 -137
  184. package/pipeline/skills/shared/external/swift-api-design-guidelines/references/side-effects-and-mutating-pairs.md +77 -120
  185. package/pipeline/skills/shared/external/swift-architecture/SKILL.md +334 -350
  186. package/pipeline/skills/shared/external/swift-architecture/evals/evals.json +22 -22
  187. package/pipeline/skills/shared/external/swift-charts/SKILL.md +208 -394
  188. package/pipeline/skills/shared/external/swift-charts/evals/evals.json +27 -30
  189. package/pipeline/skills/shared/external/swift-charts/references/charts-patterns.md +351 -762
  190. package/pipeline/skills/shared/external/swift-codable/SKILL.md +339 -343
  191. package/pipeline/skills/shared/external/swift-codable/evals/evals.json +20 -20
  192. package/pipeline/skills/shared/external/swift-concurrency/SKILL.md +304 -351
  193. package/pipeline/skills/shared/external/swift-concurrency/evals/evals.json +27 -24
  194. package/pipeline/skills/shared/external/swift-concurrency/references/approachable-concurrency.md +65 -80
  195. package/pipeline/skills/shared/external/swift-concurrency/references/async-algorithms.md +48 -84
  196. package/pipeline/skills/shared/external/swift-concurrency/references/bridging-interop.md +134 -79
  197. package/pipeline/skills/shared/external/swift-concurrency/references/concurrency-patterns.md +145 -167
  198. package/pipeline/skills/shared/external/swift-concurrency/references/diagnostics.md +62 -50
  199. package/pipeline/skills/shared/external/swift-concurrency/references/swiftui-concurrency.md +92 -121
  200. package/pipeline/skills/shared/external/swift-concurrency/references/synchronization-primitives.md +177 -241
  201. package/pipeline/skills/shared/external/swift-formatstyle/SKILL.md +258 -234
  202. package/pipeline/skills/shared/external/swift-language/SKILL.md +342 -382
  203. package/pipeline/skills/shared/external/swift-language/evals/evals.json +24 -27
  204. package/pipeline/skills/shared/external/swift-language/references/swift-attributes-interop.md +79 -56
  205. package/pipeline/skills/shared/external/swift-language/references/swift-patterns-extended.md +297 -340
  206. package/pipeline/skills/shared/external/swift-security/SKILL.md +183 -162
  207. package/pipeline/skills/shared/external/swift-security/evals/evals.json +25 -25
  208. package/pipeline/skills/shared/external/swift-security/references/biometric-authentication.md +314 -469
  209. package/pipeline/skills/shared/external/swift-security/references/certificate-trust.md +411 -476
  210. package/pipeline/skills/shared/external/swift-security/references/common-anti-patterns.md +260 -530
  211. package/pipeline/skills/shared/external/swift-security/references/compliance-owasp-mapping.md +270 -477
  212. package/pipeline/skills/shared/external/swift-security/references/credential-storage-patterns.md +573 -571
  213. package/pipeline/skills/shared/external/swift-security/references/cryptokit-public-key.md +370 -441
  214. package/pipeline/skills/shared/external/swift-security/references/cryptokit-symmetric.md +332 -433
  215. package/pipeline/skills/shared/external/swift-security/references/keychain-access-control.md +346 -468
  216. package/pipeline/skills/shared/external/swift-security/references/keychain-fundamentals.md +375 -491
  217. package/pipeline/skills/shared/external/swift-security/references/keychain-item-classes.md +431 -432
  218. package/pipeline/skills/shared/external/swift-security/references/keychain-sharing.md +328 -425
  219. package/pipeline/skills/shared/external/swift-security/references/migration-legacy-stores.md +341 -579
  220. package/pipeline/skills/shared/external/swift-security/references/secure-enclave.md +397 -457
  221. package/pipeline/skills/shared/external/swift-security/references/testing-security-code.md +354 -614
  222. package/pipeline/skills/shared/external/swift-testing/SKILL.md +191 -175
  223. package/pipeline/skills/shared/external/swift-testing/evals/evals.json +26 -24
  224. package/pipeline/skills/shared/external/swift-testing/references/testing-advanced.md +81 -84
  225. package/pipeline/skills/shared/external/swift-testing/references/testing-patterns.md +317 -433
  226. package/pipeline/skills/shared/external/swiftdata/SKILL.md +394 -256
  227. package/pipeline/skills/shared/external/swiftdata/evals/evals.json +24 -24
  228. package/pipeline/skills/shared/external/swiftdata/references/core-data-coexistence.md +206 -402
  229. package/pipeline/skills/shared/external/swiftdata/references/indexing.md +59 -52
  230. package/pipeline/skills/shared/external/swiftdata/references/predicate-pitfalls.md +57 -33
  231. package/pipeline/skills/shared/external/swiftdata/references/swiftdata-advanced.md +354 -747
  232. package/pipeline/skills/shared/external/swiftdata/references/swiftdata-queries.md +300 -508
  233. package/pipeline/skills/shared/external/swiftlint/SKILL.md +175 -226
  234. package/pipeline/skills/shared/external/swiftlint/references/adoption-and-configuration.md +141 -208
  235. package/pipeline/skills/shared/external/swiftlint/references/custom-rules-and-analyze.md +100 -109
  236. package/pipeline/skills/shared/external/swiftlint/references/plugins-run-scripts-and-integrations.md +159 -179
  237. package/pipeline/skills/shared/external/swiftlint/references/rule-reference.md +383 -18
  238. package/pipeline/skills/shared/external/swiftlint/references/rules-suppressions-and-baselines.md +143 -229
  239. package/pipeline/skills/shared/external/swiftui-animation/SKILL.md +283 -366
  240. package/pipeline/skills/shared/external/swiftui-animation/references/animation-advanced.md +396 -608
  241. package/pipeline/skills/shared/external/swiftui-animation/references/core-animation-bridge.md +336 -385
  242. package/pipeline/skills/shared/external/swiftui-gestures/SKILL.md +239 -349
  243. package/pipeline/skills/shared/external/swiftui-gestures/references/gesture-patterns.md +228 -310
  244. package/pipeline/skills/shared/external/swiftui-layout-components/SKILL.md +260 -249
  245. package/pipeline/skills/shared/external/swiftui-layout-components/references/form.md +92 -74
  246. package/pipeline/skills/shared/external/swiftui-layout-components/references/grids.md +112 -177
  247. package/pipeline/skills/shared/external/swiftui-layout-components/references/list.md +61 -64
  248. package/pipeline/skills/shared/external/swiftui-layout-components/references/scrollview.md +94 -134
  249. package/pipeline/skills/shared/external/swiftui-liquid-glass/SKILL.md +193 -225
  250. package/pipeline/skills/shared/external/swiftui-liquid-glass/references/liquid-glass.md +173 -327
  251. package/pipeline/skills/shared/external/swiftui-navigation/SKILL.md +201 -168
  252. package/pipeline/skills/shared/external/swiftui-navigation/references/deeplinks.md +127 -150
  253. package/pipeline/skills/shared/external/swiftui-navigation/references/navigationstack.md +134 -133
  254. package/pipeline/skills/shared/external/swiftui-navigation/references/sheets.md +152 -117
  255. package/pipeline/skills/shared/external/swiftui-navigation/references/tabview.md +111 -138
  256. package/pipeline/skills/shared/external/swiftui-patterns/SKILL.md +316 -252
  257. package/pipeline/skills/shared/external/swiftui-patterns/references/architecture-patterns.md +341 -332
  258. package/pipeline/skills/shared/external/swiftui-patterns/references/deprecated-migration.md +547 -854
  259. package/pipeline/skills/shared/external/swiftui-patterns/references/design-polish.md +485 -537
  260. package/pipeline/skills/shared/external/swiftui-patterns/references/platform-and-sharing.md +417 -499
  261. package/pipeline/skills/shared/external/swiftui-performance/SKILL.md +213 -376
  262. package/pipeline/skills/shared/external/swiftui-performance/references/demystify-swiftui-performance-wwdc23.md +86 -175
  263. package/pipeline/skills/shared/external/swiftui-performance/references/optimizing-swiftui-performance-instruments.md +89 -195
  264. package/pipeline/skills/shared/external/swiftui-performance/references/understanding-hangs-in-your-app.md +95 -182
  265. package/pipeline/skills/shared/external/swiftui-performance/references/understanding-improving-swiftui-performance.md +71 -149
  266. package/pipeline/skills/shared/external/swiftui-performance/references/wwdc-session-sources.md +21 -27
  267. package/pipeline/skills/shared/external/swiftui-uikit-interop/SKILL.md +303 -295
  268. package/pipeline/skills/shared/external/swiftui-uikit-interop/references/hosting-migration.md +204 -387
  269. package/pipeline/skills/shared/external/swiftui-uikit-interop/references/representable-recipes.md +469 -683
  270. package/pipeline/skills/shared/external/swiftui-webkit/SKILL.md +140 -186
  271. package/pipeline/skills/shared/external/swiftui-webkit/references/loading-and-observation.md +75 -86
  272. package/pipeline/skills/shared/external/swiftui-webkit/references/local-content-and-custom-schemes.md +63 -60
  273. package/pipeline/skills/shared/external/swiftui-webkit/references/migration-and-fallbacks.md +69 -137
  274. package/pipeline/skills/shared/external/swiftui-webkit/references/navigation-and-javascript.md +95 -67
  275. package/pipeline/skills/shared/external/tipkit/SKILL.md +220 -335
  276. package/pipeline/skills/shared/external/tipkit/references/tipkit-patterns.md +356 -494
  277. package/pipeline/skills/shared/external/vision-framework/SKILL.md +260 -375
  278. package/pipeline/skills/shared/external/vision-framework/references/vision-requests.md +393 -515
  279. package/pipeline/skills/shared/external/vision-framework/references/visionkit-scanner.md +363 -539
  280. package/pipeline/skills/shared/external/weatherkit/SKILL.md +160 -315
  281. package/pipeline/skills/shared/external/weatherkit/references/weatherkit-patterns.md +288 -407
  282. package/pipeline/skills/shared/external/widgetkit/SKILL.md +224 -288
  283. package/pipeline/skills/shared/external/widgetkit/references/widgetkit-advanced.md +416 -719
  284. package/pipeline/skills/shared/external/NOTICE-swift-ios-skills.md +0 -39
@@ -1,752 +1,754 @@
1
1
  # Credential Storage Patterns
2
2
 
3
- > Scope: Secure lifecycle patterns for client-side credentials on Apple platforms, including storage, refresh, rotation, migration, and logout cleanup.
4
-
5
- The iOS Keychain is the only Apple-sanctioned storage mechanism for OAuth tokens, API keys, passwords, and other credentials. Cybernews found in 2025 that 71% of iOS apps leak at least one hardcoded secret - primarily through `UserDefaults`, `Info.plist`, or `.xcconfig` files that produce plaintext artifacts trivially extractable from device backups or IPA bundles. This reference covers the complete credential lifecycle: secure storage via Keychain Services, OAuth2/OIDC authentication flows, atomic token refresh with rotation, runtime secret fetching, key rotation strategies, and comprehensive logout cleanup.
6
-
7
- Authoritative sources: Apple Developer Documentation (Keychain Services, Authentication Services), Apple Platform Security Guide (December 2024), WWDC 2019 Session 516 "What's New in Authentication", WWDC 2021 Session 10105 "Secure login with iCloud Keychain verification codes", WWDC 2024 Session 10125 "Streamline sign-in with passkey upgrades and credential managers", OWASP Mobile Top 10 2024, MASVS v2.1.0 (January 2024), MASTG v2, CISA/FBI "Product Security Bad Practices" advisory v2.0 (January 2025), and the Cybernews iOS app security research (March 2025).
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
- - [The Six Anti-Patterns AI Code Generators Reproduce](#the-six-anti-patterns-ai-code-generators-reproduce)
14
- - [Anti-Pattern 1 - Tokens in UserDefaults](#anti-pattern-1-tokens-in-userdefaults)
15
- - [Anti-Pattern 2 - Hardcoded API Keys in Source Code](#anti-pattern-2-hardcoded-api-keys-in-source-code)
16
- - [Anti-Pattern 3 - Production Secrets in .xcconfig](#anti-pattern-3-production-secrets-in-xcconfig)
17
- - [Anti-Pattern 4 - Missing kSecAttrAccessible Specification](#anti-pattern-4-missing-ksecattraccessible-specification)
18
- - [Anti-Pattern 5 - Non-Atomic Token Refresh](#anti-pattern-5-non-atomic-token-refresh)
19
- - [Anti-Pattern 6 - Incomplete Credential Clearing on Logout](#anti-pattern-6-incomplete-credential-clearing-on-logout)
20
- - [Correct Baseline for Credential Storage](#correct-baseline-for-credential-storage)
21
- - [Data Protection Class Selection for Credentials](#data-protection-class-selection-for-credentials)
22
- - [Actor-Based KeychainManager - Thread-Safe Credential Storage](#actor-based-keychainmanager-thread-safe-credential-storage)
23
- - [OAuth2 Token Storage and Retrieval Cycle](#oauth2-token-storage-and-retrieval-cycle)
24
- - [Token Model](#token-model)
25
- - [ASWebAuthenticationSession + PKCE Flow](#aswebauthenticationsession-pkce-flow)
26
- - [Atomic Token Refresh with Rotation Support](#atomic-token-refresh-with-rotation-support)
27
- - [Refresh Coordinator with Promise Coalescing](#refresh-coordinator-with-promise-coalescing)
28
- - [Runtime API Key Fetching with Keychain Cache and TTL](#runtime-api-key-fetching-with-keychain-cache-and-ttl)
29
- - [Comprehensive Credential Clearing on Logout](#comprehensive-credential-clearing-on-logout)
30
- - [Key Rotation and Versioned Migration](#key-rotation-and-versioned-migration)
31
- - [Rotation Strategies by Secret Type](#rotation-strategies-by-secret-type)
32
- - [Versioned Keychain Items for Migration](#versioned-keychain-items-for-migration)
33
- - [Detecting Compromised Credentials](#detecting-compromised-credentials)
34
- - [Device Binding and Backup Implications](#device-binding-and-backup-implications)
35
- - [Biometric Protection for High-Value Credentials](#biometric-protection-for-high-value-credentials)
36
- - [iOS 17+ and 18+ Modernizations](#ios-17-and-18-modernizations)
37
- - [Static Analysis and CI/CD Guardrails](#static-analysis-and-cicd-guardrails)
38
- - [OWASP MASTG Compliance Mapping](#owasp-mastg-compliance-mapping)
39
- - [Conclusion](#conclusion)
40
- - [Summary Checklist](#summary-checklist)
41
-
42
- ## The Six Anti-Patterns AI Code Generators Reproduce
43
-
44
- AI coding assistants routinely generate insecure credential handling. Each anti-pattern below is documented with evidence, an incorrect code sample, and the correct alternative.
45
-
46
- ### Anti-Pattern 1 - Tokens in UserDefaults
47
-
48
- `UserDefaults` writes an unencrypted XML plist at `/var/mobile/Containers/Data/Application/{APP_ID}/Library/Preferences/{BUNDLE_ID}.plist`. This file is included in iTunes/Finder device backups, readable with iMazing or iExplorer on non-jailbroken devices, and trivially extractable on jailbroken devices via `objection`'s `ios nsuserdefaults get` command. Apple's documentation is explicit: the defaults system stores information on disk in an unencrypted format and must not be used for personal or sensitive information.
49
-
50
- ```swift
51
- // ❌ INCORRECT - AI-generated token storage in UserDefaults
52
- // Tokens are written as plaintext XML plist, readable from device backups
53
- func saveTokens(accessToken: String, refreshToken: String) {
54
- UserDefaults.standard.set(accessToken, forKey: "access_token")
55
- UserDefaults.standard.set(refreshToken, forKey: "refresh_token")
56
- }
57
- ```
58
-
59
- **OWASP mapping:** Violates M9 (Insecure Data Storage), MASVS-STORAGE-1, MASWE-0002, and fails MASTG-TEST-0300/0301.
60
-
61
- > For the canonical ❌/✅ code samples, objection detection commands, and full remediation checklist for this pattern, see `common-anti-patterns.md` section Anti-Pattern #1 - Storing Secrets in UserDefaults.
62
-
63
- ### Anti-Pattern 2 - Hardcoded API Keys in Source Code
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
- // ❌ INCORRECT - .xcconfig value compiled into Info.plist as plaintext
84
- // In Secrets.xcconfig: MAPS_API_KEY = gm_pk_a1b2c3d4e5f6g7h8i9
85
- // In Info.plist: <key>MapsAPIKey</key><string>$(MAPS_API_KEY)</string>
86
-
87
- let apiKey = Bundle.main.infoDictionary?["MapsAPIKey"] as? String
88
- // Attacker: unzip App.ipa && plutil -p Payload/App.app/Info.plist | grep Maps
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
- ### Correct Baseline for Credential Storage
117
-
118
- ✅ Store credentials in Keychain, not `UserDefaults`/plist/source literals.
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
- ## Data Protection Class Selection for Credentials
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
- Choosing the correct `kSecAttrAccessible` value is the highest-ROI decision for credential confidentiality. The Keychain encrypts items using dual AES-256-GCM keys: a metadata key (cached for fast searches) and a per-row secret key that always requires a Secure Enclave round trip (Apple Platform Security Guide, December 2024; full architecture: `keychain-fundamentals.md` section Two-Tier Encryption and Query Cost).
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
- | Accessibility Class | Device-Bound | Background Access | Primary Use Case | Risk Note |
130
- | -------------------------------------------------- | ------------ | ----------------------- | ----------------------------- | ------------------------------------------------------------------- |
131
- | `kSecAttrAccessibleWhenUnlockedThisDeviceOnly` | Yes | No | OAuth tokens, API keys | **Recommended default** - strongest for credentials |
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
- **Rule of thumb:** Default to `kSecAttrAccessibleWhenUnlockedThisDeviceOnly` for all OAuth tokens and API keys. Use `AfterFirstUnlockThisDeviceOnly` only when background refresh is required (e.g., silent push notification handling). Never use `kSecAttrSynchronizable` for app tokens - iCloud Keychain sync is designed for website passwords, not application secrets.
110
+ More detail on classes and flags: [keychain-access-control.md](keychain-access-control.md).
137
111
 
138
- > For complete accessibility constant selection criteria, data protection tier explanations, and `SecAccessControl` interaction rules, see `keychain-access-control.md` section The "When" Layer: Seven Accessibility Constants.
112
+ ## One actor in front of the Keychain
139
113
 
140
- ---
141
-
142
- ## Actor-Based KeychainManager - Thread-Safe Credential Storage
143
-
144
- The `SecItemAdd`, `SecItemCopyMatching`, `SecItemUpdate`, and `SecItemDelete` functions (all iOS 2.0+) are synchronous C functions performing IPC to the `securityd` daemon. They are thread-safe for independent items, but concurrent modifications to the same item produce race conditions - notably `errSecDuplicateItem` (-25299) when two threads both try to add a missing item simultaneously. A Swift actor (iOS 13+, idiomatic from iOS 17+ with mature concurrency) provides a serial executor that eliminates these races.
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 KeychainManager {
153
-
154
- public enum KeychainError: Error {
155
- case unexpectedStatus(OSStatus), itemNotFound, encodingFailed, decodingFailed
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, accessGroup: String? = nil,
136
+ public init(service: String,
137
+ accessGroup: String? = nil,
163
138
  accessibility: CFString = kSecAttrAccessibleWhenUnlockedThisDeviceOnly) {
164
- self.service = service; self.accessGroup = accessGroup; self.accessibility = accessibility
139
+ self.service = service
140
+ self.accessGroup = accessGroup
141
+ self.accessibility = accessibility
165
142
  }
166
143
 
167
- func baseQuery(account: String) -> [CFString: Any] {
168
- var q: [CFString: Any] = [
169
- kSecClass: kSecClassGenericPassword, kSecAttrService: service,
170
- kSecAttrAccount: account, kSecAttrAccessible: accessibility
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 { q[kSecAttrAccessGroup] = accessGroup }
150
+ if let accessGroup { query[kSecAttrAccessGroup as String] = accessGroup }
173
151
  #if os(macOS)
174
- q[kSecUseDataProtectionKeychain] = true // iOS-style data protection on macOS
152
+ query[kSecUseDataProtectionKeychain as String] = true
175
153
  #endif
176
- return q
154
+ return query
177
155
  }
178
156
 
179
- /// Add-or-update semantics: try update first, fall back to add.
180
- public func save(account: String, data: Data) throws {
181
- var searchQ = baseQuery(account: account)
182
- searchQ.removeValue(forKey: kSecAttrAccessible)
183
- let attrs: [CFString: Any] = [kSecValueData: data, kSecAttrAccessible: accessibility]
184
- var status = SecItemUpdate(searchQ as CFDictionary, attrs as CFDictionary)
185
- if status == errSecItemNotFound {
186
- var addQ = baseQuery(account: account); addQ[kSecValueData] = data
187
- status = SecItemAdd(addQ as CFDictionary, nil)
188
- }
189
- guard status == errSecSuccess else { throw KeychainError.unexpectedStatus(status) }
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 load(account: String) throws -> Data {
193
- var q = baseQuery(account: account)
194
- q.removeValue(forKey: kSecAttrAccessible)
195
- q[kSecReturnData] = kCFBooleanTrue; q[kSecMatchLimit] = kSecMatchLimitOne
196
- var result: AnyObject?
197
- let status = SecItemCopyMatching(q as CFDictionary, &result)
198
- switch status {
199
- case errSecSuccess: guard let d = result as? Data else { throw KeychainError.decodingFailed }; return d
200
- case errSecItemNotFound: throw KeychainError.itemNotFound
201
- default: throw KeychainError.unexpectedStatus(status)
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 delete(account: String) throws {
206
- var q = baseQuery(account: account); q.removeValue(forKey: kSecAttrAccessible)
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
- /// Delete ALL items for this service - used during logout.
212
- public func deleteAll() throws {
213
- var q: [CFString: Any] = [kSecClass: kSecClassGenericPassword, kSecAttrService: service as CFString]
214
- if let accessGroup { q[kSecAttrAccessGroup] = accessGroup as CFString }
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
- q[kSecUseDataProtectionKeychain] = true
205
+ query[kSecUseDataProtectionKeychain as String] = true
217
206
  #endif
218
- let s = SecItemDelete(q as CFDictionary)
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
- **Why an actor?** The actor's serial executor guarantees that `save`, `load`, `delete`, and `deleteAll` never interleave. Two concurrent callers hitting `save` for the same account queue instead of racing. The synchronous `SecItem*` C calls execute safely within the actor - callers `await` access, suspending rather than blocking the cooperative thread pool.
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
- **Global actor alternative** - when Keychain serialization must span multiple modules:
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 KeychainActor {
233
- static let shared = KeychainActor()
230
+ actor KeychainIsolation {
231
+ static let shared = KeychainIsolation()
234
232
  }
235
233
 
236
- @KeychainActor
237
- func saveCredential(_ data: Data, account: String) throws {
238
- let query: [CFString: Any] = [
239
- kSecClass: kSecClassGenericPassword,
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
- ## OAuth2 Token Storage and Retrieval Cycle
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
- `ASWebAuthenticationSession` (iOS 12.0+) is the mandatory standard for secure web-based login flows. Using legacy web views like `WKWebView` or `SFSafariViewController` for OAuth is a significant anti-pattern - they allow the host app to inspect web content or steal credentials. WWDC 2019 "What's New in Authentication" formally recommended migrating from the deprecated `SFAuthenticationSession` to `ASWebAuthenticationSession`.
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
- ### Token Model
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
- // ✅ CORRECT - Codable token model with expiry tracking
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
- Date() >= expiresAt
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
- ### ASWebAuthenticationSession + PKCE Flow
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
- final class OAuthManager: NSObject, ASWebAuthenticationPresentationContextProviding {
291
-
292
- private let keychain = KeychainManager(
293
- service: "com.myapp.auth",
294
- accessibility: kSecAttrAccessibleWhenUnlockedThisDeviceOnly
295
- )
296
- private let clientID = "mobile-app-client" // public client, no secret needed
297
- private let redirectScheme = "com.myapp.auth"
298
-
299
- func startAuthentication() async throws -> OAuthTokens {
300
- let codeVerifier = generateCodeVerifier() // RFC 7636 PKCE
301
- let codeChallenge = generateCodeChallenge(from: codeVerifier)
302
-
303
- var components = URLComponents(string: "https://auth.example.com/authorize")!
304
- components.queryItems = [
305
- URLQueryItem(name: "response_type", value: "code"),
306
- URLQueryItem(name: "client_id", value: clientID),
307
- URLQueryItem(name: "redirect_uri", value: "\(redirectScheme)://callback"),
308
- URLQueryItem(name: "scope", value: "openid profile offline_access"),
309
- URLQueryItem(name: "code_challenge", value: codeChallenge),
310
- URLQueryItem(name: "code_challenge_method", value: "S256"),
311
- URLQueryItem(name: "state", value: UUID().uuidString)
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 callbackURL: URL = try await withCheckedThrowingContinuation { continuation in
348
+ let callback: URL = try await withCheckedThrowingContinuation { waiter in
315
349
  let session = ASWebAuthenticationSession(
316
- url: components.url!, callbackURLScheme: redirectScheme
317
- ) { url, error in
318
- if let error { continuation.resume(throwing: error) }
319
- else if let url { continuation.resume(returning: url) }
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 // iOS 13+: no cookie sharing
358
+ session.prefersEphemeralWebBrowserSession = true
322
359
  session.presentationContextProvider = self
323
360
  session.start()
324
361
  }
325
362
 
326
- guard let code = URLComponents(url: callbackURL, resolvingAgainstBaseURL: false)?
327
- .queryItems?.first(where: { $0.name == "code" })?.value else {
328
- throw OAuthError.missingAuthorizationCode
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 exchangeCodeForTokens(code: code, codeVerifier: codeVerifier)
332
- try await keychain.save(account: "oauth_tokens", data: JSONEncoder().encode(tokens))
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
- // MARK: - PKCE helpers (RFC 7636)
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
- **iOS 17.4+ improvement:** `ASWebAuthenticationSession.Callback` enables HTTPS universal link callbacks instead of custom URL schemes. Universal links provide a cryptographic guarantee of domain ownership, making them significantly less susceptible to interception (RFC 8252, OAuth 2.0 for Native Apps).
367
-
368
- **Privacy vs SSO trade-off:** Setting `prefersEphemeralWebBrowserSession = true` maximizes privacy and session isolation but breaks Single Sign-On. Toggle based on whether your app prioritizes strict isolation or seamless SSO.
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
- ## Atomic Token Refresh with Rotation Support
392
+ ## Refresh that survives crashes and races
373
393
 
374
- When a server implements Refresh Token Rotation (RTR) - as Okta, Auth0, and others do - each refresh response includes a new refresh token and the old one is immediately invalidated. If the app stores the new access token but crashes before persisting the new refresh token, the user is locked out. The solution: update both tokens atomically within the actor's serial execution context.
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
- Servers typically provide a short grace period (e.g., 30 seconds per Okta's configuration) during which the previous refresh token remains valid to handle network retries. If a previously invalidated token is reused outside the grace period, the server invalidates the entire token family - a strong signal of credential compromise.
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
- // ✅ CORRECT - Atomic token refresh with rotation support
380
- // Requires: iOS 13.0+ (actor serialization guarantees no interleaving)
381
- extension KeychainManager {
382
- func atomicTokenUpdate(oldAccount: String = "oauth_tokens", newTokens: OAuthTokens) throws {
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
- ### Refresh Coordinator with Promise Coalescing
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
- If multiple concurrent callers detect an expired token, only one refresh request should fire and all callers share the result:
418
+ Concurrent callers must share one refresh:
406
419
 
407
420
  ```swift
408
- // ✅ CORRECT - Single-flight refresh coordinator
409
- // Requires: iOS 13.0+
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 refreshTask: Task<OAuthTokens, Error>?
424
+ private var inFlight: Task<SessionTokens, Error>?
415
425
 
416
- init(keychain: KeychainManager, tokenEndpoint: URL) {
417
- self.keychain = keychain; self.tokenEndpoint = tokenEndpoint
426
+ init(vault: CredentialVault, tokenEndpoint: URL) {
427
+ self.vault = vault
428
+ self.tokenEndpoint = tokenEndpoint
418
429
  }
419
430
 
420
- /// Returns a valid access token, refreshing if necessary.
421
- func validAccessToken() async throws -> String {
422
- guard let data = try? await keychain.load(account: "oauth_tokens"),
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<OAuthTokens, Error> {
432
- defer { refreshTask = nil }
433
- return try await performRefresh(currentRefreshToken: tokens.refreshToken)
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 performRefresh(currentRefreshToken: String) async throws -> OAuthTokens {
441
+ private func refresh(using old: SessionTokens) async throws -> SessionTokens {
440
442
  var request = URLRequest(url: tokenEndpoint)
441
443
  request.httpMethod = "POST"
442
- request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
443
- request.httpBody = "grant_type=refresh_token&refresh_token=\(currentRefreshToken)".data(using: .utf8)
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 (data, response) = try await URLSession.shared.data(for: request)
446
- guard let http = response as? HTTPURLResponse else { throw TokenError.networkError }
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 http.statusCode {
451
+ switch code {
449
452
  case 200:
450
- // Decode server response (access_token, refresh_token?, expires_in, token_type)
451
- let json = try JSONSerialization.jsonObject(with: data) as! [String: Any]
452
- let newTokens = OAuthTokens(
453
- accessToken: json["access_token"] as! String,
454
- refreshToken: (json["refresh_token"] as? String) ?? currentRefreshToken,
455
- expiresAt: Date().addingTimeInterval(json["expires_in"] as! TimeInterval),
456
- tokenType: json["token_type"] as! String
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 keychain.atomicTokenUpdate(newTokens: newTokens)
459
- return newTokens
462
+ try await vault.replaceTokens(next)
463
+ return next
460
464
  case 400, 401:
461
- try? await keychain.deleteAll() // Refresh token revoked - force re-auth
462
- throw TokenError.refreshTokenExpired
465
+ try await vault.removeEverything()
466
+ throw SessionError.refreshExpired
463
467
  default:
464
- throw TokenError.serverError(http.statusCode)
468
+ throw SessionError.server(code)
465
469
  }
466
470
  }
471
+ }
467
472
 
468
- enum TokenError: Error {
469
- case notAuthenticated, refreshTokenExpired, networkError, serverError(Int)
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
- ## Runtime API Key Fetching with Keychain Cache and TTL
491
+ ## API keys fetched at runtime
477
492
 
478
- The most secure pattern for API keys is a backend proxy - the key never reaches the device. When that is not feasible, fetch the key from a secure backend at runtime and cache it in the Keychain with a time-to-live. The Keychain has no native TTL mechanism, so store expiry metadata alongside the secret.
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
- Use App Attest (`DCAppAttestService`, iOS 14.0+) to prove app integrity before the backend issues secrets. The app generates a hardware-backed key pair in the Secure Enclave and requests an attestation object from Apple. The backend validates this object, ensuring the app is untampered and running on a genuine device, before delivering short-lived API keys.
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
- // ✅ CORRECT - Runtime secret fetching with TTL-based Keychain cache
484
- // Requires: iOS 13.0+
485
- actor RuntimeSecretManager {
486
-
487
- private struct CachedSecret: Codable {
488
- let value: String; let fetchedAt: Date; let ttlSeconds: TimeInterval
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
- private let keychain: KeychainManager
493
- private let secretsEndpoint: URL
494
- private let defaultTTL: TimeInterval
495
- private var memoryCache: [String: CachedSecret] = [:]
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(keychain: KeychainManager, secretsEndpoint: URL, defaultTTL: TimeInterval = 3600) {
498
- self.keychain = keychain; self.secretsEndpoint = secretsEndpoint; self.defaultTTL = defaultTTL
516
+ init(vault: CredentialVault, fetch: @escaping @Sendable (String) async throws -> String) {
517
+ self.vault = vault
518
+ self.fetch = fetch
499
519
  }
500
520
 
501
- /// Three-tier lookup: memory → Keychain → network
502
- func secret(forKey key: String) async throws -> String {
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
- if let data = try? await keychain.load(account: "secret_\(key)"),
506
- let c = try? JSONDecoder().decode(CachedSecret.self, from: data), !c.isExpired {
507
- memoryCache[key] = c; return c.value
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 freshValue = try await fetchFromBackend(key: key)
511
- let cached = CachedSecret(value: freshValue, fetchedAt: Date(), ttlSeconds: defaultTTL)
512
- try await keychain.save(account: "secret_\(key)", data: JSONEncoder().encode(cached))
513
- memoryCache[key] = cached
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
- ## Comprehensive Credential Clearing on Logout
543
+ ## Logout clears everything
534
544
 
535
- A secure logout must clear every credential artifact: access token, refresh token, cached secrets, user profile data, and in-memory caches. It must also revoke tokens server-side when possible. Group all auth-related Keychain items under a single `kSecAttrService` value so `SecItemDelete` can wipe them in one call - no forgotten refresh tokens, no orphaned API keys.
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
- // ✅ CORRECT - Complete credential clearing on logout
539
- // OWASP MASVS-STORAGE-1, MASVS-STORAGE-2 compliant | iOS 13.0+
540
- actor SessionManager {
541
-
542
- private let keychain = KeychainManager(service: "com.myapp.auth",
543
- accessibility: kSecAttrAccessibleWhenUnlockedThisDeviceOnly)
544
-
545
- func logout() async {
546
- // 1. Server-side revocation (best-effort)
547
- if let data = try? await keychain.load(account: "oauth_tokens"),
548
- let tokens = try? JSONDecoder().decode(OAuthTokens.self, from: data) {
549
- try? await revoke(token: tokens.refreshToken)
550
- try? await revoke(token: tokens.accessToken)
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
- // 2. Nuclear Keychain cleanup - ALL items for this service
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
- private func revoke(token: String) async throws {
562
- var req = URLRequest(url: URL(string: "https://auth.example.com/oauth/revoke")!)
563
- req.httpMethod = "POST"
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
- **Server-driven revocation signals:** Backends can signal revocation via HTTP 401/403 with custom reason codes (e.g., `token_revoked`) or via silent push notifications (APNs) to trigger background logout and Keychain clearing.
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
- ### Versioned Keychain Items for Migration
578
+ ## Rotation and versioned storage
584
579
 
585
- Version Keychain items using the `kSecAttrAccount` key to enable backward-compatible migration during rotation:
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
- // ✅ CORRECT - Versioned Keychain migration during rotation
589
- // Requires: iOS 13.0+
590
- actor TokenMigrationManager {
591
-
592
- private let keychain: KeychainManager
593
- private static let currentVersion = 2
594
-
595
- init(keychain: KeychainManager) { self.keychain = keychain }
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 migrateV1ToV2(_ data: Data) throws -> Data {
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
- ### Detecting Compromised Credentials
617
-
618
- Four strategies: (1) **Token reuse detection** - server invalidates the entire token family when an already-rotated refresh token is presented. (2) **Anomaly monitoring** - geographic or temporal anomalies in token usage patterns. (3) **Proactive refresh** - refresh tokens at 75-90% of their lifetime rather than waiting for expiry. (4) **Breach database checks** - services like AWS Cognito check credentials against known breach databases during authentication.
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 Binding and Backup Implications
614
+ ## Device binding and restores
623
615
 
624
- Using `ThisDeviceOnly` variants prevents credential cloning but introduces friction during device upgrades. Because `ThisDeviceOnly` secrets are non-migratory, they will not transfer when a user restores an iCloud backup to a new device. The application must detect missing credentials on first launch and gracefully route the user through re-authentication.
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
- // ✅ Pattern: Detect missing credentials after device restore
628
- func handleAppLaunch() async {
621
+ func routeAtLaunch(vault: CredentialVault) async -> LaunchDestination {
629
622
  do {
630
- let _ = try await keychain.load(account: "oauth_tokens_v2")
631
- // Tokens present - proceed normally
632
- } catch KeychainManager.KeychainError.itemNotFound {
633
- // Likely a fresh install or device restore
634
- // Route to authentication flow
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
- // Unexpected error - log and route to auth
638
- logger.error("Keychain load failed: \(error)")
639
- await presentLoginScreen()
629
+ // log the failure without the token contents
630
+ return .signIn
640
631
  }
641
632
  }
642
- ```
643
633
 
644
- **Why not `kSecAttrSynchronizable` for app tokens?** Setting it to `true` syncs the item across all trusted Apple devices via iCloud Keychain. While appropriate for website passwords managed by the Passwords app, this significantly increases the attack surface for OAuth tokens and API keys. Omit this attribute to keep secrets local.
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
- ## Biometric Protection for High-Value Credentials
643
+ ## Biometrics on high-value credentials
649
644
 
650
- For user-initiated, high-value operations (e.g., payment authorization, viewing sensitive data), add `SecAccessControl` with biometric gating. Avoid biometric protection for refresh tokens that require headless background renewal.
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
- // ✅ CORRECT - Maximum OWASP MASTG L2 compliance configuration
654
- // Requires: iOS 11.3+ (for .or compound constraint)
655
- func createHighSecurityKeychainItem(account: String, secret: Data) throws {
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
- &error
662
- ) else {
663
- throw error!.takeRetainedValue() as Error
664
- }
655
+ nil
656
+ ) else { throw CredentialVault.Failure.encoding }
665
657
 
666
- let query: [CFString: Any] = [
667
- kSecClass: kSecClassGenericPassword,
668
- kSecAttrService: "com.myapp.auth" as CFString,
669
- kSecAttrAccount: account as CFString,
670
- kSecValueData: secret,
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
- let status = SecItemAdd(query as CFDictionary, nil)
676
- guard status == errSecSuccess else {
677
- throw NSError(domain: NSOSStatusErrorDomain, code: Int(status))
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
- **Cross-reference:** See `biometric-authentication.md` for detailed `LAContext` integration patterns and the LAContext-only bypass vulnerability. See `keychain-access-control.md` for the full accessibility class decision tree.
683
-
684
- ---
685
-
686
- ## iOS 17+ and 18+ Modernizations
687
-
688
- **iOS 17** introduced `ASWebAuthenticationSession.Callback` (iOS 17.4+), enabling HTTPS universal link callbacks instead of custom URL schemes - more secure redirect handling that verifies domain ownership. Shared password groups let teams share credentials via end-to-end encrypted iCloud Keychain. Third-party credential provider extensions can now supply passkeys alongside passwords.
689
-
690
- **iOS 18** brought the standalone Passwords app (replacing Keychain Access for end users), automatic passkey upgrades via `.conditional` registration style, and expanded credential provider extensions to support verification codes. No new `SecItem*` APIs were introduced, but the ecosystem shift toward passkeys means the Keychain's role is evolving from storing passwords to storing cryptographic keys for WebAuthn-based authentication.
691
-
692
- **WWDC 2024** Session 10125 "Streamline sign-in with passkey upgrades and credential managers" detailed the automatic passkey upgrade flow. WWDC 2021 Session 10105 introduced on-device TOTP verification code generation synced via iCloud Keychain, reducing dependence on SMS-based 2FA.
693
-
694
- **Swift 6 strict concurrency direction:** The community `swift-keychain-kit` library introduces `SecretData` as a non-copyable type (`~Copyable`) that uses `mlock` to prevent swapping to disk and zeroes memory on deallocation. While not yet an Apple framework, this pattern points toward where Keychain APIs are heading: consumed secrets that cannot accidentally be copied into insecure memory.
695
-
696
- ---
697
-
698
- ## Static Analysis and CI/CD Guardrails
699
-
700
- Catch credential anti-patterns before they reach production:
701
-
702
- | Tool | Purpose | Integration Point |
703
- | ---------------------------- | ------------------------------------------------------ | ------------------------ |
704
- | **truffleHog / gitleaks** | Scan for hardcoded secrets in source code | PR/commit hooks |
705
- | **strings / class-dump** | Verify no secrets in compiled binary | Post-build CI step |
706
- | **SwiftLint** (custom rules) | Flag `UserDefaults` usage for token-like keys | Local + CI |
707
- | **Frida / Objection** | Verify `kSecAttrAccessible` values at runtime | QA / penetration testing |
708
- | **MobSF** | Automated network traffic and storage leakage analysis | Dynamic regression gate |
709
-
710
- **Rule:** Fail the build if static analysis detects secrets in the codebase or compiled binary.
711
-
712
- ---
713
-
714
- ## OWASP MASTG Compliance Mapping
715
-
716
- The OWASP Mobile Top 10 (2024) places M1 (Improper Credential Usage) as the number-one mobile security risk. The MASVS v2.1.0 restructured requirements with MASWE (Mobile App Security Weakness Enumeration) bridging controls to specific tests.
717
-
718
- | Pattern | OWASP Controls | MASWE Weaknesses | MASTG Tests |
719
- | ------------------------------------------ | ----------------------- | ---------------------------------- | ------------------------------------- |
720
- | Keychain with `WhenUnlockedThisDeviceOnly` | M1, M9, MASVS-STORAGE-1 | MASWE-0002, MASWE-0004, MASWE-0036 | MASTG-TEST-0299, 0300, 0301, 0302 |
721
- | Actor-based thread-safe access | M9, MASVS-STORAGE-1 | MASWE-0002 | MASTG-TEST-0300 |
722
- | ASWebAuthenticationSession (ephemeral) | M1, MASVS-AUTH-1 | MASWE-0032 | MASTG-TEST-0064 |
723
- | Atomic token refresh | M1, MASVS-AUTH-1 | MASWE-0038 | - |
724
- | Runtime secret fetching | M1, MASVS-STORAGE-1 | MASWE-0005 | - |
725
- | Comprehensive logout cleanup | M9, MASVS-STORAGE-2 | MASWE-0004 | MASTG-TEST-0298 |
726
- | Biometric + `ThisDeviceOnly` | M9, MASVS-STORAGE-2 | MASWE-0046 | MASTG-TEST-0298, MASTG-DEMO-0043-0047 |
727
-
728
- The legacy test identifiers MSTG-STORAGE-1 and MSTG-STORAGE-2 map to the deprecated MASTG-TEST-0052 and MASTG-TEST-0053, now replaced by the granular suite MASTG-TEST-0296 through MASTG-TEST-0314.
729
-
730
- ---
731
-
732
- ## Conclusion
733
-
734
- The Keychain is not optional - it is the only mechanism Apple provides that encrypts credentials via the Secure Enclave and enforces data protection classes tied to device lock state. Three architectural decisions eliminate the majority of credential vulnerabilities: (1) use a Swift actor as the single Keychain access point to eliminate race conditions in token refresh; (2) fetch secrets at runtime from a backend proxy using App Attest for app attestation rather than embedding them in the binary; (3) group all auth-related Keychain items under a single `kSecAttrService` so logout can clear everything in one call.
735
-
736
- The future trajectory - passkeys, non-copyable secret types, HTTPS callbacks - reinforces rather than replaces these fundamentals.
737
-
738
- ---
739
-
740
- ## Summary Checklist
741
-
742
- 1. **Keychain-only storage** - all tokens, API keys, and credentials stored exclusively in the Keychain with `kSecAttrAccessibleWhenUnlockedThisDeviceOnly`; never in `UserDefaults`, `Info.plist`, `.xcconfig`, or hardcoded in source
743
- 2. **Actor-serialized access** - all Keychain operations routed through a Swift `actor` (or `@globalActor`) to prevent race conditions and `errSecDuplicateItem` errors from concurrent access
744
- 3. **ASWebAuthenticationSession + PKCE** - OAuth2 flows use `ASWebAuthenticationSession` with `prefersEphemeralWebBrowserSession = true` and PKCE (RFC 7636); never `WKWebView` or `SFSafariViewController`
745
- 4. **Atomic token refresh** - refresh token rotation handled atomically within the actor: encode new tokens before any mutation, delete old, store new; promise coalescing prevents duplicate refresh requests
746
- 5. **Runtime secret fetching** - API keys fetched from an attested backend (App Attest / DeviceCheck, iOS 14.0+) and cached in Keychain with application-layer TTL; three-tier lookup: memory → Keychain → network
747
- 6. **Comprehensive logout** - `deleteAll()` by `kSecAttrService` clears all credential items in one call; also revokes tokens server-side, clears cookies, and clears `URLCache`
748
- 7. **No `kSecAttrSynchronizable` for app tokens** - iCloud Keychain sync is for website passwords, not application secrets; `ThisDeviceOnly` variants prevent backup exfiltration
749
- 8. **Device restore detection** - app detects missing `ThisDeviceOnly` credentials after device restore and gracefully routes to re-authentication
750
- 9. **Versioned migration** - Keychain items versioned via `kSecAttrAccount` naming (e.g., `oauth_tokens_v2`) to support format changes and rollback during rotation
751
- 10. **CI/CD secret scanning** - static analysis (truffleHog, gitleaks, `strings`) integrated into build pipeline to catch hardcoded secrets before deployment; fail the build on detection
752
- 11. **OWASP MASTG compliance** - patterns satisfy M1, M9, MASVS-STORAGE-1, MASVS-AUTH-1 controls; validate with MASTG-TEST-0298 through 0302 and dynamic analysis (Frida/Objection) confirming protection classes at runtime
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.