@necessarysdk/react-native 0.0.0-stage → 0.1.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +22 -0
- package/LICENSE +201 -0
- package/NOTICE +2 -0
- package/NecessaryReactNative.podspec +23 -0
- package/README.md +429 -2
- package/android/build.gradle +83 -0
- package/android/src/main/AndroidManifest.xml +41 -0
- package/android/src/main/cpp/CMakeLists.txt +7 -0
- package/android/src/main/cpp/code_integrity.cpp +484 -0
- package/android/src/main/java/com/necessarysdk/reactnative/Causes.kt +49 -0
- package/android/src/main/java/com/necessarysdk/reactnative/InstallationKeyManager.kt +388 -0
- package/android/src/main/java/com/necessarysdk/reactnative/IntegrityProbeManager.kt +1152 -0
- package/android/src/main/java/com/necessarysdk/reactnative/KeyPresence.kt +62 -0
- package/android/src/main/java/com/necessarysdk/reactnative/NecessaryModules.kt +94 -0
- package/android/src/main/java/com/necessarysdk/reactnative/NecessaryNative.kt +323 -0
- package/android/src/main/java/com/necessarysdk/reactnative/NecessaryPackage.kt +38 -0
- package/android/src/main/java/com/necessarysdk/reactnative/ProbeReads.kt +314 -0
- package/android/src/main/java/com/necessarysdk/reactnative/ScreenGuard.kt +56 -0
- package/android/src/main/java/com/necessarysdk/reactnative/SecureStore.kt +127 -0
- package/android/src/main/java/com/necessarysdk/reactnative/StepUpKeyEntry.kt +57 -0
- package/android/src/main/java/com/necessarysdk/reactnative/StepUpKeyManager.kt +762 -0
- package/contract/report-1/android-8-ios-5/RULES.md +149 -0
- package/contract/report-1/android-8-ios-5/report.schema.json +2753 -0
- package/contract/report-1/android-8-ios-5/samples/android-8-complete.json +205 -0
- package/contract/report-1/android-8-ios-5/samples/android-8-incomplete.json +205 -0
- package/contract/report-1/android-8-ios-5/samples/android-8-invalid.json +205 -0
- package/contract/report-1/android-8-ios-5/samples/android-8-location-one-fix.json +228 -0
- package/contract/report-1/android-8-ios-5/samples/android-8-location-two-fixes.json +228 -0
- package/contract/report-1/android-8-ios-5/samples/android-8-refused.json +208 -0
- package/contract/report-1/android-8-ios-5/samples/ios-5-complete.json +106 -0
- package/contract/report-1/android-8-ios-5/samples/ios-5-incomplete.json +105 -0
- package/contract/report-1/android-8-ios-5/samples/ios-5-invalid.json +107 -0
- package/contract/report-1/android-8-ios-5/samples/ios-5-partial.json +110 -0
- package/contract/report-1/android-8-ios-5/samples/ios-5-refused.json +109 -0
- package/doc/integration_guide.md +771 -0
- package/doc/measurements.md +774 -0
- package/ios/InstallationKeyManager.swift +694 -0
- package/ios/IntegrityProbeManager.swift +960 -0
- package/ios/NecessaryModules.h +34 -0
- package/ios/NecessaryModules.mm +208 -0
- package/ios/NecessaryNative.swift +327 -0
- package/ios/PrivacyInfo.xcprivacy +14 -0
- package/ios/ProbeReads.swift +353 -0
- package/lib/client.d.ts +105 -0
- package/lib/client.js +176 -0
- package/lib/collector.d.ts +18 -0
- package/lib/collector.js +49 -0
- package/lib/deviceTrust.d.ts +253 -0
- package/lib/deviceTrust.js +690 -0
- package/lib/errors.d.ts +43 -0
- package/lib/errors.js +56 -0
- package/lib/index.d.ts +18 -0
- package/lib/index.js +34 -0
- package/lib/installationKey.d.ts +62 -0
- package/lib/installationKey.js +185 -0
- package/lib/installationStore.d.ts +49 -0
- package/lib/installationStore.js +127 -0
- package/lib/native/NativeNecessaryInstallationKey.d.ts +10 -0
- package/lib/native/NativeNecessaryInstallationKey.js +4 -0
- package/lib/native/NativeNecessaryIntegrity.d.ts +8 -0
- package/lib/native/NativeNecessaryIntegrity.js +4 -0
- package/lib/native/NativeNecessarySecureScreen.d.ts +7 -0
- package/lib/native/NativeNecessarySecureScreen.js +4 -0
- package/lib/native/NativeNecessaryStepUpKey.d.ts +9 -0
- package/lib/native/NativeNecessaryStepUpKey.js +4 -0
- package/lib/native/NativeNecessaryStorage.d.ts +14 -0
- package/lib/native/NativeNecessaryStorage.js +4 -0
- package/lib/protocol.d.ts +24 -0
- package/lib/protocol.js +50 -0
- package/lib/reinstallHint.d.ts +23 -0
- package/lib/reinstallHint.js +65 -0
- package/lib/report.d.ts +62 -0
- package/lib/report.js +140 -0
- package/lib/requestProof.d.ts +116 -0
- package/lib/requestProof.js +220 -0
- package/lib/secureScreen.d.ts +15 -0
- package/lib/secureScreen.js +32 -0
- package/lib/stepUpKey.d.ts +105 -0
- package/lib/stepUpKey.js +187 -0
- package/lib/transport.d.ts +39 -0
- package/lib/transport.js +117 -0
- package/lib/util/bytes.d.ts +18 -0
- package/lib/util/bytes.js +207 -0
- package/lib/util/native.d.ts +20 -0
- package/lib/util/native.js +49 -0
- package/lib/util/release.d.ts +2 -0
- package/lib/util/release.js +7 -0
- package/lib/util/target.d.ts +18 -0
- package/lib/util/target.js +128 -0
- package/package.json +81 -4
- package/src/client.ts +221 -0
- package/src/collector.ts +64 -0
- package/src/deviceTrust.ts +842 -0
- package/src/errors.ts +71 -0
- package/src/index.ts +19 -0
- package/src/installationKey.ts +223 -0
- package/src/installationStore.ts +143 -0
- package/src/native/NativeNecessaryInstallationKey.ts +12 -0
- package/src/native/NativeNecessaryIntegrity.ts +15 -0
- package/src/native/NativeNecessarySecureScreen.ts +9 -0
- package/src/native/NativeNecessaryStepUpKey.ts +11 -0
- package/src/native/NativeNecessaryStorage.ts +16 -0
- package/src/protocol.ts +52 -0
- package/src/reinstallHint.ts +69 -0
- package/src/report.ts +174 -0
- package/src/requestProof.ts +295 -0
- package/src/secureScreen.ts +29 -0
- package/src/stepUpKey.ts +238 -0
- package/src/transport.ts +145 -0
- package/src/util/bytes.ts +194 -0
- package/src/util/native.ts +56 -0
- package/src/util/release.ts +6 -0
- package/src/util/target.ts +132 -0
- 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
|
+
}
|