@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,767 @@
1
+ package com.necessarysdk.reactnative
2
+
3
+ import android.app.Activity
4
+ import android.app.KeyguardManager
5
+ import android.content.Context
6
+ import android.content.pm.PackageManager
7
+ import android.hardware.biometrics.BiometricManager
8
+ import android.hardware.biometrics.BiometricPrompt
9
+ import android.os.Build
10
+ import android.os.CancellationSignal
11
+ import android.security.keystore.KeyGenParameterSpec
12
+ import android.security.keystore.KeyInfo
13
+ import android.security.keystore.KeyPermanentlyInvalidatedException
14
+ import android.security.keystore.KeyProperties
15
+ import android.security.keystore.UserNotAuthenticatedException
16
+ import android.util.Base64
17
+ import java.math.BigInteger
18
+ import java.security.GeneralSecurityException
19
+ import java.security.KeyFactory
20
+ import java.security.KeyPairGenerator
21
+ import java.security.KeyStore
22
+ import java.security.Signature
23
+ import java.security.UnrecoverableKeyException
24
+ import java.security.interfaces.ECPublicKey
25
+ import java.security.spec.ECGenParameterSpec
26
+
27
+ /**
28
+ * Owns the OPTIONAL step-up key: a second non-exportable P-256 key in
29
+ * AndroidKeyStore that cannot sign without a device authentication
30
+ * key approval.
31
+ *
32
+ * The installation key stays unattended for routine proof of possession. This
33
+ * key approves only sensitive operations, so a compromised app process cannot
34
+ * drive it as a silent signing oracle for them.
35
+ *
36
+ * How tightly the hardware binds the authentication depends on the API level
37
+ * level:
38
+ *
39
+ * * Android 11+ (API 30): per-use. Each signature is authorised by its own
40
+ * BiometricPrompt, device credential (passcode) by default.
41
+ * * Android 9/10 with the passcode factor: before API 30 Keystore cannot bind
42
+ * a device credential to one operation, so the key gets a short
43
+ * hardware-enforced validity window. The SDK still asks for the passcode
44
+ * before every signature; the window is the backstop, and it is reported to
45
+ * the server as mode "windowed" so the downgrade is visible, not silent.
46
+ *
47
+ * The factor, mode and window reported to JavaScript are read back from KeyInfo --
48
+ * what the keystore actually enforces -- rather than echoed from the request.
49
+ */
50
+ class StepUpKeyManager(private val context: Context) {
51
+ companion object {
52
+ private const val ANDROID_KEY_STORE = "AndroidKeyStore"
53
+ private const val CURVE_NAME = "secp256r1"
54
+ private const val MAX_PAYLOAD_BYTES = 65536
55
+ private const val MAX_WINDOW_SECONDS = 3600
56
+ private const val CONFIRM_CREDENTIAL_REQUEST = 0x5354 // "ST"
57
+
58
+ const val FACTOR_PASSCODE = "passcode"
59
+ const val FACTOR_BIOMETRIC = "biometric"
60
+ const val MODE_PER_USE = "per_use"
61
+ const val MODE_WINDOWED = "windowed"
62
+
63
+ // The hardware window used where per-use passcode is not available
64
+ // (API < 30).
65
+ const val LEGACY_WINDOW_SECONDS = 30
66
+
67
+ // Making and deleting the key run one at a time in the process,
68
+ // whichever runtime asks (as for the installation key).
69
+ private val keyLock = Any()
70
+ }
71
+
72
+ private class KeyAuth(val factor: String, val mode: String, val windowSeconds: Int)
73
+
74
+ private class PendingConfirm(
75
+ val entry: KeyStore.PrivateKeyEntry,
76
+ val payload: ByteArray,
77
+ val done: (Result<String>) -> Unit,
78
+ )
79
+
80
+ private val keyAlias: String =
81
+ "${context.packageName}.device_recognition.stepup_key.v1"
82
+
83
+ // Never holds a key: deleting it says whether the keystore answers
84
+ // (KeyPresence).
85
+ private val probeAlias: String = "$keyAlias.probe"
86
+
87
+ private var pendingConfirm: PendingConfirm? = null
88
+
89
+ // MARK: module operations
90
+
91
+ fun getOrCreateKey(factor: String, mode: String, windowSeconds: Int): Map<String, Any> =
92
+ synchronized(keyLock) { loadOrMakeKey(factor, mode, windowSeconds) }
93
+
94
+ private fun loadOrMakeKey(factor: String, mode: String, windowSeconds: Int): Map<String, Any> {
95
+ requireSupportedAndroid()
96
+ if (factor != FACTOR_PASSCODE && factor != FACTOR_BIOMETRIC) {
97
+ throw InstallationKeyFailure("INVALID_ARGUMENT", "factor must be passcode or biometric.")
98
+ }
99
+ if (mode != MODE_PER_USE && mode != MODE_WINDOWED) {
100
+ throw InstallationKeyFailure("INVALID_ARGUMENT", "mode must be per_use or windowed.")
101
+ }
102
+ if (mode == MODE_WINDOWED && windowSeconds !in 1..MAX_WINDOW_SECONDS) {
103
+ throw InstallationKeyFailure(
104
+ "INVALID_ARGUMENT",
105
+ "A windowed step-up key needs window_seconds between 1 and $MAX_WINDOW_SECONDS.",
106
+ )
107
+ }
108
+ // An existing key is checked before the screen lock: removing the lock
109
+ // is what kills it, so this is where the app can say so precisely. A
110
+ // key is made only where the keystore, answering, holds none: a lookup
111
+ // that failed reads as "no key", and generating would replace the key
112
+ // the backend holds.
113
+ val keyStore = loadKeyStore()
114
+ val found = presence(keyStore)
115
+ val existing = when (found.answer) {
116
+ KeyPresence.Answer.PRESENT -> liveEntry(keyStore)
117
+ KeyPresence.Answer.ABSENT -> null
118
+ KeyPresence.Answer.UNAVAILABLE -> throw keyUnavailable(found.cause)
119
+ }
120
+ requireDeviceCredential()
121
+ if (factor == FACTOR_BIOMETRIC && Build.VERSION.SDK_INT < Build.VERSION_CODES.P) {
122
+ throw InstallationKeyFailure(
123
+ "STEPUP_UNSUPPORTED",
124
+ "Biometric step-up needs Android 9 (API 28) or newer.",
125
+ )
126
+ }
127
+
128
+ val created = existing == null
129
+ if (created) {
130
+ generateKeyPair(keyStore, factor, mode, windowSeconds)
131
+ }
132
+ val entry = existing ?: liveEntry(keyStore)
133
+ val publicKey = entry.certificate.publicKey as? ECPublicKey
134
+ ?: throw InstallationKeyFailure(
135
+ "INVALID_PUBLIC_KEY",
136
+ "AndroidKeyStore did not return an EC public key for the step-up key.",
137
+ )
138
+ val info = keyInfo(entry)
139
+ val auth = describeAuth(info)
140
+ val security = describeSecurity(info)
141
+
142
+ return mapOf(
143
+ "key_alias" to keyAlias,
144
+ "algorithm" to "ES256",
145
+ "signature_format" to "asn1_der",
146
+ "provider" to ANDROID_KEY_STORE,
147
+ "security_level" to security.first,
148
+ "hardware_backed" to security.second,
149
+ "private_key_exportable" to false,
150
+ "created" to created,
151
+ "public_key" to mapOf(
152
+ "kty" to "EC",
153
+ "crv" to "P-256",
154
+ "alg" to "ES256",
155
+ "x" to base64Url(unsignedFixed(publicKey.w.affineX, 32)),
156
+ "y" to base64Url(unsignedFixed(publicKey.w.affineY, 32)),
157
+ ),
158
+ "auth" to mapOf(
159
+ "factor" to auth.factor,
160
+ "mode" to auth.mode,
161
+ "window_seconds" to auth.windowSeconds,
162
+ ),
163
+ )
164
+ }
165
+
166
+ /**
167
+ * Prompts for the device authentication, then signs. Must be called on the
168
+ * main thread; [done] is invoked on the main thread exactly once.
169
+ *
170
+ * [configuredMode] is the app's setting. A windowed key the app asked to be
171
+ * per-use (the API < 30 fallback) prompts every time; a key the app asked
172
+ * to be windowed signs without a prompt while its hardware window is open.
173
+ */
174
+ fun sign(
175
+ activity: Activity,
176
+ payloadBase64Url: String,
177
+ reason: String,
178
+ configuredMode: String,
179
+ done: (Result<String>) -> Unit,
180
+ ) {
181
+ try {
182
+ requireSupportedAndroid()
183
+ if (pendingConfirm != null) {
184
+ throw InstallationKeyFailure(
185
+ "STEPUP_BUSY",
186
+ "A step-up authentication is already in progress.",
187
+ )
188
+ }
189
+ val payload = decodeBase64Url(payloadBase64Url)
190
+ if (payload.size > MAX_PAYLOAD_BYTES) {
191
+ throw InstallationKeyFailure(
192
+ "PAYLOAD_TOO_LARGE",
193
+ "The step-up payload is larger than $MAX_PAYLOAD_BYTES bytes.",
194
+ )
195
+ }
196
+ val keyStore = loadKeyStore()
197
+ val found = presence(keyStore)
198
+ when (found.answer) {
199
+ KeyPresence.Answer.PRESENT -> {}
200
+ KeyPresence.Answer.ABSENT -> throw InstallationKeyFailure(
201
+ "STEPUP_KEY_NOT_FOUND",
202
+ "No step-up key exists on this installation.",
203
+ )
204
+ KeyPresence.Answer.UNAVAILABLE -> throw keyUnavailable(found.cause)
205
+ }
206
+ val entry = liveEntry(keyStore)
207
+ val auth = describeAuth(keyInfo(entry))
208
+
209
+ if (auth.mode == MODE_PER_USE) {
210
+ val signer = Signature.getInstance("SHA256withECDSA")
211
+ signer.initSign(entry.privateKey)
212
+ authenticateOperation(activity, signer, auth.factor, reason, payload, done)
213
+ return
214
+ }
215
+
216
+ if (configuredMode == MODE_WINDOWED) {
217
+ try {
218
+ done(Result.success(signNow(entry, payload)))
219
+ return
220
+ } catch (_: UserNotAuthenticatedException) {
221
+ // The window is closed; authenticate below and sign.
222
+ }
223
+ }
224
+ authenticateThenSign(activity, entry, auth.factor, reason, payload, done)
225
+ } catch (error: KeyPermanentlyInvalidatedException) {
226
+ done(Result.failure(discardDeadKey(error)))
227
+ } catch (error: InstallationKeyFailure) {
228
+ done(Result.failure(error))
229
+ } catch (error: GeneralSecurityException) {
230
+ done(
231
+ Result.failure(
232
+ InstallationKeyFailure(
233
+ "STEPUP_SIGNING_FAILED",
234
+ "AndroidKeyStore could not prepare the step-up signature.",
235
+ error,
236
+ ),
237
+ ),
238
+ )
239
+ }
240
+ }
241
+
242
+ /** Delivers the legacy confirm-credential result. True if it was ours. */
243
+ fun onActivityResult(requestCode: Int, resultCode: Int): Boolean {
244
+ if (requestCode != CONFIRM_CREDENTIAL_REQUEST) {
245
+ return false
246
+ }
247
+ val pending = pendingConfirm ?: return true
248
+ pendingConfirm = null
249
+ if (resultCode == Activity.RESULT_OK) {
250
+ signAndReport(pending.entry, pending.payload, pending.done)
251
+ } else {
252
+ pending.done(
253
+ Result.failure(
254
+ InstallationKeyFailure("STEPUP_CANCELLED", "The passcode prompt was cancelled."),
255
+ ),
256
+ )
257
+ }
258
+ return true
259
+ }
260
+
261
+ /**
262
+ * Deletes the key, whatever a lookup says: a lookup that failed reads as
263
+ * "no key", and skipping the deletion then would leave the key in place
264
+ * after a reset. Whether one was there is for the answer only.
265
+ */
266
+ fun deleteKey(): Boolean = synchronized(keyLock) {
267
+ requireSupportedAndroid()
268
+ val keyStore = loadKeyStore()
269
+ try {
270
+ val existed = keyStore.containsAlias(keyAlias)
271
+ keyStore.deleteEntry(keyAlias)
272
+ if (keyStore.containsAlias(keyAlias)) {
273
+ throw InstallationKeyFailure(
274
+ "KEY_DELETE_FAILED",
275
+ "AndroidKeyStore did not delete the step-up key: it is still present.",
276
+ )
277
+ }
278
+ existed
279
+ } catch (error: GeneralSecurityException) {
280
+ throw InstallationKeyFailure(
281
+ "KEY_DELETE_FAILED",
282
+ "AndroidKeyStore could not delete the step-up key.",
283
+ error,
284
+ )
285
+ }
286
+ }
287
+
288
+ // MARK: authentication
289
+
290
+ /** Per-use: the prompt authorises this one Signature object. */
291
+ private fun authenticateOperation(
292
+ activity: Activity,
293
+ signer: Signature,
294
+ factor: String,
295
+ reason: String,
296
+ payload: ByteArray,
297
+ done: (Result<String>) -> Unit,
298
+ ) {
299
+ val prompt = buildPrompt(activity, factor, reason, done)
300
+ prompt.authenticate(
301
+ BiometricPrompt.CryptoObject(signer),
302
+ CancellationSignal(),
303
+ activity.mainExecutor,
304
+ object : BiometricPrompt.AuthenticationCallback() {
305
+ override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
306
+ val authorised = result.cryptoObject?.signature ?: signer
307
+ // Any failure here is told, a keystore's ProviderException
308
+ // among them: unanswered, this runs on the main thread, and
309
+ // an exception would end the app with the approval waiting.
310
+ val outcome: Result<String> = try {
311
+ authorised.update(payload)
312
+ Result.success(base64Url(authorised.sign()))
313
+ } catch (error: Exception) {
314
+ Result.failure(signingFailed(error))
315
+ }
316
+ done(outcome)
317
+ }
318
+
319
+ override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
320
+ done(Result.failure(promptError(errorCode, errString)))
321
+ }
322
+ },
323
+ )
324
+ }
325
+
326
+ /** Windowed: authenticate first, then sign inside the hardware window. */
327
+ private fun authenticateThenSign(
328
+ activity: Activity,
329
+ entry: KeyStore.PrivateKeyEntry,
330
+ factor: String,
331
+ reason: String,
332
+ payload: ByteArray,
333
+ done: (Result<String>) -> Unit,
334
+ ) {
335
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
336
+ val prompt = buildPrompt(activity, factor, reason, done)
337
+ prompt.authenticate(
338
+ CancellationSignal(),
339
+ activity.mainExecutor,
340
+ object : BiometricPrompt.AuthenticationCallback() {
341
+ override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
342
+ signAndReport(entry, payload, done)
343
+ }
344
+
345
+ override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
346
+ done(Result.failure(promptError(errorCode, errString)))
347
+ }
348
+ },
349
+ )
350
+ return
351
+ }
352
+
353
+ // API < 30: the system confirm-credential screen (PIN / pattern /
354
+ // password). A successful confirmation opens the key's window.
355
+ val keyguard = context.getSystemService(KeyguardManager::class.java)
356
+ @Suppress("DEPRECATION")
357
+ val intent = keyguard?.createConfirmDeviceCredentialIntent(
358
+ "Approve sensitive operation",
359
+ reason,
360
+ ) ?: throw InstallationKeyFailure(
361
+ "STEPUP_NO_DEVICE_CREDENTIAL",
362
+ "Set a screen lock (PIN, pattern or password) to use step-up.",
363
+ )
364
+ pendingConfirm = PendingConfirm(entry, payload, done)
365
+ try {
366
+ activity.startActivityForResult(intent, CONFIRM_CREDENTIAL_REQUEST)
367
+ } catch (error: RuntimeException) {
368
+ // The confirmation never opened: nothing may wait for it, or every
369
+ // later approval would answer STEPUP_BUSY. The caller says why.
370
+ pendingConfirm = null
371
+ throw error
372
+ }
373
+ }
374
+
375
+ /**
376
+ * The activity that showed the confirm-credential screen is gone for good
377
+ * (not recreated for a configuration change): its answer will never come,
378
+ * so the approval ends as a cancelled one, and the next can be asked.
379
+ */
380
+ fun cancelPendingConfirmation() {
381
+ val pending = pendingConfirm ?: return
382
+ pendingConfirm = null
383
+ pending.done(
384
+ Result.failure(
385
+ InstallationKeyFailure(
386
+ "STEPUP_CANCELLED",
387
+ "The passcode prompt was cancelled: the screen that asked for it is gone.",
388
+ ),
389
+ ),
390
+ )
391
+ }
392
+
393
+ private fun buildPrompt(
394
+ activity: Activity,
395
+ factor: String,
396
+ reason: String,
397
+ done: (Result<String>) -> Unit,
398
+ ): BiometricPrompt {
399
+ val builder = BiometricPrompt.Builder(activity)
400
+ .setTitle("Approve sensitive operation")
401
+ .setDescription(reason)
402
+ val allowsCredential = factor == FACTOR_PASSCODE &&
403
+ Build.VERSION.SDK_INT >= Build.VERSION_CODES.R
404
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
405
+ builder.setAllowedAuthenticators(
406
+ if (allowsCredential) {
407
+ BiometricManager.Authenticators.DEVICE_CREDENTIAL
408
+ } else {
409
+ BiometricManager.Authenticators.BIOMETRIC_STRONG
410
+ },
411
+ )
412
+ }
413
+ if (!allowsCredential) {
414
+ // A biometric-only prompt must offer its own way out; a
415
+ // device-credential prompt must not (the system provides one).
416
+ builder.setNegativeButton("Cancel", activity.mainExecutor) { _, _ ->
417
+ done(
418
+ Result.failure(
419
+ InstallationKeyFailure("STEPUP_CANCELLED", "The biometric prompt was cancelled."),
420
+ ),
421
+ )
422
+ }
423
+ }
424
+ return builder.build()
425
+ }
426
+
427
+ private fun promptError(errorCode: Int, errString: CharSequence): InstallationKeyFailure {
428
+ val cancelled = errorCode == BiometricPrompt.BIOMETRIC_ERROR_USER_CANCELED ||
429
+ errorCode == BiometricPrompt.BIOMETRIC_ERROR_CANCELED
430
+ return InstallationKeyFailure(
431
+ if (cancelled) "STEPUP_CANCELLED" else "STEPUP_AUTH_FAILED",
432
+ "Step-up authentication did not complete: $errString (code $errorCode)",
433
+ )
434
+ }
435
+
436
+ // MARK: signing
437
+
438
+ private fun signNow(entry: KeyStore.PrivateKeyEntry, payload: ByteArray): String {
439
+ val signer = Signature.getInstance("SHA256withECDSA")
440
+ signer.initSign(entry.privateKey)
441
+ signer.update(payload)
442
+ return base64Url(signer.sign())
443
+ }
444
+
445
+ private fun signAndReport(
446
+ entry: KeyStore.PrivateKeyEntry,
447
+ payload: ByteArray,
448
+ done: (Result<String>) -> Unit,
449
+ ) {
450
+ // Runs on the main thread after the person answered: every failure is
451
+ // told, as in authenticateOperation.
452
+ val outcome: Result<String> = try {
453
+ Result.success(signNow(entry, payload))
454
+ } catch (error: KeyPermanentlyInvalidatedException) {
455
+ Result.failure(discardDeadKey(error))
456
+ } catch (error: UserNotAuthenticatedException) {
457
+ Result.failure(
458
+ InstallationKeyFailure(
459
+ "STEPUP_AUTH_FAILED",
460
+ "The keystore did not accept the authentication for the step-up key.",
461
+ error,
462
+ ),
463
+ )
464
+ } catch (error: Exception) {
465
+ Result.failure(signingFailed(error))
466
+ }
467
+ done(outcome)
468
+ }
469
+
470
+ /**
471
+ * Deletes a step-up key that can never sign again and reports it. The
472
+ * installation must then re-enrol: the server never accepts a replacement
473
+ * step-up key for an existing installation.
474
+ */
475
+ private fun discardDeadKey(cause: Throwable): InstallationKeyFailure = synchronized(keyLock) {
476
+ // Say whether the removal actually happened: on an Android 9 phone a
477
+ // dead key's alias survived deleteEntry while the screen lock was off.
478
+ val removal = try {
479
+ val keyStore = loadKeyStore()
480
+ keyStore.deleteEntry(keyAlias)
481
+ if (keyStore.containsAlias(keyAlias)) "could not be removed (alias still present)" else "has been removed"
482
+ } catch (error: Exception) {
483
+ // Not the key's cause: the removal's own failure, said in full here.
484
+ "could not be removed (${Causes.chain(error)})"
485
+ }
486
+ InstallationKeyFailure(
487
+ "STEPUP_KEY_INVALIDATED",
488
+ "The step-up key was invalidated (the screen lock or enrolled biometrics changed) " +
489
+ "and $removal. Re-enrol the installation to get a new one.",
490
+ cause,
491
+ )
492
+ }
493
+
494
+ private fun signingFailed(error: Throwable) = InstallationKeyFailure(
495
+ "STEPUP_SIGNING_FAILED",
496
+ "AndroidKeyStore could not sign with the step-up key.",
497
+ error,
498
+ )
499
+
500
+ // MARK: key material
501
+
502
+ private fun generateKeyPair(
503
+ keyStore: KeyStore,
504
+ factor: String,
505
+ mode: String,
506
+ windowSeconds: Int,
507
+ ) {
508
+ val canTryStrongBox = Build.VERSION.SDK_INT >= Build.VERSION_CODES.P &&
509
+ context.packageManager.hasSystemFeature(PackageManager.FEATURE_STRONGBOX_KEYSTORE)
510
+ if (canTryStrongBox) {
511
+ try {
512
+ generateKeyPair(factor, mode, windowSeconds, preferStrongBox = true)
513
+ return
514
+ } catch (_: Exception) {
515
+ // Same fallback as the installation key: remove any partial
516
+ // alias and retry with the regular AndroidKeyStore provider.
517
+ try {
518
+ if (keyStore.containsAlias(keyAlias)) {
519
+ keyStore.deleteEntry(keyAlias)
520
+ }
521
+ } catch (_: Exception) {
522
+ }
523
+ }
524
+ }
525
+ generateKeyPair(factor, mode, windowSeconds, preferStrongBox = false)
526
+ }
527
+
528
+ private fun generateKeyPair(
529
+ factor: String,
530
+ mode: String,
531
+ windowSeconds: Int,
532
+ preferStrongBox: Boolean,
533
+ ) {
534
+ try {
535
+ val builder = KeyGenParameterSpec.Builder(keyAlias, KeyProperties.PURPOSE_SIGN)
536
+ .setAlgorithmParameterSpec(ECGenParameterSpec(CURVE_NAME))
537
+ .setDigests(KeyProperties.DIGEST_SHA256)
538
+ .setUserAuthenticationRequired(true)
539
+
540
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
541
+ builder.setUserAuthenticationParameters(
542
+ if (mode == MODE_WINDOWED) windowSeconds else 0,
543
+ if (factor == FACTOR_BIOMETRIC) {
544
+ KeyProperties.AUTH_BIOMETRIC_STRONG
545
+ } else {
546
+ KeyProperties.AUTH_DEVICE_CREDENTIAL
547
+ },
548
+ )
549
+ } else if (factor == FACTOR_PASSCODE) {
550
+ // No per-operation device credential before API 30, so a short
551
+ // hardware window is the strongest binding.
552
+ @Suppress("DEPRECATION")
553
+ builder.setUserAuthenticationValidityDurationSeconds(
554
+ if (mode == MODE_WINDOWED) windowSeconds else LEGACY_WINDOW_SECONDS,
555
+ )
556
+ } else if (mode == MODE_WINDOWED) {
557
+ @Suppress("DEPRECATION")
558
+ builder.setUserAuthenticationValidityDurationSeconds(windowSeconds)
559
+ }
560
+ // else: biometric per-use before API 30 keeps the default (-1),
561
+ // which demands a biometric for every operation.
562
+
563
+ if (factor == FACTOR_BIOMETRIC && Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
564
+ builder.setInvalidatedByBiometricEnrollment(true)
565
+ }
566
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P && preferStrongBox) {
567
+ builder.setIsStrongBoxBacked(true)
568
+ }
569
+
570
+ val generator = KeyPairGenerator.getInstance(
571
+ KeyProperties.KEY_ALGORITHM_EC,
572
+ ANDROID_KEY_STORE,
573
+ )
574
+ generator.initialize(builder.build())
575
+ generator.generateKeyPair()
576
+ } catch (error: Exception) {
577
+ throw InstallationKeyFailure(
578
+ "STEPUP_KEY_GENERATION_FAILED",
579
+ "AndroidKeyStore could not create the step-up key.",
580
+ error,
581
+ )
582
+ }
583
+ }
584
+
585
+ private fun requireSupportedAndroid() {
586
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) {
587
+ throw InstallationKeyFailure(
588
+ "UNSUPPORTED_ANDROID_VERSION",
589
+ "Step-up requires Android 6.0 (API 23) or newer.",
590
+ )
591
+ }
592
+ }
593
+
594
+ private fun requireDeviceCredential() {
595
+ val keyguard = context.getSystemService(KeyguardManager::class.java)
596
+ if (keyguard == null || !keyguard.isDeviceSecure) {
597
+ throw InstallationKeyFailure(
598
+ "STEPUP_NO_DEVICE_CREDENTIAL",
599
+ "Set a screen lock (PIN, pattern or password) to enable step-up.",
600
+ )
601
+ }
602
+ }
603
+
604
+ private fun loadKeyStore(): KeyStore {
605
+ try {
606
+ return KeyStore.getInstance(ANDROID_KEY_STORE).apply { load(null) }
607
+ } catch (error: GeneralSecurityException) {
608
+ throw InstallationKeyFailure("KEYSTORE_UNAVAILABLE", "AndroidKeyStore is unavailable.", error)
609
+ }
610
+ }
611
+
612
+ /**
613
+ * Loads the stored step-up key. Removing the screen lock (or, for a
614
+ * biometric key, changing the enrolled biometrics) invalidates an
615
+ * auth-bound key: Android says so when a signature starts
616
+ * (KeyPermanentlyInvalidatedException), and only then is the key deleted
617
+ * and STEPUP_KEY_INVALIDATED reported. A lookup that cannot establish
618
+ * whether the key is usable keeps it: STEPUP_KEY_UNAVAILABLE, retryable;
619
+ * any other keystore failure: KEY_LOOKUP_FAILED, the key kept
620
+ * (StepUpKeyEntry decides).
621
+ */
622
+ private fun liveEntry(keyStore: KeyStore): KeyStore.PrivateKeyEntry {
623
+ // What the lookups find is decided by StepUpKeyEntry: only Android's
624
+ // own word that the key is dead deletes it. An alias that outlived its
625
+ // private key on an Android 9 phone (isKeyEntry false, getEntry
626
+ // throwing UnsupportedOperationException) used to be deleted here, as
627
+ // did every lookup that could not say -- on Android 9 a binder error
628
+ // also reads as isKeyEntry false. Those keys are now kept.
629
+ var entry: KeyStore.PrivateKeyEntry? = null
630
+ var cause: Throwable? = null
631
+ val lookup = if (!keyStore.isKeyEntry(keyAlias)) {
632
+ StepUpKeyEntry.Lookup.NOT_KEY_ENTRY
633
+ } else {
634
+ try {
635
+ entry = keyStore.getEntry(keyAlias, null) as? KeyStore.PrivateKeyEntry
636
+ if (entry == null) StepUpKeyEntry.Lookup.NOT_PRIVATE_KEY else StepUpKeyEntry.Lookup.PRIVATE_KEY
637
+ } catch (error: UnrecoverableKeyException) {
638
+ cause = error
639
+ StepUpKeyEntry.Lookup.UNRECOVERABLE
640
+ } catch (error: UnsupportedOperationException) {
641
+ cause = error
642
+ StepUpKeyEntry.Lookup.UNSUPPORTED
643
+ } catch (error: GeneralSecurityException) {
644
+ cause = error
645
+ StepUpKeyEntry.Lookup.OTHER_FAILURE
646
+ }
647
+ }
648
+ var start = StepUpKeyEntry.SignStart.NOT_TRIED
649
+ val loaded = entry
650
+ if (loaded != null) {
651
+ start = try {
652
+ Signature.getInstance("SHA256withECDSA").initSign(loaded.privateKey)
653
+ StepUpKeyEntry.SignStart.STARTED
654
+ } catch (error: KeyPermanentlyInvalidatedException) {
655
+ cause = error
656
+ StepUpKeyEntry.SignStart.PERMANENTLY_INVALIDATED
657
+ } catch (_: UserNotAuthenticatedException) {
658
+ StepUpKeyEntry.SignStart.NEEDS_AUTHENTICATION
659
+ }
660
+ }
661
+ return when (StepUpKeyEntry.verdict(lookup, start)) {
662
+ StepUpKeyEntry.Verdict.USABLE -> loaded!!
663
+ StepUpKeyEntry.Verdict.INVALIDATED -> throw discardDeadKey(cause!!)
664
+ StepUpKeyEntry.Verdict.UNAVAILABLE -> throw InstallationKeyFailure(
665
+ "STEPUP_KEY_UNAVAILABLE",
666
+ "AndroidKeyStore could not establish whether the step-up key is usable " +
667
+ "(lookup: ${lookup.name.lowercase()}); the key was kept. Try again; if this " +
668
+ "persists, re-enrol to replace it.",
669
+ cause,
670
+ )
671
+ StepUpKeyEntry.Verdict.LOOKUP_FAILED -> throw InstallationKeyFailure(
672
+ "KEY_LOOKUP_FAILED",
673
+ "AndroidKeyStore could not load the step-up key.",
674
+ cause,
675
+ )
676
+ }
677
+ }
678
+
679
+ private fun presence(keyStore: KeyStore): KeyPresence.Found =
680
+ KeyPresence.of(object : KeyPresence.Keystore {
681
+ override fun containsKey() = keyStore.containsAlias(keyAlias)
682
+ override fun deleteProbe() = keyStore.deleteEntry(probeAlias)
683
+ })
684
+
685
+ private fun keyUnavailable(cause: Throwable?) = InstallationKeyFailure(
686
+ "STEPUP_KEY_UNAVAILABLE",
687
+ "AndroidKeyStore could not be reached for the step-up key; the key was kept, never " +
688
+ "replaced. Try again; if this persists, re-enrol to replace it.",
689
+ cause,
690
+ )
691
+
692
+ private fun keyInfo(entry: KeyStore.PrivateKeyEntry): KeyInfo {
693
+ try {
694
+ val keyFactory = KeyFactory.getInstance(entry.privateKey.algorithm, ANDROID_KEY_STORE)
695
+ return keyFactory.getKeySpec(entry.privateKey, KeyInfo::class.java)
696
+ } catch (error: GeneralSecurityException) {
697
+ throw InstallationKeyFailure(
698
+ "KEY_LOOKUP_FAILED",
699
+ "AndroidKeyStore could not describe the step-up key.",
700
+ error,
701
+ )
702
+ }
703
+ }
704
+
705
+ /** What the keystore enforces for this key, read back from KeyInfo. */
706
+ private fun describeAuth(info: KeyInfo): KeyAuth {
707
+ val duration = info.userAuthenticationValidityDurationSeconds
708
+ val mode = if (duration > 0) MODE_WINDOWED else MODE_PER_USE
709
+ val factor = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
710
+ if (info.userAuthenticationType and KeyProperties.AUTH_DEVICE_CREDENTIAL != 0) {
711
+ FACTOR_PASSCODE
712
+ } else {
713
+ FACTOR_BIOMETRIC
714
+ }
715
+ } else {
716
+ // KeyInfo has no auth type before API 30. A per-use key can then
717
+ // only be unlocked by a biometric; a windowed key by any lock-screen
718
+ // authentication, and the SDK prompts for the passcode.
719
+ if (mode == MODE_PER_USE) FACTOR_BIOMETRIC else FACTOR_PASSCODE
720
+ }
721
+ return KeyAuth(factor, mode, if (duration > 0) duration else 0)
722
+ }
723
+
724
+ private fun describeSecurity(info: KeyInfo): Pair<String, Boolean> {
725
+ return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
726
+ when (info.securityLevel) {
727
+ KeyProperties.SECURITY_LEVEL_STRONGBOX -> "strongbox" to true
728
+ KeyProperties.SECURITY_LEVEL_TRUSTED_ENVIRONMENT -> "trusted_execution_environment" to true
729
+ KeyProperties.SECURITY_LEVEL_SOFTWARE -> "software" to false
730
+ else -> "unknown" to false
731
+ }
732
+ } else {
733
+ @Suppress("DEPRECATION")
734
+ if (info.isInsideSecureHardware) "secure_hardware" to true else "software" to false
735
+ }
736
+ }
737
+
738
+ // MARK: encoding (mirrors InstallationKeyManager)
739
+
740
+ private fun unsignedFixed(value: BigInteger, size: Int): ByteArray {
741
+ var raw = value.toByteArray()
742
+ if (raw.size == size + 1 && raw[0] == 0.toByte()) {
743
+ raw = raw.copyOfRange(1, raw.size)
744
+ }
745
+ if (raw.size > size) {
746
+ throw InstallationKeyFailure("INVALID_PUBLIC_KEY", "The EC coordinate is larger than P-256 permits.")
747
+ }
748
+ return ByteArray(size).also { output ->
749
+ System.arraycopy(raw, 0, output, size - raw.size, raw.size)
750
+ }
751
+ }
752
+
753
+ private fun base64Url(value: ByteArray): String {
754
+ return Base64.encodeToString(value, Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING)
755
+ }
756
+
757
+ private fun decodeBase64Url(value: String): ByteArray {
758
+ if (value.isBlank()) {
759
+ throw InstallationKeyFailure("INVALID_PAYLOAD", "The step-up payload is empty.")
760
+ }
761
+ return try {
762
+ Base64.decode(value, Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING)
763
+ } catch (error: IllegalArgumentException) {
764
+ throw InstallationKeyFailure("INVALID_PAYLOAD", "The step-up payload is not valid base64url.", error)
765
+ }
766
+ }
767
+ }