@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.
Files changed (114) hide show
  1. package/CHANGELOG.md +22 -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 +1152 -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 +323 -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 +314 -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 +762 -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 +960 -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 +353 -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 +690 -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 +140 -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/bytes.d.ts +18 -0
  83. package/lib/util/bytes.js +207 -0
  84. package/lib/util/native.d.ts +20 -0
  85. package/lib/util/native.js +49 -0
  86. package/lib/util/release.d.ts +2 -0
  87. package/lib/util/release.js +7 -0
  88. package/lib/util/target.d.ts +18 -0
  89. package/lib/util/target.js +128 -0
  90. package/package.json +81 -4
  91. package/src/client.ts +221 -0
  92. package/src/collector.ts +64 -0
  93. package/src/deviceTrust.ts +842 -0
  94. package/src/errors.ts +71 -0
  95. package/src/index.ts +19 -0
  96. package/src/installationKey.ts +223 -0
  97. package/src/installationStore.ts +143 -0
  98. package/src/native/NativeNecessaryInstallationKey.ts +12 -0
  99. package/src/native/NativeNecessaryIntegrity.ts +15 -0
  100. package/src/native/NativeNecessarySecureScreen.ts +9 -0
  101. package/src/native/NativeNecessaryStepUpKey.ts +11 -0
  102. package/src/native/NativeNecessaryStorage.ts +16 -0
  103. package/src/protocol.ts +52 -0
  104. package/src/reinstallHint.ts +69 -0
  105. package/src/report.ts +174 -0
  106. package/src/requestProof.ts +295 -0
  107. package/src/secureScreen.ts +29 -0
  108. package/src/stepUpKey.ts +238 -0
  109. package/src/transport.ts +145 -0
  110. package/src/util/bytes.ts +194 -0
  111. package/src/util/native.ts +56 -0
  112. package/src/util/release.ts +6 -0
  113. package/src/util/target.ts +132 -0
  114. package/vectors/client_server.json +264 -0
@@ -0,0 +1,762 @@
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
+ try {
308
+ authorised.update(payload)
309
+ done(Result.success(base64Url(authorised.sign())))
310
+ } catch (error: GeneralSecurityException) {
311
+ done(Result.failure(signingFailed(error)))
312
+ }
313
+ }
314
+
315
+ override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
316
+ done(Result.failure(promptError(errorCode, errString)))
317
+ }
318
+ },
319
+ )
320
+ }
321
+
322
+ /** Windowed: authenticate first, then sign inside the hardware window. */
323
+ private fun authenticateThenSign(
324
+ activity: Activity,
325
+ entry: KeyStore.PrivateKeyEntry,
326
+ factor: String,
327
+ reason: String,
328
+ payload: ByteArray,
329
+ done: (Result<String>) -> Unit,
330
+ ) {
331
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
332
+ val prompt = buildPrompt(activity, factor, reason, done)
333
+ prompt.authenticate(
334
+ CancellationSignal(),
335
+ activity.mainExecutor,
336
+ object : BiometricPrompt.AuthenticationCallback() {
337
+ override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
338
+ signAndReport(entry, payload, done)
339
+ }
340
+
341
+ override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
342
+ done(Result.failure(promptError(errorCode, errString)))
343
+ }
344
+ },
345
+ )
346
+ return
347
+ }
348
+
349
+ // API < 30: the system confirm-credential screen (PIN / pattern /
350
+ // password). A successful confirmation opens the key's window.
351
+ val keyguard = context.getSystemService(KeyguardManager::class.java)
352
+ @Suppress("DEPRECATION")
353
+ val intent = keyguard?.createConfirmDeviceCredentialIntent(
354
+ "Approve sensitive operation",
355
+ reason,
356
+ ) ?: throw InstallationKeyFailure(
357
+ "STEPUP_NO_DEVICE_CREDENTIAL",
358
+ "Set a screen lock (PIN, pattern or password) to use step-up.",
359
+ )
360
+ pendingConfirm = PendingConfirm(entry, payload, done)
361
+ try {
362
+ activity.startActivityForResult(intent, CONFIRM_CREDENTIAL_REQUEST)
363
+ } catch (error: RuntimeException) {
364
+ // The confirmation never opened: nothing may wait for it, or every
365
+ // later approval would answer STEPUP_BUSY. The caller says why.
366
+ pendingConfirm = null
367
+ throw error
368
+ }
369
+ }
370
+
371
+ /**
372
+ * The activity that showed the confirm-credential screen is gone for good
373
+ * (not recreated for a configuration change): its answer will never come,
374
+ * so the approval ends as a cancelled one, and the next can be asked.
375
+ */
376
+ fun cancelPendingConfirmation() {
377
+ val pending = pendingConfirm ?: return
378
+ pendingConfirm = null
379
+ pending.done(
380
+ Result.failure(
381
+ InstallationKeyFailure(
382
+ "STEPUP_CANCELLED",
383
+ "The passcode prompt was cancelled: the screen that asked for it is gone.",
384
+ ),
385
+ ),
386
+ )
387
+ }
388
+
389
+ private fun buildPrompt(
390
+ activity: Activity,
391
+ factor: String,
392
+ reason: String,
393
+ done: (Result<String>) -> Unit,
394
+ ): BiometricPrompt {
395
+ val builder = BiometricPrompt.Builder(activity)
396
+ .setTitle("Approve sensitive operation")
397
+ .setDescription(reason)
398
+ val allowsCredential = factor == FACTOR_PASSCODE &&
399
+ Build.VERSION.SDK_INT >= Build.VERSION_CODES.R
400
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
401
+ builder.setAllowedAuthenticators(
402
+ if (allowsCredential) {
403
+ BiometricManager.Authenticators.DEVICE_CREDENTIAL
404
+ } else {
405
+ BiometricManager.Authenticators.BIOMETRIC_STRONG
406
+ },
407
+ )
408
+ }
409
+ if (!allowsCredential) {
410
+ // A biometric-only prompt must offer its own way out; a
411
+ // device-credential prompt must not (the system provides one).
412
+ builder.setNegativeButton("Cancel", activity.mainExecutor) { _, _ ->
413
+ done(
414
+ Result.failure(
415
+ InstallationKeyFailure("STEPUP_CANCELLED", "The biometric prompt was cancelled."),
416
+ ),
417
+ )
418
+ }
419
+ }
420
+ return builder.build()
421
+ }
422
+
423
+ private fun promptError(errorCode: Int, errString: CharSequence): InstallationKeyFailure {
424
+ val cancelled = errorCode == BiometricPrompt.BIOMETRIC_ERROR_USER_CANCELED ||
425
+ errorCode == BiometricPrompt.BIOMETRIC_ERROR_CANCELED
426
+ return InstallationKeyFailure(
427
+ if (cancelled) "STEPUP_CANCELLED" else "STEPUP_AUTH_FAILED",
428
+ "Step-up authentication did not complete: $errString (code $errorCode)",
429
+ )
430
+ }
431
+
432
+ // MARK: signing
433
+
434
+ private fun signNow(entry: KeyStore.PrivateKeyEntry, payload: ByteArray): String {
435
+ val signer = Signature.getInstance("SHA256withECDSA")
436
+ signer.initSign(entry.privateKey)
437
+ signer.update(payload)
438
+ return base64Url(signer.sign())
439
+ }
440
+
441
+ private fun signAndReport(
442
+ entry: KeyStore.PrivateKeyEntry,
443
+ payload: ByteArray,
444
+ done: (Result<String>) -> Unit,
445
+ ) {
446
+ try {
447
+ done(Result.success(signNow(entry, payload)))
448
+ } catch (error: KeyPermanentlyInvalidatedException) {
449
+ done(Result.failure(discardDeadKey(error)))
450
+ } catch (error: UserNotAuthenticatedException) {
451
+ done(
452
+ Result.failure(
453
+ InstallationKeyFailure(
454
+ "STEPUP_AUTH_FAILED",
455
+ "The keystore did not accept the authentication for the step-up key.",
456
+ error,
457
+ ),
458
+ ),
459
+ )
460
+ } catch (error: GeneralSecurityException) {
461
+ done(Result.failure(signingFailed(error)))
462
+ }
463
+ }
464
+
465
+ /**
466
+ * Deletes a step-up key that can never sign again and reports it. The
467
+ * installation must then re-enrol: the server never accepts a replacement
468
+ * step-up key for an existing installation.
469
+ */
470
+ private fun discardDeadKey(cause: Throwable): InstallationKeyFailure = synchronized(keyLock) {
471
+ // Say whether the removal actually happened: on an Android 9 phone a
472
+ // dead key's alias survived deleteEntry while the screen lock was off.
473
+ val removal = try {
474
+ val keyStore = loadKeyStore()
475
+ keyStore.deleteEntry(keyAlias)
476
+ if (keyStore.containsAlias(keyAlias)) "could not be removed (alias still present)" else "has been removed"
477
+ } catch (error: Exception) {
478
+ // Not the key's cause: the removal's own failure, said in full here.
479
+ "could not be removed (${Causes.chain(error)})"
480
+ }
481
+ InstallationKeyFailure(
482
+ "STEPUP_KEY_INVALIDATED",
483
+ "The step-up key was invalidated (the screen lock or enrolled biometrics changed) " +
484
+ "and $removal. Re-enrol the installation to get a new one.",
485
+ cause,
486
+ )
487
+ }
488
+
489
+ private fun signingFailed(error: Throwable) = InstallationKeyFailure(
490
+ "STEPUP_SIGNING_FAILED",
491
+ "AndroidKeyStore could not sign with the step-up key.",
492
+ error,
493
+ )
494
+
495
+ // MARK: key material
496
+
497
+ private fun generateKeyPair(
498
+ keyStore: KeyStore,
499
+ factor: String,
500
+ mode: String,
501
+ windowSeconds: Int,
502
+ ) {
503
+ val canTryStrongBox = Build.VERSION.SDK_INT >= Build.VERSION_CODES.P &&
504
+ context.packageManager.hasSystemFeature(PackageManager.FEATURE_STRONGBOX_KEYSTORE)
505
+ if (canTryStrongBox) {
506
+ try {
507
+ generateKeyPair(factor, mode, windowSeconds, preferStrongBox = true)
508
+ return
509
+ } catch (_: Exception) {
510
+ // Same fallback as the installation key: remove any partial
511
+ // alias and retry with the regular AndroidKeyStore provider.
512
+ try {
513
+ if (keyStore.containsAlias(keyAlias)) {
514
+ keyStore.deleteEntry(keyAlias)
515
+ }
516
+ } catch (_: Exception) {
517
+ }
518
+ }
519
+ }
520
+ generateKeyPair(factor, mode, windowSeconds, preferStrongBox = false)
521
+ }
522
+
523
+ private fun generateKeyPair(
524
+ factor: String,
525
+ mode: String,
526
+ windowSeconds: Int,
527
+ preferStrongBox: Boolean,
528
+ ) {
529
+ try {
530
+ val builder = KeyGenParameterSpec.Builder(keyAlias, KeyProperties.PURPOSE_SIGN)
531
+ .setAlgorithmParameterSpec(ECGenParameterSpec(CURVE_NAME))
532
+ .setDigests(KeyProperties.DIGEST_SHA256)
533
+ .setUserAuthenticationRequired(true)
534
+
535
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
536
+ builder.setUserAuthenticationParameters(
537
+ if (mode == MODE_WINDOWED) windowSeconds else 0,
538
+ if (factor == FACTOR_BIOMETRIC) {
539
+ KeyProperties.AUTH_BIOMETRIC_STRONG
540
+ } else {
541
+ KeyProperties.AUTH_DEVICE_CREDENTIAL
542
+ },
543
+ )
544
+ } else if (factor == FACTOR_PASSCODE) {
545
+ // No per-operation device credential before API 30, so a short
546
+ // hardware window is the strongest binding.
547
+ @Suppress("DEPRECATION")
548
+ builder.setUserAuthenticationValidityDurationSeconds(
549
+ if (mode == MODE_WINDOWED) windowSeconds else LEGACY_WINDOW_SECONDS,
550
+ )
551
+ } else if (mode == MODE_WINDOWED) {
552
+ @Suppress("DEPRECATION")
553
+ builder.setUserAuthenticationValidityDurationSeconds(windowSeconds)
554
+ }
555
+ // else: biometric per-use before API 30 keeps the default (-1),
556
+ // which demands a biometric for every operation.
557
+
558
+ if (factor == FACTOR_BIOMETRIC && Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
559
+ builder.setInvalidatedByBiometricEnrollment(true)
560
+ }
561
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P && preferStrongBox) {
562
+ builder.setIsStrongBoxBacked(true)
563
+ }
564
+
565
+ val generator = KeyPairGenerator.getInstance(
566
+ KeyProperties.KEY_ALGORITHM_EC,
567
+ ANDROID_KEY_STORE,
568
+ )
569
+ generator.initialize(builder.build())
570
+ generator.generateKeyPair()
571
+ } catch (error: Exception) {
572
+ throw InstallationKeyFailure(
573
+ "STEPUP_KEY_GENERATION_FAILED",
574
+ "AndroidKeyStore could not create the step-up key.",
575
+ error,
576
+ )
577
+ }
578
+ }
579
+
580
+ private fun requireSupportedAndroid() {
581
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) {
582
+ throw InstallationKeyFailure(
583
+ "UNSUPPORTED_ANDROID_VERSION",
584
+ "Step-up requires Android 6.0 (API 23) or newer.",
585
+ )
586
+ }
587
+ }
588
+
589
+ private fun requireDeviceCredential() {
590
+ val keyguard = context.getSystemService(KeyguardManager::class.java)
591
+ if (keyguard == null || !keyguard.isDeviceSecure) {
592
+ throw InstallationKeyFailure(
593
+ "STEPUP_NO_DEVICE_CREDENTIAL",
594
+ "Set a screen lock (PIN, pattern or password) to enable step-up.",
595
+ )
596
+ }
597
+ }
598
+
599
+ private fun loadKeyStore(): KeyStore {
600
+ try {
601
+ return KeyStore.getInstance(ANDROID_KEY_STORE).apply { load(null) }
602
+ } catch (error: GeneralSecurityException) {
603
+ throw InstallationKeyFailure("KEYSTORE_UNAVAILABLE", "AndroidKeyStore is unavailable.", error)
604
+ }
605
+ }
606
+
607
+ /**
608
+ * Loads the stored step-up key. Removing the screen lock (or, for a
609
+ * biometric key, changing the enrolled biometrics) invalidates an
610
+ * auth-bound key: Android says so when a signature starts
611
+ * (KeyPermanentlyInvalidatedException), and only then is the key deleted
612
+ * and STEPUP_KEY_INVALIDATED reported. A lookup that cannot establish
613
+ * whether the key is usable keeps it: STEPUP_KEY_UNAVAILABLE, retryable;
614
+ * any other keystore failure: KEY_LOOKUP_FAILED, the key kept
615
+ * (StepUpKeyEntry decides).
616
+ */
617
+ private fun liveEntry(keyStore: KeyStore): KeyStore.PrivateKeyEntry {
618
+ // What the lookups find is decided by StepUpKeyEntry: only Android's
619
+ // own word that the key is dead deletes it. An alias that outlived its
620
+ // private key on an Android 9 phone (isKeyEntry false, getEntry
621
+ // throwing UnsupportedOperationException) used to be deleted here, as
622
+ // did every lookup that could not say -- on Android 9 a binder error
623
+ // also reads as isKeyEntry false. Those keys are now kept.
624
+ var entry: KeyStore.PrivateKeyEntry? = null
625
+ var cause: Throwable? = null
626
+ val lookup = if (!keyStore.isKeyEntry(keyAlias)) {
627
+ StepUpKeyEntry.Lookup.NOT_KEY_ENTRY
628
+ } else {
629
+ try {
630
+ entry = keyStore.getEntry(keyAlias, null) as? KeyStore.PrivateKeyEntry
631
+ if (entry == null) StepUpKeyEntry.Lookup.NOT_PRIVATE_KEY else StepUpKeyEntry.Lookup.PRIVATE_KEY
632
+ } catch (error: UnrecoverableKeyException) {
633
+ cause = error
634
+ StepUpKeyEntry.Lookup.UNRECOVERABLE
635
+ } catch (error: UnsupportedOperationException) {
636
+ cause = error
637
+ StepUpKeyEntry.Lookup.UNSUPPORTED
638
+ } catch (error: GeneralSecurityException) {
639
+ cause = error
640
+ StepUpKeyEntry.Lookup.OTHER_FAILURE
641
+ }
642
+ }
643
+ var start = StepUpKeyEntry.SignStart.NOT_TRIED
644
+ val loaded = entry
645
+ if (loaded != null) {
646
+ start = try {
647
+ Signature.getInstance("SHA256withECDSA").initSign(loaded.privateKey)
648
+ StepUpKeyEntry.SignStart.STARTED
649
+ } catch (error: KeyPermanentlyInvalidatedException) {
650
+ cause = error
651
+ StepUpKeyEntry.SignStart.PERMANENTLY_INVALIDATED
652
+ } catch (_: UserNotAuthenticatedException) {
653
+ StepUpKeyEntry.SignStart.NEEDS_AUTHENTICATION
654
+ }
655
+ }
656
+ return when (StepUpKeyEntry.verdict(lookup, start)) {
657
+ StepUpKeyEntry.Verdict.USABLE -> loaded!!
658
+ StepUpKeyEntry.Verdict.INVALIDATED -> throw discardDeadKey(cause!!)
659
+ StepUpKeyEntry.Verdict.UNAVAILABLE -> throw InstallationKeyFailure(
660
+ "STEPUP_KEY_UNAVAILABLE",
661
+ "AndroidKeyStore could not establish whether the step-up key is usable " +
662
+ "(lookup: ${lookup.name.lowercase()}); the key was kept. Try again; if this " +
663
+ "persists, re-enrol to replace it.",
664
+ cause,
665
+ )
666
+ StepUpKeyEntry.Verdict.LOOKUP_FAILED -> throw InstallationKeyFailure(
667
+ "KEY_LOOKUP_FAILED",
668
+ "AndroidKeyStore could not load the step-up key.",
669
+ cause,
670
+ )
671
+ }
672
+ }
673
+
674
+ private fun presence(keyStore: KeyStore): KeyPresence.Found =
675
+ KeyPresence.of(object : KeyPresence.Keystore {
676
+ override fun containsKey() = keyStore.containsAlias(keyAlias)
677
+ override fun deleteProbe() = keyStore.deleteEntry(probeAlias)
678
+ })
679
+
680
+ private fun keyUnavailable(cause: Throwable?) = InstallationKeyFailure(
681
+ "STEPUP_KEY_UNAVAILABLE",
682
+ "AndroidKeyStore could not be reached for the step-up key; the key was kept, never " +
683
+ "replaced. Try again; if this persists, re-enrol to replace it.",
684
+ cause,
685
+ )
686
+
687
+ private fun keyInfo(entry: KeyStore.PrivateKeyEntry): KeyInfo {
688
+ try {
689
+ val keyFactory = KeyFactory.getInstance(entry.privateKey.algorithm, ANDROID_KEY_STORE)
690
+ return keyFactory.getKeySpec(entry.privateKey, KeyInfo::class.java)
691
+ } catch (error: GeneralSecurityException) {
692
+ throw InstallationKeyFailure(
693
+ "KEY_LOOKUP_FAILED",
694
+ "AndroidKeyStore could not describe the step-up key.",
695
+ error,
696
+ )
697
+ }
698
+ }
699
+
700
+ /** What the keystore enforces for this key, read back from KeyInfo. */
701
+ private fun describeAuth(info: KeyInfo): KeyAuth {
702
+ val duration = info.userAuthenticationValidityDurationSeconds
703
+ val mode = if (duration > 0) MODE_WINDOWED else MODE_PER_USE
704
+ val factor = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
705
+ if (info.userAuthenticationType and KeyProperties.AUTH_DEVICE_CREDENTIAL != 0) {
706
+ FACTOR_PASSCODE
707
+ } else {
708
+ FACTOR_BIOMETRIC
709
+ }
710
+ } else {
711
+ // KeyInfo has no auth type before API 30. A per-use key can then
712
+ // only be unlocked by a biometric; a windowed key by any lock-screen
713
+ // authentication, and the SDK prompts for the passcode.
714
+ if (mode == MODE_PER_USE) FACTOR_BIOMETRIC else FACTOR_PASSCODE
715
+ }
716
+ return KeyAuth(factor, mode, if (duration > 0) duration else 0)
717
+ }
718
+
719
+ private fun describeSecurity(info: KeyInfo): Pair<String, Boolean> {
720
+ return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
721
+ when (info.securityLevel) {
722
+ KeyProperties.SECURITY_LEVEL_STRONGBOX -> "strongbox" to true
723
+ KeyProperties.SECURITY_LEVEL_TRUSTED_ENVIRONMENT -> "trusted_execution_environment" to true
724
+ KeyProperties.SECURITY_LEVEL_SOFTWARE -> "software" to false
725
+ else -> "unknown" to false
726
+ }
727
+ } else {
728
+ @Suppress("DEPRECATION")
729
+ if (info.isInsideSecureHardware) "secure_hardware" to true else "software" to false
730
+ }
731
+ }
732
+
733
+ // MARK: encoding (mirrors InstallationKeyManager)
734
+
735
+ private fun unsignedFixed(value: BigInteger, size: Int): ByteArray {
736
+ var raw = value.toByteArray()
737
+ if (raw.size == size + 1 && raw[0] == 0.toByte()) {
738
+ raw = raw.copyOfRange(1, raw.size)
739
+ }
740
+ if (raw.size > size) {
741
+ throw InstallationKeyFailure("INVALID_PUBLIC_KEY", "The EC coordinate is larger than P-256 permits.")
742
+ }
743
+ return ByteArray(size).also { output ->
744
+ System.arraycopy(raw, 0, output, size - raw.size, raw.size)
745
+ }
746
+ }
747
+
748
+ private fun base64Url(value: ByteArray): String {
749
+ return Base64.encodeToString(value, Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING)
750
+ }
751
+
752
+ private fun decodeBase64Url(value: String): ByteArray {
753
+ if (value.isBlank()) {
754
+ throw InstallationKeyFailure("INVALID_PAYLOAD", "The step-up payload is empty.")
755
+ }
756
+ return try {
757
+ Base64.decode(value, Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING)
758
+ } catch (error: IllegalArgumentException) {
759
+ throw InstallationKeyFailure("INVALID_PAYLOAD", "The step-up payload is not valid base64url.", error)
760
+ }
761
+ }
762
+ }