buildanything 1.7.0 → 1.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +55 -0
- package/README.md +71 -61
- package/agents/ios-app-review-guardian.md +49 -0
- package/agents/ios-foundation-models-specialist.md +46 -0
- package/agents/ios-storekit-specialist.md +52 -0
- package/agents/ios-swift-architect.md +102 -0
- package/agents/ios-swift-search.md +130 -0
- package/agents/ios-swift-ui-design.md +104 -0
- package/commands/build.md +80 -176
- package/commands/fix.md +65 -0
- package/commands/setup.md +73 -0
- package/commands/ux-review.md +63 -0
- package/commands/verify.md +72 -0
- package/hooks/session-start +18 -1
- package/package.json +5 -2
- package/protocols/brainstorm.md +99 -0
- package/protocols/build-fix.md +52 -0
- package/protocols/cleanup.md +54 -0
- package/protocols/design.md +269 -0
- package/protocols/eval-harness.md +61 -0
- package/protocols/fake-data-detector.md +64 -0
- package/protocols/ios-context.md +235 -0
- package/protocols/ios-frameworks-map.md +323 -0
- package/protocols/ios-phase-branches.md +162 -0
- package/protocols/ios-preflight.md +27 -0
- package/protocols/metric-loop.md +93 -0
- package/protocols/planning.md +87 -0
- package/protocols/smoke-test.md +110 -0
- package/protocols/verify.md +67 -0
- package/protocols/web-phase-branches.md +201 -0
- package/skills/ios/_VENDORED.md +60 -0
- package/skills/ios/activitykit/LICENSE +131 -0
- package/skills/ios/activitykit/SKILL.md +505 -0
- package/skills/ios/activitykit/references/activitykit-patterns.md +868 -0
- package/skills/ios/app-intents/LICENSE +131 -0
- package/skills/ios/app-intents/SKILL.md +494 -0
- package/skills/ios/app-intents/references/appintents-advanced.md +1076 -0
- package/skills/ios/apple-on-device-ai/LICENSE +131 -0
- package/skills/ios/apple-on-device-ai/SKILL.md +505 -0
- package/skills/ios/apple-on-device-ai/references/coreml-conversion.md +425 -0
- package/skills/ios/apple-on-device-ai/references/coreml-optimization.md +344 -0
- package/skills/ios/apple-on-device-ai/references/foundation-models.md +508 -0
- package/skills/ios/apple-on-device-ai/references/mlx-swift.md +285 -0
- package/skills/ios/ios-26-platform/SKILL.md +53 -0
- package/skills/ios/ios-26-platform/references/automatic-adoption.md +161 -0
- package/skills/ios/ios-26-platform/references/backward-compat.md +238 -0
- package/skills/ios/ios-26-platform/references/liquid-glass.md +255 -0
- package/skills/ios/ios-26-platform/references/swiftui-apis.md +277 -0
- package/skills/ios/ios-26-platform/references/toolbar-navigation.md +250 -0
- package/skills/ios/ios-bootstrap/SKILL.md +98 -0
- package/skills/ios/ios-bootstrap/references/apple-docs-mcp-config.md +28 -0
- package/skills/ios/ios-bootstrap/references/new-project-dialog.md +41 -0
- package/skills/ios/ios-bootstrap/references/xcode-mcp-config.md +29 -0
- package/skills/ios/ios-debugger-agent/LICENSE +21 -0
- package/skills/ios/ios-debugger-agent/SKILL.md +58 -0
- package/skills/ios/ios-debugger-agent/agents/openai.yaml +4 -0
- package/skills/ios/ios-entitlements-generator/SKILL.md +47 -0
- package/skills/ios/ios-hig/SKILL.md +41 -0
- package/skills/ios/ios-hig/references/accessibility.md +81 -0
- package/skills/ios/ios-hig/references/content.md +142 -0
- package/skills/ios/ios-hig/references/feedback.md +123 -0
- package/skills/ios/ios-hig/references/interaction.md +199 -0
- package/skills/ios/ios-hig/references/performance-platform.md +129 -0
- package/skills/ios/ios-hig/references/privacy-permissions.md +181 -0
- package/skills/ios/ios-hig/references/visual-design.md +84 -0
- package/skills/ios/ios-info-plist-hardening/SKILL.md +130 -0
- package/skills/ios/ios-maestro-flow-author/SKILL.md +68 -0
- package/skills/ios/ios-maestro-flow-author/references/input-and-scroll.yaml +17 -0
- package/skills/ios/ios-maestro-flow-author/references/modal-and-dismiss.yaml +14 -0
- package/skills/ios/ios-maestro-flow-author/references/onboarding-flow.yaml +16 -0
- package/skills/ios/ios-maestro-flow-author/references/tab-navigation.yaml +13 -0
- package/skills/ios/ios-maestro-flow-author/references/tap-and-assert.yaml +9 -0
- package/skills/ios/swift-accessibility/LICENSE +21 -0
- package/skills/ios/swift-accessibility/SKILL.md +371 -0
- package/skills/ios/swift-accessibility/examples/before-after-appkit.md +446 -0
- package/skills/ios/swift-accessibility/examples/before-after-swiftui.md +441 -0
- package/skills/ios/swift-accessibility/examples/before-after-uikit.md +464 -0
- package/skills/ios/swift-accessibility/references/assistive-access.md +441 -0
- package/skills/ios/swift-accessibility/references/display-settings.md +491 -0
- package/skills/ios/swift-accessibility/references/dynamic-type.md +420 -0
- package/skills/ios/swift-accessibility/references/media-accessibility.md +421 -0
- package/skills/ios/swift-accessibility/references/motor-input.md +393 -0
- package/skills/ios/swift-accessibility/references/nutrition-labels.md +362 -0
- package/skills/ios/swift-accessibility/references/platform-specifics.md +515 -0
- package/skills/ios/swift-accessibility/references/semantic-structure.md +585 -0
- package/skills/ios/swift-accessibility/references/testing-auditing.md +507 -0
- package/skills/ios/swift-accessibility/references/voice-control.md +317 -0
- package/skills/ios/swift-accessibility/references/voiceover-swiftui.md +584 -0
- package/skills/ios/swift-accessibility/references/voiceover-uikit.md +519 -0
- package/skills/ios/swift-accessibility/references/wcag-mapping.md +167 -0
- package/skills/ios/swift-accessibility/resources/audit-template.swift +128 -0
- package/skills/ios/swift-accessibility/resources/qa-checklist.md +258 -0
- package/skills/ios/swift-concurrency/LICENSE +21 -0
- package/skills/ios/swift-concurrency/SKILL.md +171 -0
- package/skills/ios/swift-concurrency/references/_index.md +50 -0
- package/skills/ios/swift-concurrency/references/actors.md +660 -0
- package/skills/ios/swift-concurrency/references/async-algorithms.md +847 -0
- package/skills/ios/swift-concurrency/references/async-await-basics.md +266 -0
- package/skills/ios/swift-concurrency/references/async-sequences.md +710 -0
- package/skills/ios/swift-concurrency/references/core-data.md +560 -0
- package/skills/ios/swift-concurrency/references/glossary.md +135 -0
- package/skills/ios/swift-concurrency/references/linting.md +155 -0
- package/skills/ios/swift-concurrency/references/memory-management.md +569 -0
- package/skills/ios/swift-concurrency/references/migration.md +1104 -0
- package/skills/ios/swift-concurrency/references/performance.md +593 -0
- package/skills/ios/swift-concurrency/references/sendable.md +598 -0
- package/skills/ios/swift-concurrency/references/tasks.md +636 -0
- package/skills/ios/swift-concurrency/references/testing.md +592 -0
- package/skills/ios/swift-concurrency/references/threading.md +495 -0
- package/skills/ios/swift-security-expert/LICENSE +21 -0
- package/skills/ios/swift-security-expert/SKILL.md +470 -0
- package/skills/ios/swift-security-expert/references/biometric-authentication.md +565 -0
- package/skills/ios/swift-security-expert/references/certificate-trust.md +592 -0
- package/skills/ios/swift-security-expert/references/common-anti-patterns.md +690 -0
- package/skills/ios/swift-security-expert/references/compliance-owasp-mapping.md +537 -0
- package/skills/ios/swift-security-expert/references/credential-storage-patterns.md +721 -0
- package/skills/ios/swift-security-expert/references/cryptokit-public-key.md +505 -0
- package/skills/ios/swift-security-expert/references/cryptokit-symmetric.md +497 -0
- package/skills/ios/swift-security-expert/references/keychain-access-control.md +508 -0
- package/skills/ios/swift-security-expert/references/keychain-fundamentals.md +596 -0
- package/skills/ios/swift-security-expert/references/keychain-item-classes.md +476 -0
- package/skills/ios/swift-security-expert/references/keychain-sharing.md +458 -0
- package/skills/ios/swift-security-expert/references/migration-legacy-stores.md +727 -0
- package/skills/ios/swift-security-expert/references/secure-enclave.md +539 -0
- package/skills/ios/swift-security-expert/references/testing-security-code.md +781 -0
- package/skills/ios/swift-testing-expert/LICENSE +21 -0
- package/skills/ios/swift-testing-expert/SKILL.md +79 -0
- package/skills/ios/swift-testing-expert/references/_index.md +12 -0
- package/skills/ios/swift-testing-expert/references/async-testing-and-waiting.md +127 -0
- package/skills/ios/swift-testing-expert/references/expectations.md +145 -0
- package/skills/ios/swift-testing-expert/references/fundamentals.md +141 -0
- package/skills/ios/swift-testing-expert/references/migration-from-xctest.md +127 -0
- package/skills/ios/swift-testing-expert/references/parallelization-and-isolation.md +95 -0
- package/skills/ios/swift-testing-expert/references/parameterized-testing.md +284 -0
- package/skills/ios/swift-testing-expert/references/performance-and-best-practices.md +187 -0
- package/skills/ios/swift-testing-expert/references/traits-and-tags.md +114 -0
- package/skills/ios/swift-testing-expert/references/xcode-workflows.md +70 -0
- package/skills/ios/swiftdata-pro/LICENSE +21 -0
- package/skills/ios/swiftdata-pro/SKILL.md +102 -0
- package/skills/ios/swiftdata-pro/agents/openai.yaml +10 -0
- package/skills/ios/swiftdata-pro/assets/swiftdata-pro-icon.png +0 -0
- package/skills/ios/swiftdata-pro/assets/swiftdata-pro-icon.svg +29 -0
- package/skills/ios/swiftdata-pro/references/class-inheritance.md +104 -0
- package/skills/ios/swiftdata-pro/references/cloudkit.md +10 -0
- package/skills/ios/swiftdata-pro/references/core-rules.md +20 -0
- package/skills/ios/swiftdata-pro/references/indexing.md +27 -0
- package/skills/ios/swiftdata-pro/references/predicates.md +73 -0
- package/skills/ios/swiftui-design-principles/AGENTS.md +21 -0
- package/skills/ios/swiftui-design-principles/LICENSE +21 -0
- package/skills/ios/swiftui-design-principles/README.md +41 -0
- package/skills/ios/swiftui-design-principles/SKILL.md +605 -0
- package/skills/ios/swiftui-design-principles/metadata.json +10 -0
- package/skills/ios/swiftui-liquid-glass/LICENSE +21 -0
- package/skills/ios/swiftui-liquid-glass/SKILL.md +95 -0
- package/skills/ios/swiftui-liquid-glass/agents/openai.yaml +4 -0
- package/skills/ios/swiftui-liquid-glass/references/liquid-glass.md +280 -0
- package/skills/ios/swiftui-performance-audit/LICENSE +21 -0
- package/skills/ios/swiftui-performance-audit/SKILL.md +111 -0
- package/skills/ios/swiftui-performance-audit/agents/openai.yaml +4 -0
- package/skills/ios/swiftui-performance-audit/references/code-smells.md +150 -0
- package/skills/ios/swiftui-performance-audit/references/demystify-swiftui-performance-wwdc23.md +46 -0
- package/skills/ios/swiftui-performance-audit/references/optimizing-swiftui-performance-instruments.md +29 -0
- package/skills/ios/swiftui-performance-audit/references/profiling-intake.md +44 -0
- package/skills/ios/swiftui-performance-audit/references/report-template.md +47 -0
- package/skills/ios/swiftui-performance-audit/references/understanding-hangs-in-your-app.md +33 -0
- package/skills/ios/swiftui-performance-audit/references/understanding-improving-swiftui-performance.md +52 -0
- package/skills/ios/swiftui-pro/LICENSE +21 -0
- package/skills/ios/swiftui-pro/SKILL.md +108 -0
- package/skills/ios/swiftui-pro/agents/openai.yaml +10 -0
- package/skills/ios/swiftui-pro/assets/swiftui-pro-icon.png +0 -0
- package/skills/ios/swiftui-pro/assets/swiftui-pro-icon.svg +29 -0
- package/skills/ios/swiftui-pro/references/accessibility.md +13 -0
- package/skills/ios/swiftui-pro/references/api.md +39 -0
- package/skills/ios/swiftui-pro/references/data.md +43 -0
- package/skills/ios/swiftui-pro/references/design.md +31 -0
- package/skills/ios/swiftui-pro/references/hygiene.md +9 -0
- package/skills/ios/swiftui-pro/references/navigation.md +14 -0
- package/skills/ios/swiftui-pro/references/performance.md +46 -0
- package/skills/ios/swiftui-pro/references/swift.md +56 -0
- package/skills/ios/swiftui-pro/references/views.md +35 -0
- package/skills/ios/swiftui-ui-patterns/LICENSE +21 -0
- package/skills/ios/swiftui-ui-patterns/SKILL.md +100 -0
- package/skills/ios/swiftui-ui-patterns/agents/openai.yaml +4 -0
- package/skills/ios/swiftui-ui-patterns/references/app-wiring.md +201 -0
- package/skills/ios/swiftui-ui-patterns/references/async-state.md +96 -0
- package/skills/ios/swiftui-ui-patterns/references/components-index.md +50 -0
- package/skills/ios/swiftui-ui-patterns/references/controls.md +57 -0
- package/skills/ios/swiftui-ui-patterns/references/deeplinks.md +66 -0
- package/skills/ios/swiftui-ui-patterns/references/focus.md +90 -0
- package/skills/ios/swiftui-ui-patterns/references/form.md +97 -0
- package/skills/ios/swiftui-ui-patterns/references/grids.md +71 -0
- package/skills/ios/swiftui-ui-patterns/references/haptics.md +71 -0
- package/skills/ios/swiftui-ui-patterns/references/input-toolbar.md +51 -0
- package/skills/ios/swiftui-ui-patterns/references/lightweight-clients.md +93 -0
- package/skills/ios/swiftui-ui-patterns/references/list.md +86 -0
- package/skills/ios/swiftui-ui-patterns/references/loading-placeholders.md +38 -0
- package/skills/ios/swiftui-ui-patterns/references/macos-settings.md +71 -0
- package/skills/ios/swiftui-ui-patterns/references/matched-transitions.md +59 -0
- package/skills/ios/swiftui-ui-patterns/references/media.md +73 -0
- package/skills/ios/swiftui-ui-patterns/references/menu-bar.md +101 -0
- package/skills/ios/swiftui-ui-patterns/references/navigationstack.md +159 -0
- package/skills/ios/swiftui-ui-patterns/references/overlay.md +45 -0
- package/skills/ios/swiftui-ui-patterns/references/performance.md +62 -0
- package/skills/ios/swiftui-ui-patterns/references/previews.md +48 -0
- package/skills/ios/swiftui-ui-patterns/references/scroll-reveal.md +133 -0
- package/skills/ios/swiftui-ui-patterns/references/scrollview.md +87 -0
- package/skills/ios/swiftui-ui-patterns/references/searchable.md +71 -0
- package/skills/ios/swiftui-ui-patterns/references/sheets.md +155 -0
- package/skills/ios/swiftui-ui-patterns/references/split-views.md +72 -0
- package/skills/ios/swiftui-ui-patterns/references/tabview.md +114 -0
- package/skills/ios/swiftui-ui-patterns/references/theming.md +71 -0
- package/skills/ios/swiftui-ui-patterns/references/title-menus.md +93 -0
- package/skills/ios/swiftui-ui-patterns/references/top-bar.md +49 -0
- package/skills/ios/swiftui-view-refactor/LICENSE +21 -0
- package/skills/ios/swiftui-view-refactor/SKILL.md +207 -0
- package/skills/ios/swiftui-view-refactor/agents/openai.yaml +4 -0
- package/skills/ios/swiftui-view-refactor/references/mv-patterns.md +161 -0
- package/skills/ios/widgetkit/LICENSE +131 -0
- package/skills/ios/widgetkit/SKILL.md +502 -0
- package/skills/ios/widgetkit/references/widgetkit-advanced.md +871 -0
|
@@ -0,0 +1,781 @@
|
|
|
1
|
+
# Testing Keychain, CryptoKit, and Biometric Code
|
|
2
|
+
|
|
3
|
+
> Scope: Unit, integration, and CI patterns for validating keychain, CryptoKit, and biometric security code across simulator, CI runners, and physical devices.
|
|
4
|
+
|
|
5
|
+
**Protocol-based abstraction is the single most important pattern for testable security code.** Wrapping Security framework calls behind a Swift protocol lets you inject an in-memory mock for unit tests while reserving real keychain integration tests for physical devices. The core challenge is that keychain behavior differs dramatically across three environments — Xcode simulator, CI runner, and physical device — and tests that ignore these differences produce flaky failures, crashes, or false confidence.
|
|
6
|
+
|
|
7
|
+
This reference covers mock design, CryptoKit round-trip tests, Secure Enclave guards, biometric mocking, CI/CD keychain creation, simulator limitations, Swift Testing framework patterns, mutation testing, and OWASP MASTG validation. All code targets Swift 5.9+/6.0, iOS 17–18+, with iOS 26 post-quantum notes where applicable.
|
|
8
|
+
|
|
9
|
+
Key sources: Apple TN3137 "On Mac keychain APIs and implementations," WWDC19-413 "Testing in Xcode," WWDC24-10179/10195 "Meet/Go further with Swift Testing," Apple Platform Security Guide, OWASP MASTG.
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## Protocol-Based Keychain Abstraction
|
|
14
|
+
|
|
15
|
+
The foundation of testable keychain code is a protocol abstracting the four Security framework operations. Every view model, service, or manager that touches the keychain depends on this protocol, never on the Security framework directly.
|
|
16
|
+
|
|
17
|
+
### KeychainServiceProtocol with Real and Mock Implementations
|
|
18
|
+
|
|
19
|
+
```swift
|
|
20
|
+
import Foundation
|
|
21
|
+
import Security
|
|
22
|
+
|
|
23
|
+
enum KeychainError: Error, Equatable {
|
|
24
|
+
case duplicateItem
|
|
25
|
+
case itemNotFound
|
|
26
|
+
case authFailed
|
|
27
|
+
case interactionNotAllowed
|
|
28
|
+
case unexpectedData
|
|
29
|
+
case unhandledError(status: OSStatus)
|
|
30
|
+
|
|
31
|
+
init(status: OSStatus) {
|
|
32
|
+
switch status {
|
|
33
|
+
case errSecDuplicateItem: self = .duplicateItem
|
|
34
|
+
case errSecItemNotFound: self = .itemNotFound
|
|
35
|
+
case errSecAuthFailed: self = .authFailed
|
|
36
|
+
case errSecInteractionNotAllowed: self = .interactionNotAllowed
|
|
37
|
+
default: self = .unhandledError(status: status)
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
protocol KeychainServiceProtocol: Sendable {
|
|
43
|
+
func save(_ data: Data, forKey key: String) throws
|
|
44
|
+
func read(forKey key: String) throws -> Data?
|
|
45
|
+
func update(_ data: Data, forKey key: String) throws
|
|
46
|
+
func delete(forKey key: String) throws
|
|
47
|
+
func deleteAll() throws
|
|
48
|
+
}
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
The real `KeychainService` implementation wraps `SecItem*` calls with the add-or-update pattern and proper `OSStatus` mapping (see `keychain-fundamentals.md` for the full implementation). Key points: `save` attempts update first to avoid `errSecDuplicateItem`; `delete` treats `errSecItemNotFound` as success; the class conforms to `@unchecked Sendable` with immutable stored properties.
|
|
52
|
+
|
|
53
|
+
The mock replaces Security framework with a dictionary. Runs everywhere — simulator, CI, even Linux — with zero entitlement requirements. Supports injectable errors and call counting:
|
|
54
|
+
|
|
55
|
+
```swift
|
|
56
|
+
final class MockKeychainService: KeychainServiceProtocol, @unchecked Sendable {
|
|
57
|
+
var storage: [String: Data] = [:]
|
|
58
|
+
var saveCallCount = 0
|
|
59
|
+
var readCallCount = 0
|
|
60
|
+
var deleteCallCount = 0
|
|
61
|
+
var errorToThrow: KeychainError?
|
|
62
|
+
|
|
63
|
+
func save(_ data: Data, forKey key: String) throws {
|
|
64
|
+
if let error = errorToThrow { throw error }
|
|
65
|
+
saveCallCount += 1
|
|
66
|
+
storage[key] = data
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
func read(forKey key: String) throws -> Data? {
|
|
70
|
+
if let error = errorToThrow { throw error }
|
|
71
|
+
readCallCount += 1
|
|
72
|
+
return storage[key]
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
func update(_ data: Data, forKey key: String) throws {
|
|
76
|
+
if let error = errorToThrow { throw error }
|
|
77
|
+
guard storage[key] != nil else { throw KeychainError.itemNotFound }
|
|
78
|
+
storage[key] = data
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
func delete(forKey key: String) throws {
|
|
82
|
+
if let error = errorToThrow { throw error }
|
|
83
|
+
storage.removeValue(forKey: key)
|
|
84
|
+
deleteCallCount += 1
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
func deleteAll() throws {
|
|
88
|
+
if let error = errorToThrow { throw error }
|
|
89
|
+
storage.removeAll()
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
Business logic depends only on the protocol — never on `SecItem*` directly:
|
|
95
|
+
|
|
96
|
+
```swift
|
|
97
|
+
final class AuthenticationManager {
|
|
98
|
+
private let keychain: KeychainServiceProtocol
|
|
99
|
+
|
|
100
|
+
init(keychain: KeychainServiceProtocol) {
|
|
101
|
+
self.keychain = keychain
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
func storeToken(_ token: String) throws {
|
|
105
|
+
guard let data = token.data(using: .utf8) else {
|
|
106
|
+
throw KeychainError.unexpectedData
|
|
107
|
+
}
|
|
108
|
+
try keychain.save(data, forKey: "auth_token")
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
func retrieveToken() throws -> String? {
|
|
112
|
+
guard let data = try keychain.read(forKey: "auth_token") else { return nil }
|
|
113
|
+
return String(data: data, encoding: .utf8)
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
---
|
|
119
|
+
|
|
120
|
+
## Seven Mistakes AI Generators Make in Keychain Tests
|
|
121
|
+
|
|
122
|
+
Both research providers independently identified overlapping anti-patterns. This merged list covers the full set:
|
|
123
|
+
|
|
124
|
+
**1. Tests that use the real keychain without cleanup.** Tests calling `SecItemAdd` directly leave state across runs. Second run fails with `errSecDuplicateItem` (-25299). AI generators rarely include `setUp`/`tearDown` cleanup.
|
|
125
|
+
|
|
126
|
+
**2. Assuming Secure Enclave exists on simulator.** `SecureEnclave.isAvailable` returns `false` on every simulator. Tests calling `SecureEnclave.P256.Signing.PrivateKey()` directly throw `CryptoKitError` on simulator and crash CI.
|
|
127
|
+
|
|
128
|
+
**3. Not testing error paths.** Real keychain code must handle `errSecDuplicateItem` (-25299), `errSecItemNotFound` (-25300), `errSecAuthFailed` (-25293), and `errSecInteractionNotAllowed` (-25308). AI generators almost never test these failure modes.
|
|
129
|
+
|
|
130
|
+
**4. Assuming biometric hardware.** Tests instantiating a real `LAContext` and asserting `canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics)` returns `true` fail on simulator where no biometric hardware exists.
|
|
131
|
+
|
|
132
|
+
**5. Missing test host app.** Since Xcode 9, test bundles on iOS simulator require a host app to access the keychain. Without one, `SecItemAdd` returns `-25300` or `-34018`. AI generators never mention this requirement.
|
|
133
|
+
|
|
134
|
+
**6. No service/account scoping.** Tests omitting `kSecAttrService` match items from other tests or even other apps. Every keychain operation in tests must use a unique, test-specific service identifier.
|
|
135
|
+
|
|
136
|
+
**7. Confusing data protection keychain with file-based keychain.** Per Apple TN3137, macOS has two keychain implementations. The `security` CLI works with the file-based keychain; iOS apps use the data protection keychain. CI scripts using `security create-keychain` create the wrong type for `SecItemAdd` targets.
|
|
137
|
+
|
|
138
|
+
---
|
|
139
|
+
|
|
140
|
+
## Simulator vs. Device Testing Matrix
|
|
141
|
+
|
|
142
|
+
Understanding exactly what works where prevents entire categories of test failures:
|
|
143
|
+
|
|
144
|
+
| Feature | Simulator | Physical Device |
|
|
145
|
+
| ------------------------------------------------------------- | ------------------------------------- | ---------------------------- |
|
|
146
|
+
| Keychain CRUD (`SecItemAdd`, etc.) | ✅ Works | ✅ Works |
|
|
147
|
+
| CryptoKit software crypto (AES-GCM, ChaChaPoly, P256, SHA256) | ✅ Software | ✅ Hardware-accelerated |
|
|
148
|
+
| `kSecAttrAccessible` values | ✅ Accepted but not hardware-enforced | ✅ Hardware-enforced |
|
|
149
|
+
| `SecureEnclave.isAvailable` | Returns **false** | Returns **true** (A7+) |
|
|
150
|
+
| `SecureEnclave.P256.Signing.PrivateKey()` | ❌ Throws | ✅ Works |
|
|
151
|
+
| Biometric prompt on protected items | ❌ Skipped — value returned silently | ✅ Shows prompt |
|
|
152
|
+
| `LAContext.canEvaluatePolicy(.biometrics)` | Returns **false** | Returns **true** if enrolled |
|
|
153
|
+
| Face ID simulation via Xcode menu | ✅ Manual only | N/A (real hardware) |
|
|
154
|
+
| Post-quantum (ML-KEM, ML-DSA) iOS 26+ | ✅ Software (iOS 26 runtime) | ✅ Works |
|
|
155
|
+
|
|
156
|
+
**Critical subtlety:** On simulator, keychain items protected with `kSecAttrAccessControl` and biometric flags return their value without showing a biometric prompt. Simulator tests that store biometric-protected items and read them succeed silently, giving false confidence the biometric gate works.
|
|
157
|
+
|
|
158
|
+
### Conditional Compilation and Runtime Guards
|
|
159
|
+
|
|
160
|
+
```swift
|
|
161
|
+
// Compile-time: exclude SE code on simulator
|
|
162
|
+
#if targetEnvironment(simulator)
|
|
163
|
+
let signingKey = SoftwareSigningKey()
|
|
164
|
+
#else
|
|
165
|
+
let signingKey = SecureEnclave.isAvailable
|
|
166
|
+
? try SecureEnclaveSigningKey()
|
|
167
|
+
: SoftwareSigningKey()
|
|
168
|
+
#endif
|
|
169
|
+
|
|
170
|
+
// Runtime skip in XCTest
|
|
171
|
+
func testDeviceOnlyFeature() throws {
|
|
172
|
+
#if targetEnvironment(simulator)
|
|
173
|
+
throw XCTSkip("Requires physical device")
|
|
174
|
+
#endif
|
|
175
|
+
// Device-only test code here
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Runtime detection via ProcessInfo
|
|
179
|
+
struct EnvironmentDetector {
|
|
180
|
+
static var isSimulator: Bool {
|
|
181
|
+
ProcessInfo.processInfo.environment["SIMULATOR_DEVICE_NAME"] != nil
|
|
182
|
+
}
|
|
183
|
+
static var isRunningTests: Bool {
|
|
184
|
+
ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] != nil
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
---
|
|
190
|
+
|
|
191
|
+
## Essential Testing Patterns
|
|
192
|
+
|
|
193
|
+
### setUp/tearDown Cleanup for Real Keychain Tests
|
|
194
|
+
|
|
195
|
+
```swift
|
|
196
|
+
final class KeychainIntegrationTests: XCTestCase {
|
|
197
|
+
private let testService = "com.tests.keychain-integration"
|
|
198
|
+
private var keychain: KeychainService!
|
|
199
|
+
|
|
200
|
+
override func setUp() {
|
|
201
|
+
super.setUp()
|
|
202
|
+
keychain = KeychainService(service: testService)
|
|
203
|
+
try? keychain.deleteAll() // Clean slate
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
override func tearDown() {
|
|
207
|
+
try? keychain.deleteAll() // Leave no trace
|
|
208
|
+
super.tearDown()
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
func testSaveAndRetrieveToken() throws {
|
|
212
|
+
let token = "test-jwt-token-12345"
|
|
213
|
+
try keychain.save(token.data(using: .utf8)!, forKey: "access_token")
|
|
214
|
+
let retrieved = try keychain.read(forKey: "access_token")
|
|
215
|
+
XCTAssertEqual(String(data: retrieved!, encoding: .utf8), token)
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
### No Cleanup — Flaky Across Runs
|
|
221
|
+
|
|
222
|
+
```swift
|
|
223
|
+
// ❌ INCORRECT: No cleanup, no isolation
|
|
224
|
+
final class BadKeychainTests: XCTestCase {
|
|
225
|
+
func testSaveToken() {
|
|
226
|
+
let query: [String: Any] = [
|
|
227
|
+
kSecClass as String: kSecClassGenericPassword,
|
|
228
|
+
kSecAttrAccount as String: "token",
|
|
229
|
+
kSecValueData as String: "secret".data(using: .utf8)!
|
|
230
|
+
]
|
|
231
|
+
let status = SecItemAdd(query as CFDictionary, nil)
|
|
232
|
+
XCTAssertEqual(status, errSecSuccess)
|
|
233
|
+
// First run: passes ✅
|
|
234
|
+
// Second run: FAILS with errSecDuplicateItem (-25299) ❌
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
### Testing Error Paths with Injected Failures
|
|
240
|
+
|
|
241
|
+
```swift
|
|
242
|
+
final class KeychainErrorPathTests: XCTestCase {
|
|
243
|
+
var mockKeychain: MockKeychainService!
|
|
244
|
+
var authManager: AuthenticationManager!
|
|
245
|
+
|
|
246
|
+
override func setUp() {
|
|
247
|
+
mockKeychain = MockKeychainService()
|
|
248
|
+
authManager = AuthenticationManager(keychain: mockKeychain)
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
func testStoreToken_whenDuplicateItem_throwsExpectedError() {
|
|
252
|
+
mockKeychain.errorToThrow = .duplicateItem
|
|
253
|
+
XCTAssertThrowsError(try authManager.storeToken("token")) { error in
|
|
254
|
+
XCTAssertEqual(error as? KeychainError, .duplicateItem)
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
func testRetrieveToken_whenAuthFailed_throwsError() {
|
|
259
|
+
mockKeychain.errorToThrow = .authFailed
|
|
260
|
+
XCTAssertThrowsError(try authManager.retrieveToken()) { error in
|
|
261
|
+
XCTAssertEqual(error as? KeychainError, .authFailed)
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
func testRetrieveToken_whenInteractionNotAllowed_throwsError() {
|
|
266
|
+
// Simulates the most common CI failure scenario
|
|
267
|
+
mockKeychain.errorToThrow = .interactionNotAllowed
|
|
268
|
+
XCTAssertThrowsError(try authManager.retrieveToken()) { error in
|
|
269
|
+
XCTAssertEqual(error as? KeychainError, .interactionNotAllowed)
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
### CryptoKit Round-Trip Tests (Simulator-Safe)
|
|
276
|
+
|
|
277
|
+
All CryptoKit software operations work on simulator. These tests run everywhere:
|
|
278
|
+
|
|
279
|
+
```swift
|
|
280
|
+
import XCTest
|
|
281
|
+
import CryptoKit
|
|
282
|
+
|
|
283
|
+
final class CryptoKitTests: XCTestCase {
|
|
284
|
+
|
|
285
|
+
func testAESGCMRoundTrip() throws {
|
|
286
|
+
let key = SymmetricKey(size: .bits256)
|
|
287
|
+
let plaintext = "Sensitive credentials".data(using: .utf8)!
|
|
288
|
+
let sealedBox = try AES.GCM.seal(plaintext, using: key)
|
|
289
|
+
let ciphertext = sealedBox.combined!
|
|
290
|
+
XCTAssertNotEqual(ciphertext, plaintext)
|
|
291
|
+
|
|
292
|
+
let reopened = try AES.GCM.SealedBox(combined: ciphertext)
|
|
293
|
+
let decrypted = try AES.GCM.open(reopened, using: key)
|
|
294
|
+
XCTAssertEqual(decrypted, plaintext)
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
func testAESGCMWrongKeyFails() throws {
|
|
298
|
+
let correctKey = SymmetricKey(size: .bits256)
|
|
299
|
+
let wrongKey = SymmetricKey(size: .bits256)
|
|
300
|
+
let sealed = try AES.GCM.seal("secret".data(using: .utf8)!, using: correctKey)
|
|
301
|
+
XCTAssertThrowsError(try AES.GCM.open(sealed, using: wrongKey))
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
func testP256SignVerify() throws {
|
|
305
|
+
let privateKey = P256.Signing.PrivateKey()
|
|
306
|
+
let data = "Message to authenticate".data(using: .utf8)!
|
|
307
|
+
let signature = try privateKey.signature(for: data)
|
|
308
|
+
XCTAssertTrue(privateKey.publicKey.isValidSignature(signature, for: data))
|
|
309
|
+
|
|
310
|
+
let tampered = "Tampered message".data(using: .utf8)!
|
|
311
|
+
XCTAssertFalse(privateKey.publicKey.isValidSignature(signature, for: tampered))
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
func testCurve25519KeyAgreement() throws {
|
|
315
|
+
let alice = Curve25519.KeyAgreement.PrivateKey()
|
|
316
|
+
let bob = Curve25519.KeyAgreement.PrivateKey()
|
|
317
|
+
let aliceShared = try alice.sharedSecretFromKeyAgreement(with: bob.publicKey)
|
|
318
|
+
let bobShared = try bob.sharedSecretFromKeyAgreement(with: alice.publicKey)
|
|
319
|
+
|
|
320
|
+
let aliceKey = aliceShared.hkdfDerivedSymmetricKey(
|
|
321
|
+
using: SHA256.self, salt: Data(), sharedInfo: Data(), outputByteCount: 32)
|
|
322
|
+
let bobKey = bobShared.hkdfDerivedSymmetricKey(
|
|
323
|
+
using: SHA256.self, salt: Data(), sharedInfo: Data(), outputByteCount: 32)
|
|
324
|
+
|
|
325
|
+
// Both parties can decrypt each other's messages
|
|
326
|
+
let sealed = try AES.GCM.seal("test".data(using: .utf8)!, using: aliceKey)
|
|
327
|
+
XCTAssertEqual(try AES.GCM.open(sealed, using: bobKey), "test".data(using: .utf8)!)
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
```
|
|
331
|
+
|
|
332
|
+
**iOS 26 note:** Post-quantum cryptography (ML-KEM, ML-DSA) is available via CryptoKit starting iOS 26. Gate these tests with `@available(iOS 26, *)` and use the same round-trip pattern. Software-based PQC works on simulator (see `cryptokit-public-key.md`).
|
|
333
|
+
|
|
334
|
+
---
|
|
335
|
+
|
|
336
|
+
## Secure Enclave Test Strategy — Protocol Fallback
|
|
337
|
+
|
|
338
|
+
> **Cross-reference contradiction:** One research source used a function returning `P256.Signing.PrivateKey` for both SE and software paths. This is a type error — `SecureEnclave.P256.Signing.PrivateKey` and `P256.Signing.PrivateKey` are distinct types. The correct approach is a protocol-based abstraction:
|
|
339
|
+
|
|
340
|
+
### SigningKeyProvider Protocol with SE/Software Implementations
|
|
341
|
+
|
|
342
|
+
```swift
|
|
343
|
+
import CryptoKit
|
|
344
|
+
|
|
345
|
+
protocol SigningKeyProvider {
|
|
346
|
+
func sign(_ data: Data) throws -> Data
|
|
347
|
+
func publicKeyData() -> Data
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
final class SecureEnclaveSigningKey: SigningKeyProvider {
|
|
351
|
+
private let key: SecureEnclave.P256.Signing.PrivateKey
|
|
352
|
+
|
|
353
|
+
init() throws {
|
|
354
|
+
guard SecureEnclave.isAvailable else {
|
|
355
|
+
throw KeychainError.unhandledError(status: errSecUnimplemented)
|
|
356
|
+
}
|
|
357
|
+
self.key = try SecureEnclave.P256.Signing.PrivateKey()
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
func sign(_ data: Data) throws -> Data {
|
|
361
|
+
try key.signature(for: data).derRepresentation
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
func publicKeyData() -> Data { key.publicKey.derRepresentation }
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
final class SoftwareSigningKey: SigningKeyProvider {
|
|
368
|
+
private let key = P256.Signing.PrivateKey()
|
|
369
|
+
|
|
370
|
+
func sign(_ data: Data) throws -> Data {
|
|
371
|
+
try key.signature(for: data).derRepresentation
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
func publicKeyData() -> Data { key.publicKey.derRepresentation }
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
struct SigningKeyFactory {
|
|
378
|
+
static func make() -> SigningKeyProvider {
|
|
379
|
+
if SecureEnclave.isAvailable,
|
|
380
|
+
let seKey = try? SecureEnclaveSigningKey() {
|
|
381
|
+
return seKey
|
|
382
|
+
}
|
|
383
|
+
return SoftwareSigningKey()
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
```
|
|
387
|
+
|
|
388
|
+
### Testing Secure Enclave Code
|
|
389
|
+
|
|
390
|
+
```swift
|
|
391
|
+
// ❌ INCORRECT: Crashes on simulator and CI
|
|
392
|
+
func testSecureEnclaveSigning_BROKEN() throws {
|
|
393
|
+
let key = try SecureEnclave.P256.Signing.PrivateKey() // throws on simulator
|
|
394
|
+
let sig = try key.signature(for: "data".data(using: .utf8)!)
|
|
395
|
+
XCTAssertTrue(key.publicKey.isValidSignature(sig, for: "data".data(using: .utf8)!))
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// ✅ CORRECT: Skip gracefully when SE unavailable
|
|
399
|
+
func testSecureEnclaveSigning_withGuard() throws {
|
|
400
|
+
try XCTSkipUnless(SecureEnclave.isAvailable,
|
|
401
|
+
"Secure Enclave not available — skipping on simulator")
|
|
402
|
+
let key = try SecureEnclave.P256.Signing.PrivateKey()
|
|
403
|
+
let data = "authenticated payload".data(using: .utf8)!
|
|
404
|
+
let sig = try key.signature(for: data)
|
|
405
|
+
XCTAssertTrue(key.publicKey.isValidSignature(sig, for: data))
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// ✅ CORRECT: Protocol-based test runs everywhere
|
|
409
|
+
func testSigningWithFallback() throws {
|
|
410
|
+
let signer = SigningKeyFactory.make()
|
|
411
|
+
let data = "payload".data(using: .utf8)!
|
|
412
|
+
let sigBytes = try signer.sign(data)
|
|
413
|
+
XCTAssertFalse(sigBytes.isEmpty)
|
|
414
|
+
|
|
415
|
+
let publicKey = try P256.Signing.PublicKey(derRepresentation: signer.publicKeyData())
|
|
416
|
+
let signature = try P256.Signing.ECDSASignature(derRepresentation: sigBytes)
|
|
417
|
+
XCTAssertTrue(publicKey.isValidSignature(signature, for: data))
|
|
418
|
+
}
|
|
419
|
+
```
|
|
420
|
+
|
|
421
|
+
---
|
|
422
|
+
|
|
423
|
+
## Biometric Flow Testing — LAContext Mocking
|
|
424
|
+
|
|
425
|
+
Wrap `LAContext` behind a protocol for full control over biometric outcomes in tests. Alternatively, subclass `LAContext` directly (simpler but tighter coupling).
|
|
426
|
+
|
|
427
|
+
### Protocol-Based Approach (Preferred)
|
|
428
|
+
|
|
429
|
+
```swift
|
|
430
|
+
import LocalAuthentication
|
|
431
|
+
|
|
432
|
+
protocol BiometricAuthContext {
|
|
433
|
+
func canEvaluatePolicy(_ policy: LAPolicy, error: NSErrorPointer) -> Bool
|
|
434
|
+
func evaluatePolicy(_ policy: LAPolicy, localizedReason: String,
|
|
435
|
+
reply: @escaping (Bool, Error?) -> Void)
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
extension LAContext: BiometricAuthContext {}
|
|
439
|
+
|
|
440
|
+
final class BiometricAuthManager {
|
|
441
|
+
private let context: BiometricAuthContext
|
|
442
|
+
|
|
443
|
+
init(context: BiometricAuthContext = LAContext()) {
|
|
444
|
+
self.context = context
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
var isBiometricsAvailable: Bool {
|
|
448
|
+
context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: nil)
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
func authenticate(reason: String,
|
|
452
|
+
completion: @escaping (Result<Void, Error>) -> Void) {
|
|
453
|
+
guard isBiometricsAvailable else {
|
|
454
|
+
completion(.failure(LAError(.biometryNotAvailable)))
|
|
455
|
+
return
|
|
456
|
+
}
|
|
457
|
+
context.evaluatePolicy(.deviceOwnerAuthenticationWithBiometrics,
|
|
458
|
+
localizedReason: reason) { success, error in
|
|
459
|
+
completion(success ? .success(()) : .failure(error ?? LAError(.authenticationFailed)))
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
final class MockBiometricContext: BiometricAuthContext {
|
|
465
|
+
var canEvaluateResult = true
|
|
466
|
+
var evaluateResult = true
|
|
467
|
+
var evaluateError: Error?
|
|
468
|
+
var evaluateCalled = false
|
|
469
|
+
|
|
470
|
+
func canEvaluatePolicy(_ policy: LAPolicy, error: NSErrorPointer) -> Bool {
|
|
471
|
+
canEvaluateResult
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
func evaluatePolicy(_ policy: LAPolicy, localizedReason: String,
|
|
475
|
+
reply: @escaping (Bool, Error?) -> Void) {
|
|
476
|
+
evaluateCalled = true
|
|
477
|
+
reply(evaluateResult, evaluateError)
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
```
|
|
481
|
+
|
|
482
|
+
### Biometric Scenarios to Cover
|
|
483
|
+
|
|
484
|
+
| Scenario | canEvaluate | evaluatePolicy | Error | Expected App Behavior |
|
|
485
|
+
| ------------ | ----------- | -------------- | ---------------------- | ------------------------- |
|
|
486
|
+
| Success | true | true | nil | Proceed |
|
|
487
|
+
| User cancel | true | false | `.userCancel` | Retry or abort gracefully |
|
|
488
|
+
| Lockout | true | false | `.biometryLockout` | Fallback to passcode |
|
|
489
|
+
| Not enrolled | false | n/a | `.biometryNotEnrolled` | Show enrollment guidance |
|
|
490
|
+
|
|
491
|
+
```swift
|
|
492
|
+
func testBiometricAuthSuccess() {
|
|
493
|
+
let mock = MockBiometricContext()
|
|
494
|
+
mock.canEvaluateResult = true
|
|
495
|
+
mock.evaluateResult = true
|
|
496
|
+
let manager = BiometricAuthManager(context: mock)
|
|
497
|
+
|
|
498
|
+
let exp = expectation(description: "auth")
|
|
499
|
+
manager.authenticate(reason: "Test") { result in
|
|
500
|
+
if case .failure = result { XCTFail("Expected success") }
|
|
501
|
+
exp.fulfill()
|
|
502
|
+
}
|
|
503
|
+
waitForExpectations(timeout: 1)
|
|
504
|
+
XCTAssertTrue(mock.evaluateCalled)
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
func testBiometricAuthUnavailable() {
|
|
508
|
+
let mock = MockBiometricContext()
|
|
509
|
+
mock.canEvaluateResult = false
|
|
510
|
+
let manager = BiometricAuthManager(context: mock)
|
|
511
|
+
|
|
512
|
+
let exp = expectation(description: "unavailable")
|
|
513
|
+
manager.authenticate(reason: "Test") { result in
|
|
514
|
+
if case .success = result { XCTFail("Expected failure") }
|
|
515
|
+
exp.fulfill()
|
|
516
|
+
}
|
|
517
|
+
waitForExpectations(timeout: 1)
|
|
518
|
+
XCTAssertFalse(mock.evaluateCalled) // Should not attempt auth
|
|
519
|
+
}
|
|
520
|
+
```
|
|
521
|
+
|
|
522
|
+
---
|
|
523
|
+
|
|
524
|
+
## CI/CD Pipeline Configuration
|
|
525
|
+
|
|
526
|
+
Running keychain tests in CI is the most error-prone part. The `-25308` (`errSecInteractionNotAllowed`) error is the most common CI failure — keychain locked or requires GUI interaction in a headless environment.
|
|
527
|
+
|
|
528
|
+
### GitHub Actions
|
|
529
|
+
|
|
530
|
+
```yaml
|
|
531
|
+
name: iOS CI
|
|
532
|
+
on: [push, pull_request]
|
|
533
|
+
jobs:
|
|
534
|
+
test:
|
|
535
|
+
runs-on: macos-latest
|
|
536
|
+
steps:
|
|
537
|
+
- uses: actions/checkout@v5
|
|
538
|
+
- name: Create temporary keychain
|
|
539
|
+
env:
|
|
540
|
+
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
|
|
541
|
+
run: |
|
|
542
|
+
KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db
|
|
543
|
+
security create-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
|
|
544
|
+
security set-keychain-settings -lut 21600 $KEYCHAIN_PATH
|
|
545
|
+
security unlock-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
|
|
546
|
+
security list-keychain -d user -s $KEYCHAIN_PATH
|
|
547
|
+
# Import cert + CRITICAL partition list step
|
|
548
|
+
echo -n "$BUILD_CERTIFICATE_BASE64" | base64 --decode -o $RUNNER_TEMP/cert.p12
|
|
549
|
+
security import $RUNNER_TEMP/cert.p12 -P "$P12_PASSWORD" \
|
|
550
|
+
-A -t cert -f pkcs12 -k $KEYCHAIN_PATH
|
|
551
|
+
security set-key-partition-list -S apple-tool:,apple: \
|
|
552
|
+
-k "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
|
|
553
|
+
- name: Run simulator-safe tests
|
|
554
|
+
run: |
|
|
555
|
+
xcodebuild test -scheme MyApp \
|
|
556
|
+
-destination 'platform=iOS Simulator,name=iPhone 16' \
|
|
557
|
+
-testPlan CITests
|
|
558
|
+
- name: Cleanup
|
|
559
|
+
if: always()
|
|
560
|
+
run: security delete-keychain $RUNNER_TEMP/app-signing.keychain-db
|
|
561
|
+
```
|
|
562
|
+
|
|
563
|
+
**`security set-key-partition-list` must be called after importing certificates** — this is the step most people miss. Without it, `codesign` hangs indefinitely waiting for a GUI prompt. The `-A` flag on import grants access to all applications, necessary in CI.
|
|
564
|
+
|
|
565
|
+
**Xcode Cloud:** Uses ephemeral environments — no manual `security create-keychain`. Apple manages signing automatically. Ensure Keychain Sharing capability is enabled. The `-25308` error is common when SPM tries to save credentials.
|
|
566
|
+
|
|
567
|
+
**Fastlane:** `setup_ci` creates a temporary `fastlane_tmp_keychain` and sets it as default. On self-hosted runners, this can interfere with the host machine's keychain.
|
|
568
|
+
|
|
569
|
+
```ruby
|
|
570
|
+
lane :ci_test do
|
|
571
|
+
setup_ci(timeout: 3600)
|
|
572
|
+
sync_code_signing(type: "development", readonly: is_ci)
|
|
573
|
+
run_tests(scheme: "MyApp", testplan: "CITests", device: "iPhone 16")
|
|
574
|
+
end
|
|
575
|
+
```
|
|
576
|
+
|
|
577
|
+
### Common CI Error Reference
|
|
578
|
+
|
|
579
|
+
| Error | OSStatus | Cause | Fix |
|
|
580
|
+
| ----------------------------- | -------- | ------------------------------------- | ----------------------------------------------- |
|
|
581
|
+
| `errSecInteractionNotAllowed` | -25308 | Keychain locked / needs GUI | Unlock keychain + `set-key-partition-list` |
|
|
582
|
+
| `errSecMissingEntitlement` | -34018 | No keychain-access-groups entitlement | Add entitlements to test host app |
|
|
583
|
+
| `errSecItemNotFound` | -25300 | No test host or missing entitlement | Use test host app with keychain capability |
|
|
584
|
+
| `errSecInternalComponent` | -67585 | Partition list not set after import | Call `set-key-partition-list` after cert import |
|
|
585
|
+
| Default keychain not found | -25307 | No default keychain on CI runner | Create and set default keychain |
|
|
586
|
+
|
|
587
|
+
### Test Host App Requirement
|
|
588
|
+
|
|
589
|
+
Since Xcode 9, test bundles on iOS simulator require a host app to access the keychain. Without one, `SecItemAdd` returns `-25300` or `-34018`. Create a minimal iOS app target, enable the Keychain Sharing capability, and set the test target's **Test Host** and **Bundle Loader** build settings to point at it.
|
|
590
|
+
|
|
591
|
+
---
|
|
592
|
+
|
|
593
|
+
## Xcode Test Plans — Separating Simulator from Device
|
|
594
|
+
|
|
595
|
+
Create two test plans for CI/device split:
|
|
596
|
+
|
|
597
|
+
- **CITests.xctestplan**: Only tests using `MockKeychainService` and simulator-safe CryptoKit. Skips integration, biometric, and SE tests.
|
|
598
|
+
- **DeviceTests.xctestplan**: Real keychain integration, Secure Enclave, and biometric hardware tests. Requires physical device.
|
|
599
|
+
|
|
600
|
+
```bash
|
|
601
|
+
# CI: simulator-safe tests on every push
|
|
602
|
+
xcodebuild test -scheme MyApp \
|
|
603
|
+
-destination 'platform=iOS Simulator,name=iPhone 16' \
|
|
604
|
+
-testPlan CITests
|
|
605
|
+
|
|
606
|
+
# Nightly: device farm runs everything
|
|
607
|
+
xcodebuild test -scheme MyApp \
|
|
608
|
+
-destination 'platform=iOS,id=DEVICE_UDID' \
|
|
609
|
+
-testPlan DeviceTests
|
|
610
|
+
```
|
|
611
|
+
|
|
612
|
+
---
|
|
613
|
+
|
|
614
|
+
## Swift Testing Framework Patterns
|
|
615
|
+
|
|
616
|
+
Swift Testing (WWDC24) introduces tags, traits, and parameterized tests that map well to security test organization:
|
|
617
|
+
|
|
618
|
+
```swift
|
|
619
|
+
import Testing
|
|
620
|
+
@testable import MyApp
|
|
621
|
+
|
|
622
|
+
extension Tag {
|
|
623
|
+
@Tag static var keychain: Self
|
|
624
|
+
@Tag static var deviceOnly: Self
|
|
625
|
+
@Tag static var ciSafe: Self
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
@Suite(.serialized, .tags(.keychain))
|
|
629
|
+
struct KeychainTests {
|
|
630
|
+
|
|
631
|
+
@Test("Save and retrieve round-trip", .tags(.ciSafe))
|
|
632
|
+
func saveAndRetrieve() throws {
|
|
633
|
+
let mock = MockKeychainService()
|
|
634
|
+
let manager = AuthenticationManager(keychain: mock)
|
|
635
|
+
try manager.storeToken("test-token")
|
|
636
|
+
let result = try #require(try manager.retrieveToken())
|
|
637
|
+
#expect(result == "test-token")
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
@Test("Device-only: real keychain integration",
|
|
641
|
+
.enabled(if: ProcessInfo.processInfo.environment["CI"] == nil),
|
|
642
|
+
.tags(.deviceOnly))
|
|
643
|
+
func realKeychainIntegration() throws {
|
|
644
|
+
let keychain = KeychainService(service: "com.test.swift-testing")
|
|
645
|
+
try keychain.deleteAll()
|
|
646
|
+
defer { try? keychain.deleteAll() }
|
|
647
|
+
try keychain.save("token".data(using: .utf8)!, forKey: "key")
|
|
648
|
+
let data = try #require(try keychain.read(forKey: "key"))
|
|
649
|
+
#expect(String(data: data, encoding: .utf8) == "token")
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
@Test("Parameterized error paths",
|
|
653
|
+
arguments: [
|
|
654
|
+
KeychainError.duplicateItem,
|
|
655
|
+
KeychainError.itemNotFound,
|
|
656
|
+
KeychainError.authFailed,
|
|
657
|
+
KeychainError.interactionNotAllowed
|
|
658
|
+
])
|
|
659
|
+
func errorPathHandling(expectedError: KeychainError) {
|
|
660
|
+
let mock = MockKeychainService()
|
|
661
|
+
mock.errorToThrow = expectedError
|
|
662
|
+
#expect(throws: KeychainError.self) {
|
|
663
|
+
try mock.read(forKey: "any-key")
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
```
|
|
668
|
+
|
|
669
|
+
The `.serialized` trait ensures keychain tests modifying shared state run sequentially. Tags integrate with test plans for filtering — `.ciSafe` tests run in CI, `.deviceOnly` tests run on device farms.
|
|
670
|
+
|
|
671
|
+
---
|
|
672
|
+
|
|
673
|
+
## Advanced Patterns
|
|
674
|
+
|
|
675
|
+
### Migration Testing: UserDefaults to Keychain
|
|
676
|
+
|
|
677
|
+
Migration code is security-critical — silent failure leaves credentials in UserDefaults (see `migration-legacy-stores.md`). The class under test accepts injected dependencies for both stores:
|
|
678
|
+
|
|
679
|
+
```swift
|
|
680
|
+
final class StorageMigrationManager {
|
|
681
|
+
private let defaults: UserDefaults
|
|
682
|
+
private let keychain: KeychainServiceProtocol
|
|
683
|
+
|
|
684
|
+
init(defaults: UserDefaults = .standard,
|
|
685
|
+
keychain: KeychainServiceProtocol) {
|
|
686
|
+
self.defaults = defaults
|
|
687
|
+
self.keychain = keychain
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
func migrateIfNeeded() throws {
|
|
691
|
+
let version = defaults.integer(forKey: "migration_version")
|
|
692
|
+
if version < 1 {
|
|
693
|
+
if let token = defaults.string(forKey: "auth_token"),
|
|
694
|
+
let data = token.data(using: .utf8) {
|
|
695
|
+
try keychain.save(data, forKey: "auth_token")
|
|
696
|
+
defaults.removeObject(forKey: "auth_token")
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
defaults.set(1, forKey: "migration_version")
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
```
|
|
703
|
+
|
|
704
|
+
Test with isolated `UserDefaults(suiteName:)` and mock keychain:
|
|
705
|
+
|
|
706
|
+
```swift
|
|
707
|
+
func testMigrationMovesTokenToKeychain() throws {
|
|
708
|
+
let defaults = UserDefaults(suiteName: "migration-test")!
|
|
709
|
+
defaults.removePersistentDomain(forName: "migration-test")
|
|
710
|
+
defaults.set("my-secret", forKey: "auth_token")
|
|
711
|
+
defaults.set(0, forKey: "migration_version")
|
|
712
|
+
|
|
713
|
+
let mock = MockKeychainService()
|
|
714
|
+
let migrator = StorageMigrationManager(defaults: defaults, keychain: mock)
|
|
715
|
+
try migrator.migrateIfNeeded()
|
|
716
|
+
|
|
717
|
+
// Token moved to keychain, removed from UserDefaults
|
|
718
|
+
XCTAssertEqual(String(data: mock.storage["auth_token"]!, encoding: .utf8), "my-secret")
|
|
719
|
+
XCTAssertNil(defaults.string(forKey: "auth_token"))
|
|
720
|
+
}
|
|
721
|
+
```
|
|
722
|
+
|
|
723
|
+
### Performance Testing
|
|
724
|
+
|
|
725
|
+
```swift
|
|
726
|
+
func testKeychainWritePerformance() {
|
|
727
|
+
let keychain = KeychainService(service: "com.test.perf")
|
|
728
|
+
let options = XCTMeasureOptions()
|
|
729
|
+
options.iterationCount = 20
|
|
730
|
+
|
|
731
|
+
measure(metrics: [XCTClockMetric(), XCTCPUMetric()], options: options) {
|
|
732
|
+
let data = UUID().uuidString.data(using: .utf8)!
|
|
733
|
+
try? keychain.save(data, forKey: "perf-key")
|
|
734
|
+
try? keychain.delete(forKey: "perf-key")
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
```
|
|
738
|
+
|
|
739
|
+
### Mutation Testing
|
|
740
|
+
|
|
741
|
+
Mutation testing introduces deliberate bugs (flipping `==` to `!=`, removing `SecItemDelete` calls, swapping `&&` to `||`) and checks whether your tests catch them. A project can have 81% code coverage but only 16% mutation score — tests execute security code without validating it does the right thing.
|
|
742
|
+
|
|
743
|
+
**Muter** (`brew install muter-mutation-testing/muter/muter`) is the primary Swift mutation testing tool. Its `RelationalOperatorReplacement` operator catches authentication bypasses; `RemoveSideEffects` catches missing `SecItemDelete` calls in logout flows. For security code, target mutation score above **80%**.
|
|
744
|
+
|
|
745
|
+
### OWASP MASTG Keychain Validation
|
|
746
|
+
|
|
747
|
+
MASTG-TEST-0052 requires that sensitive data use the Keychain, not `NSUserDefaults` or `.plist` files. OWASP also documents that keychain data persists after app uninstallation — the app sandbox is wiped but keychain items remain. Standard mitigation is a fresh-install detector (see `common-anti-patterns.md`):
|
|
748
|
+
|
|
749
|
+
```swift
|
|
750
|
+
static func handleFreshInstall(keychain: KeychainServiceProtocol) {
|
|
751
|
+
let hasLaunched = UserDefaults.standard.bool(forKey: "has_launched")
|
|
752
|
+
if !hasLaunched {
|
|
753
|
+
try? keychain.deleteAll()
|
|
754
|
+
UserDefaults.standard.set(true, forKey: "has_launched")
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
```
|
|
758
|
+
|
|
759
|
+
---
|
|
760
|
+
|
|
761
|
+
## Conclusion
|
|
762
|
+
|
|
763
|
+
Protocol-abstraction is non-negotiable for testable keychain code. Every `SecItem` call should be behind `KeychainServiceProtocol` so that 95%+ of your test suite runs against `MockKeychainService` with zero entitlement requirements and zero CI flakiness. Reserve real-keychain integration tests for a dedicated test plan on physical devices.
|
|
764
|
+
|
|
765
|
+
Three insights most guides miss: (1) the simulator silently returns biometric-protected items without prompting — tests appear to validate biometric gates but test nothing; (2) TN3137's distinction between file-based and data protection keychains means `security create-keychain` in CI creates the wrong keychain type; (3) mutation testing reveals that even high-coverage suites fail to catch inverted conditionals and removed side effects — the exact mutations that create real vulnerabilities.
|
|
766
|
+
|
|
767
|
+
---
|
|
768
|
+
|
|
769
|
+
## Summary Checklist
|
|
770
|
+
|
|
771
|
+
1. **Protocol abstraction** — All keychain access goes through `KeychainServiceProtocol`; no direct `SecItem*` calls in business logic
|
|
772
|
+
2. **Mock with injectable errors** — `MockKeychainService` supports `errorToThrow` for testing `errSecDuplicateItem`, `errSecAuthFailed`, `errSecInteractionNotAllowed`, and `errSecItemNotFound` paths
|
|
773
|
+
3. **setUp/tearDown cleanup** — Every integration test using real keychain has both pre-test and post-test cleanup with a test-specific `kSecAttrService`
|
|
774
|
+
4. **Secure Enclave guard** — All SE tests use `try XCTSkipUnless(SecureEnclave.isAvailable, ...)` or protocol-based fallback; never call `SecureEnclave.P256.*` unconditionally
|
|
775
|
+
5. **Biometric mock** — `LAContext` wrapped behind protocol or subclass mock; tests cover success, user cancel, lockout, and not-enrolled scenarios
|
|
776
|
+
6. **Simulator/device split** — Two Xcode test plans: `CITests` (mock-based, simulator-safe) and `DeviceTests` (real keychain, SE, biometrics on physical device)
|
|
777
|
+
7. **CI keychain setup** — GitHub Actions calls `security set-key-partition-list` after cert import; test target has host app with Keychain Sharing capability enabled
|
|
778
|
+
8. **CryptoKit round-trips** — Encrypt→decrypt and sign→verify tests for AES-GCM, ChaChaPoly, P256, Curve25519; wrong-key failure tests included
|
|
779
|
+
9. **Error path coverage** — Every `OSStatus` code the app can encounter has a corresponding test with injected mock failure
|
|
780
|
+
10. **Migration testing** — UserDefaults→Keychain migration tested with isolated `UserDefaults(suiteName:)` and mock keychain; verifies source cleared after migration
|
|
781
|
+
11. **Mutation testing baseline** — Muter mutation score ≥80% for security-critical code paths; `RelationalOperatorReplacement` and `RemoveSideEffects` operators enabled
|