@necessarysdk/react-native 0.0.0-stage → 0.1.6

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 (117) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/LICENSE +201 -0
  3. package/NOTICE +2 -0
  4. package/NecessaryReactNative.podspec +23 -0
  5. package/README.md +429 -2
  6. package/android/build.gradle +83 -0
  7. package/android/src/main/AndroidManifest.xml +41 -0
  8. package/android/src/main/cpp/CMakeLists.txt +7 -0
  9. package/android/src/main/cpp/code_integrity.cpp +484 -0
  10. package/android/src/main/java/com/necessarysdk/reactnative/Causes.kt +49 -0
  11. package/android/src/main/java/com/necessarysdk/reactnative/InstallationKeyManager.kt +388 -0
  12. package/android/src/main/java/com/necessarysdk/reactnative/IntegrityProbeManager.kt +1157 -0
  13. package/android/src/main/java/com/necessarysdk/reactnative/KeyPresence.kt +62 -0
  14. package/android/src/main/java/com/necessarysdk/reactnative/NecessaryModules.kt +94 -0
  15. package/android/src/main/java/com/necessarysdk/reactnative/NecessaryNative.kt +338 -0
  16. package/android/src/main/java/com/necessarysdk/reactnative/NecessaryPackage.kt +38 -0
  17. package/android/src/main/java/com/necessarysdk/reactnative/ProbeReads.kt +333 -0
  18. package/android/src/main/java/com/necessarysdk/reactnative/ScreenGuard.kt +56 -0
  19. package/android/src/main/java/com/necessarysdk/reactnative/SecureStore.kt +127 -0
  20. package/android/src/main/java/com/necessarysdk/reactnative/StepUpKeyEntry.kt +57 -0
  21. package/android/src/main/java/com/necessarysdk/reactnative/StepUpKeyManager.kt +767 -0
  22. package/contract/report-1/android-8-ios-5/RULES.md +149 -0
  23. package/contract/report-1/android-8-ios-5/report.schema.json +2753 -0
  24. package/contract/report-1/android-8-ios-5/samples/android-8-complete.json +205 -0
  25. package/contract/report-1/android-8-ios-5/samples/android-8-incomplete.json +205 -0
  26. package/contract/report-1/android-8-ios-5/samples/android-8-invalid.json +205 -0
  27. package/contract/report-1/android-8-ios-5/samples/android-8-location-one-fix.json +228 -0
  28. package/contract/report-1/android-8-ios-5/samples/android-8-location-two-fixes.json +228 -0
  29. package/contract/report-1/android-8-ios-5/samples/android-8-refused.json +208 -0
  30. package/contract/report-1/android-8-ios-5/samples/ios-5-complete.json +106 -0
  31. package/contract/report-1/android-8-ios-5/samples/ios-5-incomplete.json +105 -0
  32. package/contract/report-1/android-8-ios-5/samples/ios-5-invalid.json +107 -0
  33. package/contract/report-1/android-8-ios-5/samples/ios-5-partial.json +110 -0
  34. package/contract/report-1/android-8-ios-5/samples/ios-5-refused.json +109 -0
  35. package/doc/integration_guide.md +771 -0
  36. package/doc/measurements.md +774 -0
  37. package/ios/InstallationKeyManager.swift +694 -0
  38. package/ios/IntegrityProbeManager.swift +959 -0
  39. package/ios/NecessaryModules.h +34 -0
  40. package/ios/NecessaryModules.mm +208 -0
  41. package/ios/NecessaryNative.swift +327 -0
  42. package/ios/PrivacyInfo.xcprivacy +14 -0
  43. package/ios/ProbeReads.swift +370 -0
  44. package/lib/client.d.ts +105 -0
  45. package/lib/client.js +176 -0
  46. package/lib/collector.d.ts +18 -0
  47. package/lib/collector.js +49 -0
  48. package/lib/deviceTrust.d.ts +253 -0
  49. package/lib/deviceTrust.js +699 -0
  50. package/lib/errors.d.ts +43 -0
  51. package/lib/errors.js +56 -0
  52. package/lib/index.d.ts +18 -0
  53. package/lib/index.js +34 -0
  54. package/lib/installationKey.d.ts +62 -0
  55. package/lib/installationKey.js +185 -0
  56. package/lib/installationStore.d.ts +49 -0
  57. package/lib/installationStore.js +127 -0
  58. package/lib/native/NativeNecessaryInstallationKey.d.ts +10 -0
  59. package/lib/native/NativeNecessaryInstallationKey.js +4 -0
  60. package/lib/native/NativeNecessaryIntegrity.d.ts +8 -0
  61. package/lib/native/NativeNecessaryIntegrity.js +4 -0
  62. package/lib/native/NativeNecessarySecureScreen.d.ts +7 -0
  63. package/lib/native/NativeNecessarySecureScreen.js +4 -0
  64. package/lib/native/NativeNecessaryStepUpKey.d.ts +9 -0
  65. package/lib/native/NativeNecessaryStepUpKey.js +4 -0
  66. package/lib/native/NativeNecessaryStorage.d.ts +14 -0
  67. package/lib/native/NativeNecessaryStorage.js +4 -0
  68. package/lib/protocol.d.ts +24 -0
  69. package/lib/protocol.js +50 -0
  70. package/lib/reinstallHint.d.ts +23 -0
  71. package/lib/reinstallHint.js +65 -0
  72. package/lib/report.d.ts +62 -0
  73. package/lib/report.js +134 -0
  74. package/lib/requestProof.d.ts +116 -0
  75. package/lib/requestProof.js +220 -0
  76. package/lib/secureScreen.d.ts +15 -0
  77. package/lib/secureScreen.js +32 -0
  78. package/lib/stepUpKey.d.ts +105 -0
  79. package/lib/stepUpKey.js +187 -0
  80. package/lib/transport.d.ts +39 -0
  81. package/lib/transport.js +117 -0
  82. package/lib/util/answers.d.ts +6 -0
  83. package/lib/util/answers.js +16 -0
  84. package/lib/util/bytes.d.ts +18 -0
  85. package/lib/util/bytes.js +207 -0
  86. package/lib/util/native.d.ts +20 -0
  87. package/lib/util/native.js +49 -0
  88. package/lib/util/release.d.ts +2 -0
  89. package/lib/util/release.js +7 -0
  90. package/lib/util/target.d.ts +18 -0
  91. package/lib/util/target.js +128 -0
  92. package/package.json +81 -4
  93. package/src/client.ts +221 -0
  94. package/src/collector.ts +64 -0
  95. package/src/deviceTrust.ts +851 -0
  96. package/src/errors.ts +71 -0
  97. package/src/index.ts +19 -0
  98. package/src/installationKey.ts +223 -0
  99. package/src/installationStore.ts +143 -0
  100. package/src/native/NativeNecessaryInstallationKey.ts +12 -0
  101. package/src/native/NativeNecessaryIntegrity.ts +15 -0
  102. package/src/native/NativeNecessarySecureScreen.ts +9 -0
  103. package/src/native/NativeNecessaryStepUpKey.ts +11 -0
  104. package/src/native/NativeNecessaryStorage.ts +16 -0
  105. package/src/protocol.ts +52 -0
  106. package/src/reinstallHint.ts +69 -0
  107. package/src/report.ts +168 -0
  108. package/src/requestProof.ts +295 -0
  109. package/src/secureScreen.ts +29 -0
  110. package/src/stepUpKey.ts +238 -0
  111. package/src/transport.ts +145 -0
  112. package/src/util/answers.ts +15 -0
  113. package/src/util/bytes.ts +194 -0
  114. package/src/util/native.ts +56 -0
  115. package/src/util/release.ts +6 -0
  116. package/src/util/target.ts +132 -0
  117. package/vectors/client_server.json +264 -0
@@ -0,0 +1,694 @@
1
+ import Foundation
2
+ import LocalAuthentication
3
+ import Security
4
+
5
+ /// Failure carrying the same error-code vocabulary the Android side uses, so
6
+ /// the JavaScript side sees one contract regardless of platform.
7
+ struct InstallationKeyFailure: Error {
8
+ let code: String
9
+ let message: String
10
+ }
11
+
12
+ /// Owns one non-exportable P-256 signing key in the Secure Enclave.
13
+ ///
14
+ /// This is the iOS counterpart of the Android installation key manager. The
15
+ /// contract it must honour is fixed by the JavaScript side (`NativeKeyMetadata.fromPlatform`),
16
+ /// which rejects anything that is not an EC P-256 JWK and requires every field
17
+ /// below to be present and correctly typed.
18
+ ///
19
+ /// Two properties are load-bearing and must match Android exactly:
20
+ ///
21
+ /// * the payload arrives as base64url TEXT, is base64url-DECODED, and the raw
22
+ /// bytes are signed — the server verifies over exactly those bytes;
23
+ /// * the signature is ASN.1 DER. `ecdsaSignatureMessageX962SHA256` produces
24
+ /// X9.62/DER, which is what `SHA256withECDSA` yields on Android and what
25
+ /// PyCryptodome verifies server-side. A raw r||s signature would be
26
+ /// rejected.
27
+ final class InstallationKeyManager {
28
+ private static let keyMetadataVersion = 2
29
+ private static let maxPayloadBytes = 65536
30
+ /// Making and deleting the key run one at a time in the process, whichever
31
+ /// runtime asks: two first starts at once could each find no key and each
32
+ /// add one under the same tag. Recursive: a key found dead is deleted
33
+ /// while it is being made.
34
+ private static let keyLock = NSRecursiveLock()
35
+
36
+ private let alias: String
37
+ private let tag: Data
38
+
39
+ init(bundleIdentifier: String) {
40
+ // Mirrors the Android alias shape so the two platforms are legible
41
+ // side by side in logs and support tickets.
42
+ alias = "\(bundleIdentifier).device_recognition.installation_key.v2"
43
+ tag = Data(alias.utf8)
44
+ }
45
+
46
+ // MARK: - Module operations
47
+
48
+ func getOrCreateKey() throws -> [String: Any] {
49
+ Self.keyLock.lock()
50
+ defer { Self.keyLock.unlock() }
51
+ var created = false
52
+ var key = try loadKey()
53
+
54
+ if key == nil {
55
+ key = try generateKey()
56
+ created = true
57
+ }
58
+
59
+ guard let privateKey = key else {
60
+ throw InstallationKeyFailure(
61
+ code: "KEY_NOT_FOUND",
62
+ message: "The iOS installation key could not be created or loaded."
63
+ )
64
+ }
65
+
66
+ guard let publicKey = SecKeyCopyPublicKey(privateKey) else {
67
+ throw InstallationKeyFailure(
68
+ code: "INVALID_PUBLIC_KEY",
69
+ message: "The Secure Enclave did not return a public key."
70
+ )
71
+ }
72
+
73
+ var exportError: Unmanaged<CFError>?
74
+ guard let representation =
75
+ SecKeyCopyExternalRepresentation(publicKey, &exportError) as Data? else {
76
+ throw InstallationKeyFailure(
77
+ code: "INVALID_PUBLIC_KEY",
78
+ message: "The public key could not be exported: \(describe(exportError))"
79
+ )
80
+ }
81
+
82
+ // ANSI X9.63 uncompressed point: 0x04 || X(32) || Y(32).
83
+ guard representation.count == 65, representation.first == 0x04 else {
84
+ throw InstallationKeyFailure(
85
+ code: "INVALID_PUBLIC_KEY",
86
+ message: "The public key is not an uncompressed P-256 point."
87
+ )
88
+ }
89
+ let x = representation.subdata(in: 1..<33)
90
+ let y = representation.subdata(in: 33..<65)
91
+
92
+ let security = describeSecurity(privateKey)
93
+
94
+ return [
95
+ "version": InstallationKeyManager.keyMetadataVersion,
96
+ "key_alias": alias,
97
+ "algorithm": "ES256",
98
+ "signature_format": "asn1_der",
99
+ "provider": security.provider,
100
+ "security_level": security.level,
101
+ "hardware_backed": security.hardwareBacked,
102
+ "private_key_exportable": false,
103
+ "created": created,
104
+ "public_key": [
105
+ "kty": "EC",
106
+ "crv": "P-256",
107
+ "alg": "ES256",
108
+ "x": base64Url(x),
109
+ "y": base64Url(y),
110
+ ],
111
+ ]
112
+ }
113
+
114
+ func signPayload(_ payloadBase64Url: String) throws -> String {
115
+ let payload = try decodeBase64Url(payloadBase64Url)
116
+ guard payload.count <= InstallationKeyManager.maxPayloadBytes else {
117
+ throw InstallationKeyFailure(
118
+ code: "PAYLOAD_TOO_LARGE",
119
+ message: "The challenge payload is larger than \(InstallationKeyManager.maxPayloadBytes) bytes."
120
+ )
121
+ }
122
+
123
+ guard let privateKey = try loadKey() else {
124
+ throw InstallationKeyFailure(
125
+ code: "KEY_NOT_FOUND",
126
+ message: "The iOS installation key is missing. Restart the app to create a fresh installation identity."
127
+ )
128
+ }
129
+
130
+ guard SecKeyIsAlgorithmSupported(
131
+ privateKey, .sign, .ecdsaSignatureMessageX962SHA256
132
+ ) else {
133
+ throw InstallationKeyFailure(
134
+ code: "SIGNING_FAILED",
135
+ message: "The installation key does not support ECDSA-SHA256 signing."
136
+ )
137
+ }
138
+
139
+ var signError: Unmanaged<CFError>?
140
+ guard let signature = SecKeyCreateSignature(
141
+ privateKey,
142
+ .ecdsaSignatureMessageX962SHA256,
143
+ payload as CFData,
144
+ &signError
145
+ ) as Data? else {
146
+ throw InstallationKeyFailure(
147
+ code: "SIGNING_FAILED",
148
+ message: "The Secure Enclave could not sign the installation challenge: \(describe(signError))"
149
+ )
150
+ }
151
+
152
+ return base64Url(signature)
153
+ }
154
+
155
+ @discardableResult
156
+ func deleteKey() throws -> Bool {
157
+ Self.keyLock.lock()
158
+ defer { Self.keyLock.unlock() }
159
+ let query: [String: Any] = [
160
+ kSecClass as String: kSecClassKey,
161
+ kSecAttrApplicationTag as String: tag,
162
+ kSecAttrKeyType as String: kSecAttrKeyTypeECSECPrimeRandom,
163
+ ]
164
+ let status = SecItemDelete(query as CFDictionary)
165
+ if status == errSecSuccess { return true }
166
+ if status == errSecItemNotFound { return false }
167
+ throw InstallationKeyFailure(
168
+ code: "KEY_DELETE_FAILED",
169
+ message: "The keychain could not delete the installation key (OSStatus \(status))."
170
+ )
171
+ }
172
+
173
+ // MARK: - Key material
174
+
175
+ private func loadKey() throws -> SecKey? {
176
+ let query: [String: Any] = [
177
+ kSecClass as String: kSecClassKey,
178
+ kSecAttrApplicationTag as String: tag,
179
+ kSecAttrKeyType as String: kSecAttrKeyTypeECSECPrimeRandom,
180
+ kSecReturnRef as String: true,
181
+ ]
182
+ var item: CFTypeRef?
183
+ let status = SecItemCopyMatching(query as CFDictionary, &item)
184
+
185
+ if status == errSecItemNotFound { return nil }
186
+ guard status == errSecSuccess else {
187
+ throw InstallationKeyFailure(
188
+ code: "KEY_LOOKUP_FAILED",
189
+ message: "The keychain could not load the installation key (OSStatus \(status))."
190
+ )
191
+ }
192
+ guard let result = item, CFGetTypeID(result) == SecKeyGetTypeID() else {
193
+ throw InstallationKeyFailure(
194
+ code: "KEY_LOOKUP_FAILED",
195
+ message: "The keychain returned an item that is not a key."
196
+ )
197
+ }
198
+ return (result as! SecKey)
199
+ }
200
+
201
+ /// Secure Enclave first, with a graceful fallback — the same shape as the
202
+ /// Android host's StrongBox attempt. The Simulator has no Secure Enclave,
203
+ /// and some configurations refuse a given algorithm, so a failure here must
204
+ /// not leave the app unable to enrol.
205
+ private func generateKey() throws -> SecKey {
206
+ if let key = try? generateKey(useSecureEnclave: true) {
207
+ return key
208
+ }
209
+ // Clear any partial item the failed attempt may have left behind, so
210
+ // the retry is not blocked by a duplicate tag.
211
+ _ = try? deleteKey()
212
+ return try generateKey(useSecureEnclave: false)
213
+ }
214
+
215
+ private func generateKey(useSecureEnclave: Bool) throws -> SecKey {
216
+ var privateKeyAttributes: [String: Any] = [
217
+ kSecAttrIsPermanent as String: true,
218
+ kSecAttrApplicationTag as String: tag,
219
+ ]
220
+
221
+ if useSecureEnclave {
222
+ var accessError: Unmanaged<CFError>?
223
+ // .privateKeyUsage only - no biometric or passcode gate, matching
224
+ // the Android host's setUserAuthenticationRequired(false). The key
225
+ // must be usable unattended for background integrity scans.
226
+ guard let access = SecAccessControlCreateWithFlags(
227
+ kCFAllocatorDefault,
228
+ kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly,
229
+ [.privateKeyUsage],
230
+ &accessError
231
+ ) else {
232
+ throw InstallationKeyFailure(
233
+ code: "KEY_GENERATION_FAILED",
234
+ message: "Could not build the Secure Enclave access control: \(describe(accessError))"
235
+ )
236
+ }
237
+ privateKeyAttributes[kSecAttrAccessControl as String] = access
238
+ } else {
239
+ privateKeyAttributes[kSecAttrAccessible as String] =
240
+ kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
241
+ // Best effort on a device without a Secure Enclave. The server is
242
+ // told the truth through security_level/hardware_backed so it can
243
+ // weight the installation accordingly.
244
+ privateKeyAttributes[kSecAttrIsExtractable as String] = false
245
+ }
246
+
247
+ var attributes: [String: Any] = [
248
+ kSecAttrKeyType as String: kSecAttrKeyTypeECSECPrimeRandom,
249
+ kSecAttrKeySizeInBits as String: 256,
250
+ kSecPrivateKeyAttrs as String: privateKeyAttributes,
251
+ ]
252
+ if useSecureEnclave {
253
+ attributes[kSecAttrTokenID as String] = kSecAttrTokenIDSecureEnclave
254
+ }
255
+
256
+ var error: Unmanaged<CFError>?
257
+ guard let key = SecKeyCreateRandomKey(attributes as CFDictionary, &error) else {
258
+ throw InstallationKeyFailure(
259
+ code: "KEY_GENERATION_FAILED",
260
+ message: useSecureEnclave
261
+ ? "The Secure Enclave could not create the P-256 installation key: \(describe(error))"
262
+ : "The keychain could not create the P-256 installation key: \(describe(error))"
263
+ )
264
+ }
265
+ return key
266
+ }
267
+
268
+ private func describeSecurity(
269
+ _ key: SecKey
270
+ ) -> (provider: String, level: String, hardwareBacked: Bool) {
271
+ guard let attributes = SecKeyCopyAttributes(key) as? [String: Any] else {
272
+ return ("Keychain", "unknown", false)
273
+ }
274
+ let tokenID = attributes[kSecAttrTokenID as String] as? String
275
+ if tokenID == (kSecAttrTokenIDSecureEnclave as String) {
276
+ return ("SecureEnclave", "secure_enclave", true)
277
+ }
278
+ return ("Keychain", "software", false)
279
+ }
280
+
281
+ // MARK: - Encoding
282
+
283
+ private func base64Url(_ value: Data) -> String {
284
+ var text = value.base64EncodedString()
285
+ text = text.replacingOccurrences(of: "+", with: "-")
286
+ text = text.replacingOccurrences(of: "/", with: "_")
287
+ text = text.replacingOccurrences(of: "=", with: "")
288
+ return text
289
+ }
290
+
291
+ private func decodeBase64Url(_ value: String) throws -> Data {
292
+ let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
293
+ guard !trimmed.isEmpty else {
294
+ throw InstallationKeyFailure(
295
+ code: "INVALID_PAYLOAD",
296
+ message: "The challenge payload is empty."
297
+ )
298
+ }
299
+ var text = trimmed
300
+ .replacingOccurrences(of: "-", with: "+")
301
+ .replacingOccurrences(of: "_", with: "/")
302
+ // Restore the padding base64url omits.
303
+ let remainder = text.count % 4
304
+ if remainder > 0 {
305
+ text.append(String(repeating: "=", count: 4 - remainder))
306
+ }
307
+ guard let data = Data(base64Encoded: text) else {
308
+ throw InstallationKeyFailure(
309
+ code: "INVALID_PAYLOAD",
310
+ message: "The challenge payload is not valid base64url."
311
+ )
312
+ }
313
+ return data
314
+ }
315
+
316
+ private func describe(_ error: Unmanaged<CFError>?) -> String {
317
+ guard let error = error?.takeRetainedValue() else { return "unknown error" }
318
+ return CFErrorCopyDescription(error) as String? ?? "unknown error"
319
+ }
320
+ }
321
+
322
+ /// Owns the OPTIONAL step-up key: a second Secure Enclave
323
+ /// P-256 key whose access control demands the device passcode (default) or
324
+ /// the current biometric set for EVERY signature.
325
+ ///
326
+ /// The installation key above stays unattended for routine proof of
327
+ /// possession; this key approves only sensitive operations, so a compromised
328
+ /// app process cannot drive it as a silent signing oracle for them.
329
+ ///
330
+ /// iOS always gets per-use: each signature runs with a fresh `LAContext`, so an
331
+ /// earlier authentication is never reused. A windowed mode is not offered here
332
+ /// because iOS has no hardware-enforced reuse window for a passcode-gated key,
333
+ /// and an app-level "authenticated recently?" check proves nothing.
334
+ ///
335
+ /// The key requires a passcode to exist
336
+ /// (`kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly`): without one it cannot
337
+ /// be created. Removing the passcode did NOT delete it on an iPhone on iOS
338
+ /// 15: the item stayed and could never sign again. The factor is recorded in
339
+ /// the key's label at creation so what is reported is what the key enforces.
340
+ final class StepUpKeyManager {
341
+ private static let maxPayloadBytes = 65536
342
+ private static let labelPrefix = "stepup:"
343
+ /// As for the installation key: making and deleting run one at a time in
344
+ /// the process. Never held while the person is asked.
345
+ private static let keyLock = NSRecursiveLock()
346
+
347
+ private let alias: String
348
+ private let tag: Data
349
+
350
+ init(bundleIdentifier: String) {
351
+ alias = "\(bundleIdentifier).device_recognition.stepup_key.v1"
352
+ tag = Data(alias.utf8)
353
+ }
354
+
355
+ // MARK: - Module operations
356
+
357
+ func getOrCreateKey(factor: String) throws -> [String: Any] {
358
+ Self.keyLock.lock()
359
+ defer { Self.keyLock.unlock() }
360
+ guard factor == "passcode" || factor == "biometric" else {
361
+ throw InstallationKeyFailure(
362
+ code: "INVALID_ARGUMENT",
363
+ message: "factor must be passcode or biometric."
364
+ )
365
+ }
366
+
367
+ var created = false
368
+ var key = try loadKey(context: nil)
369
+ if key != nil && !devicePasscodeSet() {
370
+ // A passcode-bound key cannot outlive the passcode: its protection
371
+ // is destroyed when the passcode is removed, but on an iPhone on
372
+ // iOS 15 the item itself stayed behind.
373
+ throw discardDeadKey(because: "the device passcode was removed")
374
+ }
375
+ if key == nil {
376
+ try requireFactorAvailable(factor)
377
+ key = try generateKey(factor: factor)
378
+ created = true
379
+ }
380
+ guard let privateKey = key, let publicKey = SecKeyCopyPublicKey(privateKey) else {
381
+ throw InstallationKeyFailure(
382
+ code: "STEPUP_KEY_NOT_FOUND",
383
+ message: "The step-up key could not be created or loaded."
384
+ )
385
+ }
386
+
387
+ var exportError: Unmanaged<CFError>?
388
+ guard let representation =
389
+ SecKeyCopyExternalRepresentation(publicKey, &exportError) as Data?,
390
+ representation.count == 65, representation.first == 0x04 else {
391
+ throw InstallationKeyFailure(
392
+ code: "INVALID_PUBLIC_KEY",
393
+ message: "The step-up public key is not an uncompressed P-256 point."
394
+ )
395
+ }
396
+
397
+ return [
398
+ "key_alias": alias,
399
+ "algorithm": "ES256",
400
+ "signature_format": "asn1_der",
401
+ "provider": "SecureEnclave",
402
+ "security_level": "secure_enclave",
403
+ "hardware_backed": true,
404
+ "private_key_exportable": false,
405
+ "created": created,
406
+ "public_key": [
407
+ "kty": "EC",
408
+ "crv": "P-256",
409
+ "alg": "ES256",
410
+ "x": base64Url(representation.subdata(in: 1..<33)),
411
+ "y": base64Url(representation.subdata(in: 33..<65)),
412
+ ],
413
+ "auth": [
414
+ "factor": storedFactor() ?? factor,
415
+ "mode": "per_use",
416
+ "window_seconds": 0,
417
+ ] as [String: Any],
418
+ ]
419
+ }
420
+
421
+ /// Blocks until the user answers the system prompt, so it must run off the
422
+ /// main thread (the SDK's worker queue).
423
+ func signPayload(_ payloadBase64Url: String, reason: String) throws -> String {
424
+ let payload = try decodeBase64Url(payloadBase64Url)
425
+ guard payload.count <= StepUpKeyManager.maxPayloadBytes else {
426
+ throw InstallationKeyFailure(
427
+ code: "PAYLOAD_TOO_LARGE",
428
+ message: "The step-up payload is larger than \(StepUpKeyManager.maxPayloadBytes) bytes."
429
+ )
430
+ }
431
+
432
+ // A fresh context per signature: an already-evaluated context would be
433
+ // reused silently, and per-use is the whole point.
434
+ let context = LAContext()
435
+ context.localizedReason = reason
436
+ context.touchIDAuthenticationAllowableReuseDuration = 0
437
+
438
+ guard let privateKey = try loadKey(context: context) else {
439
+ throw InstallationKeyFailure(
440
+ code: "STEPUP_KEY_NOT_FOUND",
441
+ message: "No step-up key exists on this installation."
442
+ )
443
+ }
444
+
445
+ var signError: Unmanaged<CFError>?
446
+ guard let signature = SecKeyCreateSignature(
447
+ privateKey,
448
+ .ecdsaSignatureMessageX962SHA256,
449
+ payload as CFData,
450
+ &signError
451
+ ) as Data? else {
452
+ let error = signError?.takeRetainedValue()
453
+ if let error = error, isDeadKeyError(error) {
454
+ throw discardDeadKey(because: "the Secure Enclave can no longer use it")
455
+ }
456
+ throw signFailure(error)
457
+ }
458
+ return base64Url(signature)
459
+ }
460
+
461
+ // MARK: - Dead keys
462
+
463
+ /// CryptoTokenKit error -3 (corrupted data): the Secure Enclave can no
464
+ /// longer unwrap the key. Seen on an iPhone after the passcode was
465
+ /// removed, and still after it was set again.
466
+ private func isDeadKeyError(_ error: CFError) -> Bool {
467
+ return (CFErrorGetDomain(error) as String) == "CryptoTokenKit" && CFErrorGetCode(error) == -3
468
+ }
469
+
470
+ /// False only when iOS reports that no passcode is set; any other answer
471
+ /// is treated as "set", so a live key is never removed on a guess.
472
+ private func devicePasscodeSet() -> Bool {
473
+ let context = LAContext()
474
+ var policyError: NSError?
475
+ if context.canEvaluatePolicy(.deviceOwnerAuthentication, error: &policyError) {
476
+ return true
477
+ }
478
+ return policyError?.code != LAError.Code.passcodeNotSet.rawValue
479
+ }
480
+
481
+ /// Deletes a step-up key that can never sign again and reports it,
482
+ /// saying truthfully whether the removal worked. The installation must
483
+ /// then re-enrol: the server never accepts a replacement step-up key.
484
+ private func discardDeadKey(because reason: String) -> InstallationKeyFailure {
485
+ var removal = "has been removed"
486
+ do {
487
+ try deleteKey()
488
+ if try loadKey(context: nil) != nil {
489
+ removal = "could not be removed (still present)"
490
+ }
491
+ } catch let failure as InstallationKeyFailure {
492
+ removal = "could not be removed (\(failure.message))"
493
+ } catch {
494
+ removal = "could not be removed (\(error.localizedDescription))"
495
+ }
496
+ return InstallationKeyFailure(
497
+ code: "STEPUP_KEY_INVALIDATED",
498
+ message: "The step-up key was invalidated (\(reason)) and \(removal). "
499
+ + "Re-enrol the installation to get a new one."
500
+ )
501
+ }
502
+
503
+ @discardableResult
504
+ func deleteKey() throws -> Bool {
505
+ Self.keyLock.lock()
506
+ defer { Self.keyLock.unlock() }
507
+ let query: [String: Any] = [
508
+ kSecClass as String: kSecClassKey,
509
+ kSecAttrApplicationTag as String: tag,
510
+ kSecAttrKeyType as String: kSecAttrKeyTypeECSECPrimeRandom,
511
+ ]
512
+ let status = SecItemDelete(query as CFDictionary)
513
+ if status == errSecSuccess { return true }
514
+ if status == errSecItemNotFound { return false }
515
+ throw InstallationKeyFailure(
516
+ code: "KEY_DELETE_FAILED",
517
+ message: "The keychain could not delete the step-up key (OSStatus \(status))."
518
+ )
519
+ }
520
+
521
+ // MARK: - Key material
522
+
523
+ private func requireFactorAvailable(_ factor: String) throws {
524
+ let context = LAContext()
525
+ var policyError: NSError?
526
+ let policy: LAPolicy = factor == "biometric"
527
+ ? .deviceOwnerAuthenticationWithBiometrics
528
+ : .deviceOwnerAuthentication
529
+ if !context.canEvaluatePolicy(policy, error: &policyError) {
530
+ throw InstallationKeyFailure(
531
+ code: factor == "biometric" ? "STEPUP_NO_BIOMETRIC" : "STEPUP_NO_DEVICE_CREDENTIAL",
532
+ message: factor == "biometric"
533
+ ? "Enrol Touch ID or Face ID to enable biometric step-up."
534
+ : "Set a device passcode to enable step-up."
535
+ )
536
+ }
537
+ }
538
+
539
+ private func generateKey(factor: String) throws -> SecKey {
540
+ let flags: SecAccessControlCreateFlags = factor == "biometric"
541
+ ? [.privateKeyUsage, .biometryCurrentSet]
542
+ : [.privateKeyUsage, .devicePasscode]
543
+ var accessError: Unmanaged<CFError>?
544
+ guard let access = SecAccessControlCreateWithFlags(
545
+ kCFAllocatorDefault,
546
+ kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly,
547
+ flags,
548
+ &accessError
549
+ ) else {
550
+ throw InstallationKeyFailure(
551
+ code: "STEPUP_KEY_GENERATION_FAILED",
552
+ message: "Could not build the step-up access control: \(describe(accessError))"
553
+ )
554
+ }
555
+
556
+ let privateKeyAttributes: [String: Any] = [
557
+ kSecAttrIsPermanent as String: true,
558
+ kSecAttrApplicationTag as String: tag,
559
+ kSecAttrLabel as String: StepUpKeyManager.labelPrefix + factor,
560
+ kSecAttrAccessControl as String: access,
561
+ ]
562
+ let attributes: [String: Any] = [
563
+ kSecAttrKeyType as String: kSecAttrKeyTypeECSECPrimeRandom,
564
+ kSecAttrKeySizeInBits as String: 256,
565
+ kSecAttrTokenID as String: kSecAttrTokenIDSecureEnclave,
566
+ kSecPrivateKeyAttrs as String: privateKeyAttributes,
567
+ ]
568
+
569
+ var error: Unmanaged<CFError>?
570
+ guard let key = SecKeyCreateRandomKey(attributes as CFDictionary, &error) else {
571
+ throw InstallationKeyFailure(
572
+ code: "STEPUP_KEY_GENERATION_FAILED",
573
+ message: "The Secure Enclave could not create the step-up key: \(describe(error))"
574
+ )
575
+ }
576
+ return key
577
+ }
578
+
579
+ private func loadKey(context: LAContext?) throws -> SecKey? {
580
+ var query: [String: Any] = [
581
+ kSecClass as String: kSecClassKey,
582
+ kSecAttrApplicationTag as String: tag,
583
+ kSecAttrKeyType as String: kSecAttrKeyTypeECSECPrimeRandom,
584
+ kSecReturnRef as String: true,
585
+ ]
586
+ if let context = context {
587
+ query[kSecUseAuthenticationContext as String] = context
588
+ }
589
+ var item: CFTypeRef?
590
+ let status = SecItemCopyMatching(query as CFDictionary, &item)
591
+ if status == errSecItemNotFound { return nil }
592
+ guard status == errSecSuccess else {
593
+ throw InstallationKeyFailure(
594
+ code: "KEY_LOOKUP_FAILED",
595
+ message: "The keychain could not load the step-up key (OSStatus \(status))."
596
+ )
597
+ }
598
+ guard let result = item, CFGetTypeID(result) == SecKeyGetTypeID() else {
599
+ throw InstallationKeyFailure(
600
+ code: "KEY_LOOKUP_FAILED",
601
+ message: "The keychain returned an item that is not a key."
602
+ )
603
+ }
604
+ return (result as! SecKey)
605
+ }
606
+
607
+ private func storedFactor() -> String? {
608
+ let query: [String: Any] = [
609
+ kSecClass as String: kSecClassKey,
610
+ kSecAttrApplicationTag as String: tag,
611
+ kSecAttrKeyType as String: kSecAttrKeyTypeECSECPrimeRandom,
612
+ kSecReturnAttributes as String: true,
613
+ ]
614
+ var item: CFTypeRef?
615
+ guard SecItemCopyMatching(query as CFDictionary, &item) == errSecSuccess,
616
+ let attributes = item as? [String: Any],
617
+ let label = attributes[kSecAttrLabel as String] as? String,
618
+ label.hasPrefix(StepUpKeyManager.labelPrefix) else {
619
+ return nil
620
+ }
621
+ return String(label.dropFirst(StepUpKeyManager.labelPrefix.count))
622
+ }
623
+
624
+ private func signFailure(_ error: CFError?) -> InstallationKeyFailure {
625
+ guard let error = error else {
626
+ return InstallationKeyFailure(
627
+ code: "STEPUP_SIGNING_FAILED",
628
+ message: "The Secure Enclave could not sign with the step-up key."
629
+ )
630
+ }
631
+ let domain = CFErrorGetDomain(error) as String
632
+ let code = CFErrorGetCode(error)
633
+ let text = CFErrorCopyDescription(error) as String? ?? "unknown error"
634
+ let laCancel = [
635
+ LAError.Code.userCancel.rawValue,
636
+ LAError.Code.appCancel.rawValue,
637
+ LAError.Code.systemCancel.rawValue,
638
+ ]
639
+ if (domain == NSOSStatusErrorDomain && code == Int(errSecUserCanceled))
640
+ || (domain == LAErrorDomain && laCancel.contains(code)) {
641
+ return InstallationKeyFailure(
642
+ code: "STEPUP_CANCELLED",
643
+ message: "Step-up authentication was cancelled: \(text)"
644
+ )
645
+ }
646
+ if (domain == NSOSStatusErrorDomain && code == Int(errSecAuthFailed))
647
+ || domain == LAErrorDomain {
648
+ return InstallationKeyFailure(
649
+ code: "STEPUP_AUTH_FAILED",
650
+ message: "Step-up authentication failed: \(text)"
651
+ )
652
+ }
653
+ return InstallationKeyFailure(
654
+ code: "STEPUP_SIGNING_FAILED",
655
+ message: "The Secure Enclave could not sign with the step-up key: \(text)"
656
+ )
657
+ }
658
+
659
+ // MARK: - Encoding (mirrors InstallationKeyManager)
660
+
661
+ private func base64Url(_ value: Data) -> String {
662
+ var text = value.base64EncodedString()
663
+ text = text.replacingOccurrences(of: "+", with: "-")
664
+ text = text.replacingOccurrences(of: "/", with: "_")
665
+ text = text.replacingOccurrences(of: "=", with: "")
666
+ return text
667
+ }
668
+
669
+ private func decodeBase64Url(_ value: String) throws -> Data {
670
+ let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
671
+ guard !trimmed.isEmpty else {
672
+ throw InstallationKeyFailure(code: "INVALID_PAYLOAD", message: "The step-up payload is empty.")
673
+ }
674
+ var text = trimmed
675
+ .replacingOccurrences(of: "-", with: "+")
676
+ .replacingOccurrences(of: "_", with: "/")
677
+ let remainder = text.count % 4
678
+ if remainder > 0 {
679
+ text.append(String(repeating: "=", count: 4 - remainder))
680
+ }
681
+ guard let data = Data(base64Encoded: text) else {
682
+ throw InstallationKeyFailure(
683
+ code: "INVALID_PAYLOAD",
684
+ message: "The step-up payload is not valid base64url."
685
+ )
686
+ }
687
+ return data
688
+ }
689
+
690
+ private func describe(_ error: Unmanaged<CFError>?) -> String {
691
+ guard let error = error?.takeRetainedValue() else { return "unknown error" }
692
+ return CFErrorCopyDescription(error) as String? ?? "unknown error"
693
+ }
694
+ }