react-native-wallet-keystore 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/LICENSE +20 -0
  2. package/README.md +376 -0
  3. package/WalletKeystore.podspec +29 -0
  4. package/android/build.gradle +62 -0
  5. package/android/src/main/AndroidManifest.xml +2 -0
  6. package/android/src/main/java/com/walletkeystore/Secp256k1.kt +158 -0
  7. package/android/src/main/java/com/walletkeystore/WalletKeystoreCrypto.kt +233 -0
  8. package/android/src/main/java/com/walletkeystore/WalletKeystoreModule.kt +627 -0
  9. package/android/src/main/java/com/walletkeystore/WalletKeystorePackage.kt +31 -0
  10. package/ios/WalletKeystore.h +5 -0
  11. package/ios/WalletKeystore.mm +895 -0
  12. package/lib/module/NativeWalletKeystore.js +15 -0
  13. package/lib/module/NativeWalletKeystore.js.map +1 -0
  14. package/lib/module/errors.js +57 -0
  15. package/lib/module/errors.js.map +1 -0
  16. package/lib/module/index.js +9 -0
  17. package/lib/module/index.js.map +1 -0
  18. package/lib/module/keystore.js +220 -0
  19. package/lib/module/keystore.js.map +1 -0
  20. package/lib/module/package.json +1 -0
  21. package/lib/module/viem.js +65 -0
  22. package/lib/module/viem.js.map +1 -0
  23. package/lib/typescript/package.json +1 -0
  24. package/lib/typescript/src/NativeWalletKeystore.d.ts +26 -0
  25. package/lib/typescript/src/NativeWalletKeystore.d.ts.map +1 -0
  26. package/lib/typescript/src/errors.d.ts +29 -0
  27. package/lib/typescript/src/errors.d.ts.map +1 -0
  28. package/lib/typescript/src/index.d.ts +3 -0
  29. package/lib/typescript/src/index.d.ts.map +1 -0
  30. package/lib/typescript/src/keystore.d.ts +100 -0
  31. package/lib/typescript/src/keystore.d.ts.map +1 -0
  32. package/lib/typescript/src/viem.d.ts +24 -0
  33. package/lib/typescript/src/viem.d.ts.map +1 -0
  34. package/package.json +210 -0
  35. package/src/NativeWalletKeystore.ts +43 -0
  36. package/src/errors.ts +94 -0
  37. package/src/index.tsx +6 -0
  38. package/src/keystore.ts +317 -0
  39. package/src/viem.ts +99 -0
@@ -0,0 +1,627 @@
1
+ package com.walletkeystore
2
+
3
+ import android.content.pm.PackageManager
4
+ import android.os.Build
5
+ import androidx.biometric.BiometricManager
6
+ import androidx.biometric.BiometricPrompt
7
+ import androidx.core.content.ContextCompat
8
+ import androidx.fragment.app.FragmentActivity
9
+ import com.facebook.react.bridge.Promise
10
+ import com.facebook.react.bridge.ReactApplicationContext
11
+ import com.facebook.react.bridge.UiThreadUtil
12
+ import java.math.BigInteger
13
+ import java.util.concurrent.atomic.AtomicBoolean
14
+ import javax.crypto.Cipher
15
+
16
+ class WalletKeystoreModule(reactContext: ReactApplicationContext) :
17
+ NativeWalletKeystoreSpec(reactContext) {
18
+
19
+ /**
20
+ * Where an operation's result goes. Signing reuses the storage operations and
21
+ * needs to intercept their result, and this avoids hand-rolling a `Promise`
22
+ * stand-in that would drift from React Native's interface.
23
+ */
24
+ private interface Settler {
25
+ fun resolve(value: Any?)
26
+ fun reject(code: String, message: String)
27
+ }
28
+
29
+ /**
30
+ * Settles exactly once. BiometricPrompt can deliver a terminal callback while
31
+ * an earlier failure is still unwinding, and settling twice throws.
32
+ */
33
+ private class PromiseGuard(private val promise: Promise) : Settler {
34
+ private val settled = AtomicBoolean(false)
35
+
36
+ override fun resolve(value: Any?) {
37
+ if (settled.compareAndSet(false, true)) promise.resolve(value)
38
+ }
39
+
40
+ override fun reject(code: String, message: String) {
41
+ if (settled.compareAndSet(false, true)) promise.reject(code, message)
42
+ }
43
+ }
44
+
45
+ /** Routes a nested operation's outcome back into the caller's own handling. */
46
+ private class Relay(
47
+ private val onResolve: (Any?) -> Unit,
48
+ private val onReject: (String, String) -> Unit,
49
+ ) : Settler {
50
+ override fun resolve(value: Any?) = onResolve(value)
51
+ override fun reject(code: String, message: String) = onReject(code, message)
52
+ }
53
+
54
+ // ---------------------------------------------------------------------------
55
+ // Authentication
56
+ // ---------------------------------------------------------------------------
57
+
58
+ override fun getBiometryType(promise: Promise) {
59
+ val pm = reactApplicationContext.packageManager
60
+
61
+ // PackageManager reports hardware presence only; Android has no API for
62
+ // which modality is enrolled. Several present means we cannot attribute.
63
+ val hasFingerprint = pm.hasSystemFeature(PackageManager.FEATURE_FINGERPRINT)
64
+ val hasFace =
65
+ Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q &&
66
+ pm.hasSystemFeature(PackageManager.FEATURE_FACE)
67
+ val hasIris =
68
+ Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q &&
69
+ pm.hasSystemFeature(PackageManager.FEATURE_IRIS)
70
+
71
+ val present = listOf(
72
+ hasFingerprint to "fingerprint",
73
+ hasFace to "face",
74
+ hasIris to "iris"
75
+ ).filter { it.first }.map { it.second }
76
+
77
+ promise.resolve(
78
+ when {
79
+ present.isEmpty() -> "none"
80
+ present.size == 1 -> present.first()
81
+ else -> "biometric"
82
+ }
83
+ )
84
+ }
85
+
86
+ override fun authenticate(reason: String, policy: String, promise: Promise) {
87
+ val guard = PromiseGuard(promise)
88
+
89
+ if (reason.isBlank()) {
90
+ guard.reject(CODE_UNKNOWN, "A non-empty `reason` is required to authenticate.")
91
+ return
92
+ }
93
+
94
+ val activity = reactApplicationContext.currentActivity
95
+ if (activity !is FragmentActivity) {
96
+ guard.reject(
97
+ CODE_NOT_AVAILABLE,
98
+ "A foreground FragmentActivity is required to show the biometric prompt."
99
+ )
100
+ return
101
+ }
102
+
103
+ val authenticators = authenticatorsFor(policy)
104
+ val status = BiometricManager.from(reactApplicationContext).canAuthenticate(authenticators)
105
+ if (status != BiometricManager.BIOMETRIC_SUCCESS) {
106
+ guard.reject(mapAvailability(status), availabilityMessage(status, authenticators))
107
+ return
108
+ }
109
+
110
+ // BiometricPrompt must be constructed and shown on the main thread. The
111
+ // authentication itself runs off it, and the callback returns here.
112
+ UiThreadUtil.runOnUiThread {
113
+ try {
114
+ val callback = object : BiometricPrompt.AuthenticationCallback() {
115
+ override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
116
+ guard.resolve(true)
117
+ }
118
+
119
+ override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
120
+ guard.reject(mapAuthError(errorCode), errString.toString())
121
+ }
122
+
123
+ // onAuthenticationFailed is deliberately not overridden: it fires per
124
+ // rejected attempt while the prompt stays up, so settling there would
125
+ // end the flow on the user's first fumble.
126
+ }
127
+
128
+ BiometricPrompt(activity, ContextCompat.getMainExecutor(activity), callback)
129
+ .authenticate(buildPromptInfo(reason, authenticators))
130
+ } catch (e: Exception) {
131
+ guard.reject(CODE_UNKNOWN, e.message ?: "Failed to present the biometric prompt.")
132
+ }
133
+ }
134
+ }
135
+
136
+ // ---------------------------------------------------------------------------
137
+ // Secret storage
138
+ // ---------------------------------------------------------------------------
139
+
140
+ override fun storeSecret(
141
+ keyId: String,
142
+ secretHex: String,
143
+ policy: String,
144
+ invalidation: String,
145
+ promise: Promise
146
+ ) = storeSecretInto(keyId, secretHex, policy, invalidation, PromiseGuard(promise))
147
+
148
+ /**
149
+ * Unlike iOS, storing prompts here: the wrapping key is symmetric, and
150
+ * `setUserAuthenticationRequired(true)` governs every use of it. Silent
151
+ * storage would mean dropping that requirement entirely.
152
+ */
153
+ private fun storeSecretInto(
154
+ keyId: String,
155
+ secretHex: String,
156
+ policy: String,
157
+ invalidation: String,
158
+ settler: Settler
159
+ ) {
160
+ val secret = WalletKeystoreCrypto.fromHex(secretHex)
161
+ if (secret == null) {
162
+ settler.reject(CODE_UNKNOWN, "`secretHex` must be a non-empty hex string.")
163
+ return
164
+ }
165
+
166
+ // Overwriting a wallet key has to be deliberate.
167
+ if (WalletKeystoreCrypto.hasRecord(reactApplicationContext, keyId) ||
168
+ WalletKeystoreCrypto.hasKey(keyId)
169
+ ) {
170
+ settler.reject(CODE_KEY_ALREADY_EXISTS, "A secret is already stored under this keyId.")
171
+ return
172
+ }
173
+
174
+ val generated = try {
175
+ WalletKeystoreCrypto.generateKey(keyId, policy, invalidation)
176
+ } catch (e: Exception) {
177
+ settler.reject(CODE_STORAGE_ERROR, e.message ?: "Could not create the wrapping key.")
178
+ return
179
+ }
180
+
181
+ val cipher = try {
182
+ WalletKeystoreCrypto.encryptCipher(generated.key)
183
+ } catch (e: Exception) {
184
+ WalletKeystoreCrypto.deleteKey(keyId)
185
+ settler.reject(classify(e), e.message ?: "Could not initialize encryption.")
186
+ return
187
+ }
188
+
189
+ withPrompt(
190
+ settler,
191
+ reason = "Store your wallet key",
192
+ policy = policy,
193
+ cipher = cipher,
194
+ onAuthenticated = { authenticated -> finishStore(settler, keyId, secret, authenticated) },
195
+ onSetupFailure = { WalletKeystoreCrypto.deleteKey(keyId) }
196
+ )
197
+ }
198
+
199
+ private fun finishStore(
200
+ settler: Settler,
201
+ keyId: String,
202
+ secret: ByteArray,
203
+ cipher: Cipher
204
+ ) {
205
+ try {
206
+ val ciphertext = cipher.doFinal(secret)
207
+ WalletKeystoreCrypto.writeRecord(reactApplicationContext, keyId, cipher.iv, ciphertext)
208
+ settler.resolve(null)
209
+ } catch (e: Exception) {
210
+ // Never leave a key behind with no ciphertext — the id would look taken
211
+ // forever and storeSecret would keep rejecting KEY_ALREADY_EXISTS.
212
+ WalletKeystoreCrypto.deleteKey(keyId)
213
+ WalletKeystoreCrypto.deleteRecord(reactApplicationContext, keyId)
214
+ settler.reject(classify(e), e.message ?: "Could not encrypt the secret.")
215
+ } finally {
216
+ secret.fill(0)
217
+ }
218
+ }
219
+
220
+ override fun getSecret(keyId: String, reason: String, promise: Promise) =
221
+ getSecretInto(keyId, reason, PromiseGuard(promise))
222
+
223
+ private fun getSecretInto(keyId: String, reason: String, settler: Settler) {
224
+ if (reason.isBlank()) {
225
+ settler.reject(CODE_UNKNOWN, "A non-empty `reason` is required to read a secret.")
226
+ return
227
+ }
228
+
229
+ val record = WalletKeystoreCrypto.readRecord(reactApplicationContext, keyId)
230
+ if (record == null) {
231
+ settler.reject(CODE_KEY_NOT_FOUND, "No secret is stored under this keyId.")
232
+ return
233
+ }
234
+ val (iv, ciphertext) = record
235
+
236
+ val key = try {
237
+ WalletKeystoreCrypto.loadKey(keyId)
238
+ } catch (e: Exception) {
239
+ settler.reject(classify(e), e.message ?: "Could not load the wrapping key.")
240
+ return
241
+ }
242
+
243
+ if (key == null) {
244
+ // Ciphertext present but wrapping key gone: unrecoverable, not absent.
245
+ // Removing the device lock deletes auth-bound keys outright rather than
246
+ // throwing KeyPermanentlyInvalidatedException, so it lands here.
247
+ settler.reject(
248
+ CODE_KEY_INVALIDATED,
249
+ "The wrapping key no longer exists; this secret cannot be recovered."
250
+ )
251
+ return
252
+ }
253
+
254
+ // Cipher.init is where a key pinned to a changed biometric enrollment
255
+ // throws, so this is where KEY_INVALIDATED is detected.
256
+ val cipher = try {
257
+ WalletKeystoreCrypto.decryptCipher(key, iv)
258
+ } catch (e: Exception) {
259
+ settler.reject(classify(e), e.message ?: "The wrapping key is no longer usable.")
260
+ return
261
+ }
262
+
263
+ // Asked of the key rather than discovered by attempting the operation: a
264
+ // failed doFinal leaves the Cipher unusable, so it could not then be handed
265
+ // to the CryptoObject below.
266
+ if (!WalletKeystoreCrypto.requiresAuth(key)) {
267
+ try {
268
+ val plaintext = cipher.doFinal(ciphertext)
269
+ settler.resolve(WalletKeystoreCrypto.hex(plaintext))
270
+ plaintext.fill(0)
271
+ } catch (e: Exception) {
272
+ settler.reject(classify(e), e.message ?: "Could not decrypt the secret.")
273
+ }
274
+ return
275
+ }
276
+
277
+ // The CryptoObject is what makes this a real boundary rather than a check:
278
+ // the Cipher stays unusable until the OS validates the user, so a
279
+ // compromised JS bundle cannot skip it by faking a boolean.
280
+ withPrompt(
281
+ settler,
282
+ reason = reason,
283
+ policy = POLICY_BIOMETRIC_OR_PASSCODE,
284
+ cipher = cipher,
285
+ onAuthenticated = { authenticated ->
286
+ try {
287
+ val plaintext = authenticated.doFinal(ciphertext)
288
+ settler.resolve(WalletKeystoreCrypto.hex(plaintext))
289
+ plaintext.fill(0)
290
+ } catch (e: Exception) {
291
+ settler.reject(classify(e), e.message ?: "Could not decrypt the secret.")
292
+ }
293
+ }
294
+ )
295
+ }
296
+
297
+ override fun hasSecret(keyId: String, promise: Promise) {
298
+ promise.resolve(WalletKeystoreCrypto.hasRecord(reactApplicationContext, keyId))
299
+ }
300
+
301
+ override fun deleteSecret(keyId: String, promise: Promise) {
302
+ // Idempotent: a missing keyId is success for a teardown path.
303
+ WalletKeystoreCrypto.deleteRecord(reactApplicationContext, keyId)
304
+ WalletKeystoreCrypto.deletePublicKey(reactApplicationContext, keyId)
305
+ WalletKeystoreCrypto.deleteKey(keyId)
306
+ promise.resolve(null)
307
+ }
308
+
309
+ // ---------------------------------------------------------------------------
310
+ // secp256k1
311
+ // ---------------------------------------------------------------------------
312
+
313
+ override fun generateKey(
314
+ keyId: String,
315
+ policy: String,
316
+ invalidation: String,
317
+ promise: Promise
318
+ ) {
319
+ val guard = PromiseGuard(promise)
320
+
321
+ // Entropy from SecureRandom, never from JS. The private key goes straight
322
+ // into the wrapping path and never crosses the bridge.
323
+ val privateKey = try {
324
+ Secp256k1.generatePrivateKey()
325
+ } catch (e: Exception) {
326
+ guard.reject(CODE_STORAGE_ERROR, e.message ?: "Could not generate a key.")
327
+ return
328
+ }
329
+
330
+ wrapPrivateKey(keyId, privateKey, policy, invalidation, guard)
331
+ }
332
+
333
+ override fun importPrivateKey(
334
+ keyId: String,
335
+ privateKeyHex: String,
336
+ policy: String,
337
+ invalidation: String,
338
+ promise: Promise
339
+ ) {
340
+ val guard = PromiseGuard(promise)
341
+
342
+ val privateKey = WalletKeystoreCrypto.fromHex(privateKeyHex)
343
+ if (privateKey == null || privateKey.size != 32) {
344
+ guard.reject(CODE_INVALID_KEY, "A private key must be exactly 32 bytes of hex.")
345
+ return
346
+ }
347
+
348
+ // Zero and anything at or above the curve order are not merely malformed:
349
+ // they produce signatures that verify against nothing. Rejected, not clamped.
350
+ if (!Secp256k1.isValidPrivateKey(BigInteger(1, privateKey))) {
351
+ privateKey.fill(0)
352
+ guard.reject(CODE_INVALID_KEY, "The private key must be in [1, n-1].")
353
+ return
354
+ }
355
+
356
+ wrapPrivateKey(keyId, privateKey, policy, invalidation, guard)
357
+ }
358
+
359
+ /** Derives the public key, then stores the private key via the v0.2 path. */
360
+ private fun wrapPrivateKey(
361
+ keyId: String,
362
+ privateKey: ByteArray,
363
+ policy: String,
364
+ invalidation: String,
365
+ guard: Settler
366
+ ) {
367
+ val publicKeyHex = try {
368
+ WalletKeystoreCrypto.hex(Secp256k1.publicKeyFrom(privateKey))
369
+ } catch (e: Exception) {
370
+ privateKey.fill(0)
371
+ guard.reject(CODE_INVALID_KEY, e.message ?: "Could not derive the public key.")
372
+ return
373
+ }
374
+
375
+ val hex = WalletKeystoreCrypto.hex(privateKey)
376
+ privateKey.fill(0)
377
+
378
+ storeSecretInto(
379
+ keyId, hex, policy, invalidation,
380
+ Relay(
381
+ onResolve = {
382
+ // Recorded only after the wrapping succeeded, so a stored public key
383
+ // always implies a retrievable private one.
384
+ WalletKeystoreCrypto.writePublicKey(reactApplicationContext, keyId, publicKeyHex)
385
+ guard.resolve(publicKeyHex)
386
+ },
387
+ onReject = { code, message -> guard.reject(code, message) }
388
+ )
389
+ )
390
+ }
391
+
392
+ override fun getPublicKey(keyId: String, promise: Promise) {
393
+ val publicKey = WalletKeystoreCrypto.readPublicKey(reactApplicationContext, keyId)
394
+ if (publicKey == null) {
395
+ promise.reject(CODE_KEY_NOT_FOUND, "No key is stored under this keyId.")
396
+ return
397
+ }
398
+ promise.resolve(publicKey)
399
+ }
400
+
401
+ override fun signDigest(
402
+ keyId: String,
403
+ digestHex: String,
404
+ reason: String,
405
+ promise: Promise
406
+ ) {
407
+ val guard = PromiseGuard(promise)
408
+
409
+ val digest = WalletKeystoreCrypto.fromHex(digestHex)
410
+ if (digest == null || digest.size != 32) {
411
+ guard.reject(CODE_INVALID_KEY, "A digest must be exactly 32 bytes of hex.")
412
+ return
413
+ }
414
+
415
+ withUnwrappedKey(keyId, reason, guard) { privateKey ->
416
+ try {
417
+ guard.resolve(WalletKeystoreCrypto.hex(Secp256k1.sign(digest, privateKey)))
418
+ } catch (e: Exception) {
419
+ guard.reject(CODE_STORAGE_ERROR, e.message ?: "Could not sign the digest.")
420
+ }
421
+ }
422
+ }
423
+
424
+ override fun exportPrivateKey(keyId: String, reason: String, promise: Promise) {
425
+ val guard = PromiseGuard(promise)
426
+ withUnwrappedKey(keyId, reason, guard) { privateKey ->
427
+ guard.resolve(WalletKeystoreCrypto.hex(privateKey))
428
+ }
429
+ }
430
+
431
+ /**
432
+ * Authenticates, decrypts, hands over the raw key, then zeroes it.
433
+ *
434
+ * The zeroing is best-effort by nature — see the README. It shrinks the window
435
+ * in which the key sits in memory; in a managed runtime it cannot close it.
436
+ */
437
+ private fun withUnwrappedKey(
438
+ keyId: String,
439
+ reason: String,
440
+ guard: Settler,
441
+ use: (ByteArray) -> Unit
442
+ ) {
443
+ getSecretInto(
444
+ keyId, reason,
445
+ Relay(
446
+ onResolve = { value ->
447
+ val privateKey = (value as? String)?.let { WalletKeystoreCrypto.fromHex(it) }
448
+ if (privateKey == null) {
449
+ guard.reject(CODE_STORAGE_ERROR, "The stored key is missing or malformed.")
450
+ } else {
451
+ try {
452
+ use(privateKey)
453
+ } finally {
454
+ privateKey.fill(0)
455
+ }
456
+ }
457
+ },
458
+ onReject = { code, message -> guard.reject(code, message) }
459
+ )
460
+ )
461
+ }
462
+
463
+ // ---------------------------------------------------------------------------
464
+ // Prompt plumbing
465
+ // ---------------------------------------------------------------------------
466
+
467
+ /** Shared BiometricPrompt presentation for the crypto-bound operations. */
468
+ private fun withPrompt(
469
+ settler: Settler,
470
+ reason: String,
471
+ policy: String,
472
+ cipher: Cipher,
473
+ onAuthenticated: (Cipher) -> Unit,
474
+ onSetupFailure: () -> Unit = {}
475
+ ) {
476
+ val activity = reactApplicationContext.currentActivity
477
+ if (activity !is FragmentActivity) {
478
+ onSetupFailure()
479
+ settler.reject(
480
+ CODE_NOT_AVAILABLE,
481
+ "A foreground FragmentActivity is required to show the biometric prompt."
482
+ )
483
+ return
484
+ }
485
+
486
+ val authenticators = authenticatorsFor(policy)
487
+ val status = BiometricManager.from(reactApplicationContext).canAuthenticate(authenticators)
488
+ if (status != BiometricManager.BIOMETRIC_SUCCESS) {
489
+ onSetupFailure()
490
+ settler.reject(mapAvailability(status), availabilityMessage(status, authenticators))
491
+ return
492
+ }
493
+
494
+ UiThreadUtil.runOnUiThread {
495
+ try {
496
+ val callback = object : BiometricPrompt.AuthenticationCallback() {
497
+ override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
498
+ // The Cipher handed back by the framework is the authenticated one.
499
+ val authenticated = result.cryptoObject?.cipher
500
+ if (authenticated == null) {
501
+ onSetupFailure()
502
+ settler.reject(CODE_UNKNOWN, "The authenticated cipher was not returned.")
503
+ return
504
+ }
505
+ onAuthenticated(authenticated)
506
+ }
507
+
508
+ override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
509
+ onSetupFailure()
510
+ settler.reject(mapAuthError(errorCode), errString.toString())
511
+ }
512
+
513
+ // onAuthenticationFailed is intentionally not overridden — see
514
+ // authenticate() above.
515
+ }
516
+
517
+ BiometricPrompt(activity, ContextCompat.getMainExecutor(activity), callback)
518
+ .authenticate(
519
+ buildPromptInfo(reason, authenticators),
520
+ BiometricPrompt.CryptoObject(cipher)
521
+ )
522
+ } catch (e: Exception) {
523
+ onSetupFailure()
524
+ settler.reject(CODE_UNKNOWN, e.message ?: "Failed to present the biometric prompt.")
525
+ }
526
+ }
527
+ }
528
+
529
+ private fun buildPromptInfo(reason: String, authenticators: Int): BiometricPrompt.PromptInfo {
530
+ val builder = BiometricPrompt.PromptInfo.Builder()
531
+ .setTitle(reason)
532
+ .setAllowedAuthenticators(authenticators)
533
+
534
+ // The framework supplies its own device-credential affordance, and setting
535
+ // a negative button alongside DEVICE_CREDENTIAL throws. When biometrics are
536
+ // the only authenticator the negative button is mandatory instead — there
537
+ // would otherwise be no way to dismiss the prompt.
538
+ if (authenticators and BiometricManager.Authenticators.DEVICE_CREDENTIAL == 0) {
539
+ builder.setNegativeButtonText("Cancel")
540
+ }
541
+
542
+ return builder.build()
543
+ }
544
+
545
+ // setAllowedAuthenticators rejects BIOMETRIC_STRONG or DEVICE_CREDENTIAL below
546
+ // API 30, so API 24-29 degrades to biometric-only rather than silently
547
+ // accepting a weaker credential.
548
+ private fun authenticatorsFor(policy: String): Int =
549
+ WalletKeystoreCrypto.authenticatorsFor(policy)
550
+
551
+ private fun mapAvailability(status: Int): String = when (status) {
552
+ BiometricManager.BIOMETRIC_ERROR_NO_HARDWARE,
553
+ BiometricManager.BIOMETRIC_ERROR_HW_UNAVAILABLE -> CODE_NOT_AVAILABLE
554
+ BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED -> CODE_NOT_ENROLLED
555
+ BiometricManager.BIOMETRIC_ERROR_SECURITY_UPDATE_REQUIRED -> CODE_NOT_AVAILABLE
556
+ else -> CODE_UNKNOWN
557
+ }
558
+
559
+ private fun availabilityMessage(status: Int, authenticators: Int): String = when (status) {
560
+ BiometricManager.BIOMETRIC_ERROR_NO_HARDWARE ->
561
+ "This device has no biometric hardware."
562
+ BiometricManager.BIOMETRIC_ERROR_HW_UNAVAILABLE ->
563
+ "Biometric hardware is currently unavailable."
564
+ BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED ->
565
+ // A device credential may well be enrolled while this still fails under
566
+ // 'biometricOnly', so the message has to name only what was actually
567
+ // asked for. Saying "or device credential" there sends the user to
568
+ // Settings to re-add a PIN they already have.
569
+ if (authenticators and BiometricManager.Authenticators.DEVICE_CREDENTIAL != 0) {
570
+ "No biometric or device credential is enrolled."
571
+ } else {
572
+ "No biometric is enrolled."
573
+ }
574
+ BiometricManager.BIOMETRIC_ERROR_SECURITY_UPDATE_REQUIRED ->
575
+ "A security update is required before biometrics can be used."
576
+ else -> "Biometric authentication is unavailable (status $status)."
577
+ }
578
+
579
+ private fun mapAuthError(errorCode: Int): String = when (errorCode) {
580
+ BiometricPrompt.ERROR_HW_NOT_PRESENT,
581
+ BiometricPrompt.ERROR_HW_UNAVAILABLE -> CODE_NOT_AVAILABLE
582
+
583
+ BiometricPrompt.ERROR_NO_BIOMETRICS -> CODE_NOT_ENROLLED
584
+
585
+ // ERROR_NEGATIVE_BUTTON is the Cancel button; ERROR_USER_CANCELED is a
586
+ // dismissal. Both are the user declining, so both stay retryable.
587
+ BiometricPrompt.ERROR_USER_CANCELED,
588
+ BiometricPrompt.ERROR_NEGATIVE_BUTTON -> CODE_USER_CANCELED
589
+
590
+ BiometricPrompt.ERROR_LOCKOUT -> CODE_LOCKOUT
591
+ BiometricPrompt.ERROR_LOCKOUT_PERMANENT -> CODE_LOCKOUT_PERMANENT
592
+
593
+ // ERROR_CANCELED is the system tearing the prompt down; ERROR_TIMEOUT is it
594
+ // expiring untouched. Neither is user intent.
595
+ BiometricPrompt.ERROR_CANCELED,
596
+ BiometricPrompt.ERROR_TIMEOUT -> CODE_SYSTEM_CANCEL
597
+
598
+ else -> CODE_UNKNOWN
599
+ }
600
+
601
+ private fun classify(t: Throwable): String = when {
602
+ WalletKeystoreCrypto.isInvalidated(t) -> CODE_KEY_INVALIDATED
603
+ WalletKeystoreCrypto.isNotAuthenticated(t) -> CODE_NOT_ENROLLED
604
+ else -> CODE_STORAGE_ERROR
605
+ }
606
+
607
+ companion object {
608
+ const val NAME = NativeWalletKeystoreSpec.NAME
609
+
610
+ internal const val POLICY_BIOMETRIC_ONLY = "biometricOnly"
611
+ internal const val POLICY_BIOMETRIC_OR_PASSCODE = "biometricOrPasscode"
612
+ internal const val INVALIDATION_ON_ENROLLMENT_CHANGE = "onEnrollmentChange"
613
+
614
+ private const val CODE_NOT_AVAILABLE = "NOT_AVAILABLE"
615
+ private const val CODE_NOT_ENROLLED = "NOT_ENROLLED"
616
+ private const val CODE_USER_CANCELED = "USER_CANCELED"
617
+ private const val CODE_LOCKOUT = "LOCKOUT"
618
+ private const val CODE_LOCKOUT_PERMANENT = "LOCKOUT_PERMANENT"
619
+ private const val CODE_SYSTEM_CANCEL = "SYSTEM_CANCEL"
620
+ private const val CODE_UNKNOWN = "UNKNOWN"
621
+ private const val CODE_KEY_NOT_FOUND = "KEY_NOT_FOUND"
622
+ private const val CODE_KEY_ALREADY_EXISTS = "KEY_ALREADY_EXISTS"
623
+ private const val CODE_KEY_INVALIDATED = "KEY_INVALIDATED"
624
+ private const val CODE_STORAGE_ERROR = "STORAGE_ERROR"
625
+ private const val CODE_INVALID_KEY = "INVALID_KEY"
626
+ }
627
+ }
@@ -0,0 +1,31 @@
1
+ package com.walletkeystore
2
+
3
+ import com.facebook.react.BaseReactPackage
4
+ import com.facebook.react.bridge.NativeModule
5
+ import com.facebook.react.bridge.ReactApplicationContext
6
+ import com.facebook.react.module.model.ReactModuleInfo
7
+ import com.facebook.react.module.model.ReactModuleInfoProvider
8
+ import java.util.HashMap
9
+
10
+ class WalletKeystorePackage : BaseReactPackage() {
11
+ override fun getModule(name: String, reactContext: ReactApplicationContext): NativeModule? {
12
+ return if (name == WalletKeystoreModule.NAME) {
13
+ WalletKeystoreModule(reactContext)
14
+ } else {
15
+ null
16
+ }
17
+ }
18
+
19
+ override fun getReactModuleInfoProvider() = ReactModuleInfoProvider {
20
+ mapOf(
21
+ WalletKeystoreModule.NAME to ReactModuleInfo(
22
+ name = WalletKeystoreModule.NAME,
23
+ className = WalletKeystoreModule.NAME,
24
+ canOverrideExistingModule = false,
25
+ needsEagerInit = false,
26
+ isCxxModule = false,
27
+ isTurboModule = true
28
+ )
29
+ )
30
+ }
31
+ }
@@ -0,0 +1,5 @@
1
+ #import <WalletKeystoreSpec/WalletKeystoreSpec.h>
2
+
3
+ @interface WalletKeystore : NSObject <NativeWalletKeystoreSpec>
4
+
5
+ @end