@chainberry/trust-wallet-core 1.0.2 → 2.5.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 (37) hide show
  1. package/{TrustWalletCoreModule.podspec → ChainberryTrustWalletCoreModule.podspec} +2 -2
  2. package/README.md +38 -30
  3. package/android/build.gradle +31 -6
  4. package/android/libs/README.md +34 -0
  5. package/android/libs/com/trustwallet/wallet-core/4.1.19/wallet-core-4.1.19.aar +0 -0
  6. package/android/libs/com/trustwallet/wallet-core/4.1.19/wallet-core-4.1.19.aar.md5 +1 -0
  7. package/android/libs/com/trustwallet/wallet-core/4.1.19/wallet-core-4.1.19.aar.sha1 +1 -0
  8. package/android/libs/com/trustwallet/wallet-core/4.1.19/wallet-core-4.1.19.pom +22 -0
  9. package/android/libs/com/trustwallet/wallet-core/4.1.19/wallet-core-4.1.19.pom.md5 +1 -0
  10. package/android/libs/com/trustwallet/wallet-core/4.1.19/wallet-core-4.1.19.pom.sha1 +1 -0
  11. package/android/libs/com/trustwallet/wallet-core-proto/4.1.19/wallet-core-proto-4.1.19.jar +0 -0
  12. package/android/libs/com/trustwallet/wallet-core-proto/4.1.19/wallet-core-proto-4.1.19.jar.md5 +1 -0
  13. package/android/libs/com/trustwallet/wallet-core-proto/4.1.19/wallet-core-proto-4.1.19.jar.sha1 +1 -0
  14. package/android/libs/com/trustwallet/wallet-core-proto/4.1.19/wallet-core-proto-4.1.19.pom +21 -0
  15. package/android/libs/com/trustwallet/wallet-core-proto/4.1.19/wallet-core-proto-4.1.19.pom.md5 +1 -0
  16. package/android/libs/com/trustwallet/wallet-core-proto/4.1.19/wallet-core-proto-4.1.19.pom.sha1 +1 -0
  17. package/android/libs/download.sh +52 -0
  18. package/android/src/androidTest/java/com/chainberry/trustwalletcore/AddressDerivationConformanceTest.kt +106 -0
  19. package/android/src/androidTest/java/com/chainberry/trustwalletcore/SigningConformanceTest.kt +186 -0
  20. package/android/src/main/java/com/chainberry/trustwalletcore/AmountParsing.kt +45 -0
  21. package/android/src/main/java/com/chainberry/trustwalletcore/Bech32.kt +68 -0
  22. package/android/src/main/java/com/chainberry/trustwalletcore/ChainSigning.kt +526 -0
  23. package/android/src/main/java/com/chainberry/trustwalletcore/ChainberryTrustWalletCoreModule.kt +147 -0
  24. package/android/src/main/java/com/chainberry/trustwalletcore/NativeWalletStore.kt +796 -0
  25. package/android/src/test/java/com/chainberry/trustwalletcore/AmountParsingConformanceTest.kt +57 -0
  26. package/android/src/test/java/com/chainberry/trustwalletcore/Bech32Test.kt +35 -0
  27. package/android/src/test/java/com/chainberry/trustwalletcore/NativeWalletStoreTest.kt +274 -0
  28. package/expo-module.config.json +3 -2
  29. package/ios/AmountParsing.swift +62 -0
  30. package/ios/Bech32.swift +66 -0
  31. package/ios/ChainSigning.swift +602 -0
  32. package/ios/ChainberryTrustWalletCoreModule.swift +231 -0
  33. package/ios/NativeWalletStore.swift +232 -0
  34. package/package.json +5 -4
  35. package/src/index.ts +70 -60
  36. package/android/src/main/java/expo/modules/trustwalletcore/TrustWalletCoreModule.kt +0 -143
  37. package/ios/TrustWalletCoreModule.swift +0 -145
@@ -0,0 +1,796 @@
1
+ package com.chainberry.trustwalletcore
2
+
3
+ import android.app.KeyguardManager
4
+ import android.content.Context
5
+ import android.os.Build
6
+ import android.security.keystore.KeyGenParameterSpec
7
+ import android.security.keystore.KeyInfo
8
+ import android.security.keystore.KeyPermanentlyInvalidatedException
9
+ import android.security.keystore.KeyProperties
10
+ import android.security.keystore.StrongBoxUnavailableException
11
+ import android.security.keystore.UserNotAuthenticatedException
12
+ import android.util.Log
13
+ import androidx.biometric.BiometricManager
14
+ import androidx.biometric.BiometricPrompt
15
+ import androidx.core.content.ContextCompat
16
+ import androidx.fragment.app.FragmentActivity
17
+ import expo.modules.kotlin.exception.CodedException
18
+ import kotlinx.coroutines.Dispatchers
19
+ import kotlinx.coroutines.withContext
20
+ import org.json.JSONException
21
+ import org.json.JSONObject
22
+ import java.io.File
23
+ import java.security.KeyStore
24
+ import java.util.UUID
25
+ import javax.crypto.Cipher
26
+ import javax.crypto.KeyGenerator
27
+ import javax.crypto.SecretKey
28
+ import javax.crypto.SecretKeyFactory
29
+ import javax.crypto.spec.GCMParameterSpec
30
+ import kotlin.coroutines.resume
31
+ import kotlin.coroutines.resumeWithException
32
+ import kotlin.coroutines.suspendCoroutine
33
+
34
+ /**
35
+ * Which authenticator a wallet's Keystore key is gated by, chosen once at creation time (see
36
+ * [NativeWalletStore.resolveAvailableMode]) and thereafter recoverable from which Keystore alias
37
+ * exists for that wallet id (see [NativeWalletStore.resolveExistingMode]) — no separate metadata
38
+ * needed. The two *creatable* modes are deliberately separate flows rather than one prompt/key
39
+ * straddling both: combining `BIOMETRIC_STRONG` and `DEVICE_CREDENTIAL` in a single
40
+ * `BiometricPrompt` (or in a single per-use Keystore key) is not reliably supported on API 29 and
41
+ * below, per https://developer.android.com/identity/sign-in/biometric-auth — see
42
+ * [NativeWalletStore] for the full rationale.
43
+ */
44
+ enum class AuthMode(val aliasInfix: String) {
45
+ /** Authentication-per-use: every single encrypt/decrypt requires a fresh `BIOMETRIC_STRONG`
46
+ * prompt. Supported identically on every API level 24+ — this is the only combination that
47
+ * needs no API-level branching in [NativeWalletStore.getOrCreateKey] at all. */
48
+ BIOMETRIC_STRONG("bio_"),
49
+
50
+ /** Fallback used only when no strong biometric is enrolled/available. Backed by a short
51
+ * bounded-validity key rather than a per-use one, and never binds a `CryptoObject` to its
52
+ * prompt on any API level (see [NativeWalletStore.confirmDeviceCredential]) — both are
53
+ * consequences of `CryptoObject` support for device-credential auth only existing from API 30
54
+ * (androidx.biometric 1.1.0-alpha02) onward. */
55
+ DEVICE_CREDENTIAL("cred_"),
56
+
57
+ /** Pre-migration scheme (alias `vault_wallet_<id>`, no infix — matches [aliasInfix] `""`) from
58
+ * before the biometric/device-credential split above existed: a single per-use key accepting
59
+ * *either* `BIOMETRIC_STRONG` or `DEVICE_CREDENTIAL` in one combined `BiometricPrompt`. Never
60
+ * chosen for a new wallet ([NativeWalletStore.resolveAvailableMode] never returns it) — it
61
+ * exists purely so [NativeWalletStore.resolveExistingMode] can still find and
62
+ * [NativeWalletStore.authenticateForExistingWallet] can still unlock a wallet that was created
63
+ * before the split shipped. The split's original commit assumed no such wallet could exist
64
+ * pre-launch and shipped with no migration path; that assumption turned out to be wrong (a
65
+ * real wallet created under this scheme was found to be permanently unreachable — `resolveExistingMode`
66
+ * only checked the two post-split aliases), so this case restores discoverability rather than
67
+ * silently stranding it. */
68
+ LEGACY_COMBINED(""),
69
+ }
70
+
71
+ /**
72
+ * `.NotFound` and `.Corrupted` are deliberately distinct: `.NotFound` means "there is
73
+ * legitimately nothing here yet" (no metadata has ever been written, or a wallet id has no
74
+ * matching file) and is safe to treat as an empty/absent result. `.Corrupted` means
75
+ * "something is here but it isn't what we expect" (malformed JSON) and must never be
76
+ * silently treated as absent — doing so is exactly how a transient read failure can cause
77
+ * `createWallet` to stomp a real, unreadable index with a fresh one.
78
+ *
79
+ * Extends `CodedException` directly (rather than a flat `Exception`) so `.code` survives the
80
+ * Expo bridge losslessly with no extra wrapping step.
81
+ */
82
+ sealed class NativeWalletStoreError private constructor(code: String, message: String, cause: Throwable? = null) :
83
+ CodedException(code, message, cause) {
84
+
85
+ class NotFound(walletId: String) :
86
+ NativeWalletStoreError("ERR_WALLET_NOT_FOUND", "Wallet not found: $walletId")
87
+
88
+ class Corrupted(detail: String, cause: Throwable? = null) :
89
+ NativeWalletStoreError("ERR_WALLET_DATA_CORRUPTED", "Wallet data is corrupted: $detail", cause)
90
+
91
+ class PermissionDenied(detail: String, cause: Throwable? = null) :
92
+ NativeWalletStoreError("ERR_WALLET_PERMISSION_DENIED", "Permission denied: $detail", cause)
93
+
94
+ class DeleteFailed(walletId: String, cause: Throwable? = null) :
95
+ NativeWalletStoreError("ERR_WALLET_DELETE_FAILED", "Failed to delete wallet: $walletId", cause)
96
+
97
+ class InvalidWalletId(walletId: String) :
98
+ NativeWalletStoreError("ERR_INVALID_WALLET_ID", "Invalid wallet id: $walletId")
99
+
100
+ /** Residual bucket for anything not classified more specifically below. Carries the raw
101
+ * AndroidX `errorCode` (when known) for native-side logging only — the JS-facing `.code`/
102
+ * `.message` are unaffected by it. */
103
+ class AuthenticationFailed(detail: String, val errorCode: Int? = null) :
104
+ NativeWalletStoreError("ERR_AUTHENTICATION_FAILED", "Authentication failed: $detail")
105
+
106
+ /** Thrown at wallet-creation time when neither `BIOMETRIC_STRONG` nor `DEVICE_CREDENTIAL` is
107
+ * available — never create a Keystore key that couldn't possibly be unlocked. */
108
+ class NoSecureAuthAvailable :
109
+ NativeWalletStoreError(
110
+ "ERR_WALLET_NO_SECURE_AUTH",
111
+ "No secure authentication method (biometric or device credential) is available on this device"
112
+ )
113
+
114
+ /** Thrown by the use-time `canAuthenticate()` precheck (before any prompt UI opens) when an
115
+ * existing wallet's already-committed [mode] is no longer satisfiable — e.g. the user removed
116
+ * their only fingerprint, or disabled the screen lock. Distinct from [KeyInvalidated]: this
117
+ * fires on the precheck, before ever touching the Keystore key. */
118
+ class AuthUnavailable(mode: AuthMode, reason: String) :
119
+ NativeWalletStoreError("ERR_WALLET_AUTH_UNAVAILABLE", "Authentication unavailable for $mode: $reason")
120
+
121
+ /** `BiometricPrompt.ERROR_LOCKOUT` — too many failed attempts, temporary; clears itself after
122
+ * a short OS-enforced cooldown. */
123
+ class AuthLockedOutTemporary :
124
+ NativeWalletStoreError("ERR_WALLET_AUTH_LOCKED_OUT", "Too many failed authentication attempts — try again later")
125
+
126
+ /** `BiometricPrompt.ERROR_LOCKOUT_PERMANENT` — biometric auth is disabled until the user
127
+ * unlocks the device with their device credential. */
128
+ class AuthLockedOutPermanent :
129
+ NativeWalletStoreError(
130
+ "ERR_WALLET_AUTH_LOCKED_OUT_PERMANENT",
131
+ "Too many failed authentication attempts — unlock your device to reset"
132
+ )
133
+
134
+ /** User dismissed the prompt (back/negative-button/system-cancel) rather than authentication
135
+ * actually failing. Kept distinct from [AuthenticationFailed] so callers can treat it as a
136
+ * quiet no-op instead of an error to surface. */
137
+ class AuthCancelled :
138
+ NativeWalletStoreError("ERR_WALLET_AUTH_CANCELLED", "Authentication was cancelled")
139
+
140
+ /** `KeyPermanentlyInvalidatedException` from the Keystore — the wallet's key was invalidated
141
+ * by an enrollment or lock-screen change since it was created and can never be unlocked again.
142
+ * There is no recovery for this wallet's on-disk mnemonic file; the user must restore from
143
+ * their recovery phrase. */
144
+ class KeyInvalidated(walletId: String, cause: Throwable? = null) :
145
+ NativeWalletStoreError(
146
+ "ERR_WALLET_KEY_INVALIDATED",
147
+ "Wallet key invalidated by a device security change: $walletId",
148
+ cause
149
+ )
150
+ }
151
+
152
+ /**
153
+ * Persists mnemonics as files encrypted with an Android Keystore AES key (one key per wallet),
154
+ * plus a parallel ungated metadata store (walletId -> per-chain addresses) for read-only UI.
155
+ * Deliberately not `EncryptedSharedPreferences` or wallet-core's `StoredKey` keystore-JSON —
156
+ * confidentiality comes from the Keystore key never leaving secure hardware when the device has
157
+ * any, not from the on-disk file encoding.
158
+ *
159
+ * ### Hardware backing is requested and verified, not assumed
160
+ *
161
+ * [getOrCreateKey] requests the strongest hardware backing available: StrongBox first (API 28+,
162
+ * `setIsStrongBoxBacked(true)`), falling back to a plain (TEE-or-better) Keystore key on
163
+ * `StrongBoxUnavailableException` or below API 28. Android Keystore keys can still end up
164
+ * software-only on devices/emulators without secure hardware, so after generating a fresh key
165
+ * this module inspects its actual [KeyInfo] and logs the real level achieved
166
+ * ([logKeySecurityLevel]) rather than asserting it blindly. Per this module's security model, a
167
+ * software-only key is tolerated (best-effort, never blocks wallet creation) — see the README's
168
+ * Security model section for the full policy.
169
+ *
170
+ * ### Biometric vs. device-credential: two separate flows, not one combined prompt
171
+ *
172
+ * Every wallet's key is gated by exactly one [AuthMode], chosen once at creation
173
+ * ([resolveAvailableMode]) and thereafter recovered from which Keystore alias exists
174
+ * ([resolveExistingMode]). The two modes are handled as genuinely separate flows rather than one
175
+ * `BiometricPrompt` requesting `BIOMETRIC_STRONG | DEVICE_CREDENTIAL` together, because per
176
+ * https://developer.android.com/identity/sign-in/biometric-auth that combination (and
177
+ * `DEVICE_CREDENTIAL` alone via `setAllowedAuthenticators`) is not supported on API 29 and below,
178
+ * and per https://developer.android.com/privacy-and-security/keystore a per-use
179
+ * (`setUserAuthenticationValidityDurationSeconds(-1)`) key is restricted to biometric-only
180
+ * authentication pre-API-30 regardless of what the prompt requests. `BIOMETRIC_STRONG`-mode keeps
181
+ * today's strict "fresh prompt for every single operation" semantics; `DEVICE_CREDENTIAL`-mode
182
+ * uses a short bounded validity window instead (see [getOrCreateKey]) and never binds a
183
+ * `CryptoObject` to its prompt on any API level, since androidx.biometric only added
184
+ * `CryptoObject` support for device-credential auth from API 30 onward. Every prompt path also
185
+ * runs a `BiometricManager.canAuthenticate()` precheck before opening any UI, so availability
186
+ * problems (not enrolled, no hardware, locked out, security patch required) surface as a specific
187
+ * typed error instead of a generic mid-prompt failure.
188
+ *
189
+ * A wallet created before this split shipped still carries a third possible mode,
190
+ * [AuthMode.LEGACY_COMBINED] — [resolveExistingMode] and [authenticateForExistingWallet] both
191
+ * handle it so such a wallet stays reachable, but [resolveAvailableMode] (the only path that
192
+ * chooses a *new* wallet's mode) never produces it. See that case's doc for why it exists.
193
+ */
194
+ object NativeWalletStore {
195
+ private const val TAG = "NativeWalletStore"
196
+ private const val KEY_ALIAS_PREFIX = "vault_wallet_"
197
+ private const val ANDROID_KEYSTORE = "AndroidKeyStore"
198
+ private const val TRANSFORMATION = "AES/GCM/NoPadding"
199
+ private const val GCM_IV_LENGTH = 12
200
+ private const val GCM_TAG_LENGTH_BITS = 128
201
+ private const val WALLETS_DIR = "vault_wallets"
202
+ private const val METADATA_FILE = "metadata.json"
203
+
204
+ /** How long a `DEVICE_CREDENTIAL`-mode key stays usable after a confirmed device-credential
205
+ * unlock. Chosen to comfortably absorb normal prompt-dismiss-to-cipher-init latency without
206
+ * leaving a needlessly wide window open. */
207
+ private const val WINDOW_SECONDS = 30
208
+
209
+ private fun walletsDir(context: Context): File =
210
+ File(context.filesDir, WALLETS_DIR).apply { mkdirs() }
211
+
212
+ private fun mnemonicFile(context: Context, walletId: String): File =
213
+ File(walletsDir(context), "$walletId.enc")
214
+
215
+ private fun metadataFile(context: Context): File =
216
+ File(walletsDir(context), METADATA_FILE)
217
+
218
+ /** Wallet ids are always internally generated as UUIDs (`UUID.randomUUID().toString()`).
219
+ * Any caller-supplied id is validated against that format before it's used to build a file
220
+ * path or Keystore alias, rejecting malformed/adversarial input (e.g. path traversal) up front. */
221
+ fun validateWalletId(walletId: String): String {
222
+ try {
223
+ UUID.fromString(walletId)
224
+ } catch (e: IllegalArgumentException) {
225
+ throw NativeWalletStoreError.InvalidWalletId(walletId)
226
+ }
227
+ return walletId
228
+ }
229
+
230
+ // MARK: - Keystore key management
231
+
232
+ private fun keyStore(): KeyStore = KeyStore.getInstance(ANDROID_KEYSTORE).apply { load(null) }
233
+
234
+ /** Self-describing alias: which [AuthMode] gates a wallet's key is recoverable purely from
235
+ * which of these two aliases exists for it (see [resolveExistingMode]), with no separate
236
+ * metadata field needed. */
237
+ internal fun keyAlias(mode: AuthMode, walletId: String): String = KEY_ALIAS_PREFIX + mode.aliasInfix + walletId
238
+
239
+ private fun getOrCreateKey(walletId: String, mode: AuthMode): SecretKey {
240
+ val alias = keyAlias(mode, walletId)
241
+ val ks = keyStore()
242
+ (ks.getKey(alias, null) as? SecretKey)?.let { return it }
243
+
244
+ val key = generateKey(alias, mode)
245
+ logKeySecurityLevel(alias, key)
246
+ return key
247
+ }
248
+
249
+ /** Builds the [KeyGenParameterSpec] shared by both the StrongBox-requested attempt and its
250
+ * fallback — everything except whether StrongBox is requested is identical, so this is
251
+ * parameterized on [strongBox] rather than duplicated. */
252
+ private fun buildKeySpec(alias: String, mode: AuthMode, strongBox: Boolean): KeyGenParameterSpec {
253
+ val builder = KeyGenParameterSpec.Builder(alias, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT)
254
+ .setBlockModes(KeyProperties.BLOCK_MODE_GCM)
255
+ .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
256
+ .setUserAuthenticationRequired(true)
257
+
258
+ when (mode) {
259
+ AuthMode.BIOMETRIC_STRONG ->
260
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
261
+ // Single authenticator type only (no DEVICE_CREDENTIAL bit) — combining types is
262
+ // unsupported pre-API-30, which is exactly why DEVICE_CREDENTIAL is a wholly separate
263
+ // mode/key rather than an OR'd-in fallback on this same key.
264
+ builder.setUserAuthenticationParameters(0, KeyProperties.AUTH_BIOMETRIC_STRONG)
265
+ } else {
266
+ // Pre-R, a validity duration of -1 is documented to restrict the key to biometric
267
+ // authentication only (https://developer.android.com/privacy-and-security/keystore)
268
+ // — exactly the semantics this mode wants, with no explicit type parameter available
269
+ // at this API level.
270
+ @Suppress("DEPRECATION")
271
+ builder.setUserAuthenticationValidityDurationSeconds(-1)
272
+ }
273
+
274
+ AuthMode.DEVICE_CREDENTIAL ->
275
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
276
+ builder.setUserAuthenticationParameters(WINDOW_SECONDS, KeyProperties.AUTH_DEVICE_CREDENTIAL)
277
+ } else {
278
+ // Pre-R has no type-restriction parameter for a windowed key — it accepts a recent
279
+ // keyguard unlock by any registered method. If the user later enrolls a fingerprint, a
280
+ // biometric unlock within the window would also satisfy this key; that's an
281
+ // unavoidable platform limitation of setUserAuthenticationValidityDurationSeconds, not
282
+ // a bug here (see the class doc and the manual test matrix in the remediation plan).
283
+ @Suppress("DEPRECATION")
284
+ builder.setUserAuthenticationValidityDurationSeconds(WINDOW_SECONDS)
285
+ }
286
+
287
+ AuthMode.LEGACY_COMBINED ->
288
+ // Unreachable in practice: getOrCreateKey only calls this when no existing alias was
289
+ // found, and LEGACY_COMBINED is only ever returned by resolveExistingMode for an alias
290
+ // that, by definition, already exists — resolveAvailableMode (the only source of a
291
+ // *new* wallet's mode) never returns it. Fails loudly rather than silently minting a
292
+ // new key under a scheme this codebase deliberately stopped creating.
293
+ error("LEGACY_COMBINED keys are never freshly generated — resolveExistingMode found alias '$alias' but getOrCreateKey couldn't retrieve it")
294
+ }
295
+
296
+ if (strongBox) {
297
+ builder.setIsStrongBoxBacked(true)
298
+ }
299
+
300
+ return builder.build()
301
+ }
302
+
303
+ /** Requests the strongest hardware backing available for a brand-new key: StrongBox first
304
+ * (API 28+), falling back to a plain Keystore key — which Keymaster may still back with a TEE
305
+ * or, on devices without secure hardware, software only — on [StrongBoxUnavailableException]
306
+ * or below API 28 (`setIsStrongBoxBacked` doesn't exist pre-P). The actual level achieved is
307
+ * verified separately by [logKeySecurityLevel]; this function never inspects it. */
308
+ private fun generateKey(alias: String, mode: AuthMode): SecretKey {
309
+ val keyGenerator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, ANDROID_KEYSTORE)
310
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
311
+ try {
312
+ keyGenerator.init(buildKeySpec(alias, mode, strongBox = true))
313
+ return keyGenerator.generateKey()
314
+ } catch (e: StrongBoxUnavailableException) {
315
+ Log.i(TAG, "StrongBox unavailable for $alias, falling back to a non-StrongBox key", e)
316
+ }
317
+ }
318
+ keyGenerator.init(buildKeySpec(alias, mode, strongBox = false))
319
+ return keyGenerator.generateKey()
320
+ }
321
+
322
+ /** Pure `KeyInfo.securityLevel` -> human-readable-level mapping (API 31+), extracted so it's
323
+ * JVM-testable without a real Keystore key. */
324
+ internal fun describeSecurityLevel(securityLevel: Int): String = when (securityLevel) {
325
+ KeyProperties.SECURITY_LEVEL_STRONGBOX -> "STRONGBOX"
326
+ KeyProperties.SECURITY_LEVEL_TRUSTED_ENVIRONMENT -> "TEE"
327
+ KeyProperties.SECURITY_LEVEL_SOFTWARE -> "SOFTWARE"
328
+ else -> "UNKNOWN($securityLevel)"
329
+ }
330
+
331
+ /** Pure legacy `KeyInfo.isInsideSecureHardware` -> human-readable-level mapping (below API 31,
332
+ * where `getSecurityLevel()` doesn't exist and TEE vs. StrongBox can't be distinguished),
333
+ * extracted so it's JVM-testable without a real Keystore key. */
334
+ internal fun describeLegacySecurityLevel(insideSecureHardware: Boolean): String =
335
+ if (insideSecureHardware) "HARDWARE" else "SOFTWARE"
336
+
337
+ /** Verifies and logs the actual security level of a freshly generated key — best-effort only:
338
+ * this module's security model tolerates a software-only key (e.g. an emulator, or a device
339
+ * with no secure hardware at all) rather than blocking wallet creation, so this never throws
340
+ * on either a software-only result or an introspection failure, it only logs. Only called for
341
+ * freshly generated keys, not ones retrieved from an existing alias — the level can't change
342
+ * after creation, so re-checking on every retrieval would just be log noise. */
343
+ private fun logKeySecurityLevel(alias: String, key: SecretKey) {
344
+ try {
345
+ // SecretKeyFactory, not KeyFactory — KeyFactory is for asymmetric KeyPair material
346
+ // (PrivateKey/PublicKey); AndroidKeyStore only registers a symmetric-key ("AES") service
347
+ // under SecretKeyFactory, so KeyFactory.getInstance("AES", "AndroidKeyStore") throws
348
+ // NoSuchAlgorithmException for a SecretKey like this one.
349
+ // The Android stub's getKeySpec(SecretKey, Class<?>) is non-generic (unlike the desktop
350
+ // JDK's), returning a raw KeySpec — an explicit cast to KeyInfo is required here.
351
+ val keyInfo = SecretKeyFactory.getInstance(KeyProperties.KEY_ALGORITHM_AES, ANDROID_KEYSTORE)
352
+ .getKeySpec(key, KeyInfo::class.java) as KeyInfo
353
+ val description = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
354
+ describeSecurityLevel(keyInfo.securityLevel)
355
+ } else {
356
+ @Suppress("DEPRECATION")
357
+ describeLegacySecurityLevel(keyInfo.isInsideSecureHardware)
358
+ }
359
+ if (description == "SOFTWARE") {
360
+ Log.w(TAG, "Keystore key $alias is NOT hardware-backed (level=$description) — this device has no usable secure hardware, falling back to software-only protection")
361
+ } else {
362
+ Log.i(TAG, "Keystore key $alias security level: $description")
363
+ }
364
+ } catch (e: Exception) {
365
+ Log.w(TAG, "Could not determine security level for Keystore key $alias", e)
366
+ }
367
+ }
368
+
369
+ /** Resolves which [AuthMode] to gate a *new* wallet's key with, preferring `BIOMETRIC_STRONG`
370
+ * and falling back to `DEVICE_CREDENTIAL`. Throws [NativeWalletStoreError.NoSecureAuthAvailable]
371
+ * rather than ever creating a key that couldn't possibly be unlocked. */
372
+ private fun resolveAvailableMode(context: Context): AuthMode {
373
+ val biometricManager = BiometricManager.from(context)
374
+ if (biometricManager.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_STRONG) ==
375
+ BiometricManager.BIOMETRIC_SUCCESS
376
+ ) {
377
+ return AuthMode.BIOMETRIC_STRONG
378
+ }
379
+ if (deviceCredentialAvailable(context, biometricManager)) {
380
+ return AuthMode.DEVICE_CREDENTIAL
381
+ }
382
+ throw NativeWalletStoreError.NoSecureAuthAvailable()
383
+ }
384
+
385
+ /** `BiometricManager.canAuthenticate(DEVICE_CREDENTIAL)` is itself unsupported pre-API-30
386
+ * (same restriction as the combined-authenticator case), so pre-30 this asks the keyguard
387
+ * directly whether a screen lock is set instead. */
388
+ private fun deviceCredentialAvailable(context: Context, biometricManager: BiometricManager): Boolean =
389
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
390
+ biometricManager.canAuthenticate(BiometricManager.Authenticators.DEVICE_CREDENTIAL) ==
391
+ BiometricManager.BIOMETRIC_SUCCESS
392
+ } else {
393
+ ContextCompat.getSystemService(context, KeyguardManager::class.java)?.isDeviceSecure == true
394
+ }
395
+
396
+ /** Recovers which [AuthMode] an *existing* wallet's key was created with, purely from which
397
+ * Keystore alias exists — see [keyAlias]. Checks [AuthMode.LEGACY_COMBINED] last: a wallet
398
+ * created before the biometric/device-credential split (see that case's doc) would otherwise
399
+ * be permanently unreachable despite its key and metadata both still being intact — this was
400
+ * found to actually happen, not just a theoretical gap. */
401
+ private fun resolveExistingMode(walletId: String): AuthMode {
402
+ val ks = keyStore()
403
+ if (ks.containsAlias(keyAlias(AuthMode.BIOMETRIC_STRONG, walletId))) return AuthMode.BIOMETRIC_STRONG
404
+ if (ks.containsAlias(keyAlias(AuthMode.DEVICE_CREDENTIAL, walletId))) return AuthMode.DEVICE_CREDENTIAL
405
+ if (ks.containsAlias(keyAlias(AuthMode.LEGACY_COMBINED, walletId))) return AuthMode.LEGACY_COMBINED
406
+ throw NativeWalletStoreError.NotFound(walletId)
407
+ }
408
+
409
+ /** The `canAuthenticate()` precheck required before every use-time prompt (not just at
410
+ * creation): confirms the wallet's already-committed [mode] is still satisfiable *before* any
411
+ * prompt UI opens, so an enrollment/lock-screen change surfaces as a specific
412
+ * [NativeWalletStoreError.AuthUnavailable] instead of a generic mid-prompt failure. */
413
+ private fun precheckExistingMode(context: Context, mode: AuthMode) {
414
+ val biometricManager = BiometricManager.from(context)
415
+ val available = when (mode) {
416
+ AuthMode.BIOMETRIC_STRONG ->
417
+ biometricManager.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_STRONG) ==
418
+ BiometricManager.BIOMETRIC_SUCCESS
419
+ AuthMode.DEVICE_CREDENTIAL -> deviceCredentialAvailable(context, biometricManager)
420
+ // Accepts either, same as the key itself does — mirrors resolveAvailableMode's
421
+ // BIOMETRIC_STRONG-or-DEVICE_CREDENTIAL precedence rather than requiring both.
422
+ AuthMode.LEGACY_COMBINED ->
423
+ biometricManager.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_STRONG) ==
424
+ BiometricManager.BIOMETRIC_SUCCESS || deviceCredentialAvailable(context, biometricManager)
425
+ }
426
+ if (!available) {
427
+ val result = if (mode == AuthMode.BIOMETRIC_STRONG) {
428
+ biometricManager.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_STRONG)
429
+ } else {
430
+ BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED
431
+ }
432
+ throw NativeWalletStoreError.AuthUnavailable(mode, describeUnavailableReason(result))
433
+ }
434
+ }
435
+
436
+ /** Pure `canAuthenticate()`-result -> human-readable-reason mapping, extracted so it's
437
+ * JVM-testable without a real `BiometricManager`. */
438
+ internal fun describeUnavailableReason(canAuthenticateResult: Int): String = when (canAuthenticateResult) {
439
+ BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED -> "no biometric or device credential is enrolled"
440
+ BiometricManager.BIOMETRIC_ERROR_NO_HARDWARE -> "no biometric hardware present"
441
+ BiometricManager.BIOMETRIC_ERROR_HW_UNAVAILABLE -> "biometric hardware currently unavailable"
442
+ BiometricManager.BIOMETRIC_ERROR_SECURITY_UPDATE_REQUIRED -> "a security update is required"
443
+ BiometricManager.BIOMETRIC_ERROR_UNSUPPORTED -> "authentication is unsupported on this device"
444
+ BiometricManager.BIOMETRIC_STATUS_UNKNOWN -> "authentication status could not be determined"
445
+ else -> "unavailable (status $canAuthenticateResult)"
446
+ }
447
+
448
+ private fun buildEncryptCipher(walletId: String, mode: AuthMode): Cipher {
449
+ val cipher = Cipher.getInstance(TRANSFORMATION)
450
+ initCipherOrThrow(walletId) { cipher.init(Cipher.ENCRYPT_MODE, getOrCreateKey(walletId, mode)) }
451
+ return cipher
452
+ }
453
+
454
+ private fun buildDecryptCipher(context: Context, walletId: String, mode: AuthMode): Cipher {
455
+ val file = mnemonicFile(context, walletId)
456
+ if (!file.exists()) throw NativeWalletStoreError.NotFound(walletId)
457
+ val iv = file.readBytes().copyOfRange(0, GCM_IV_LENGTH)
458
+ val cipher = Cipher.getInstance(TRANSFORMATION)
459
+ initCipherOrThrow(walletId) {
460
+ cipher.init(Cipher.DECRYPT_MODE, getOrCreateKey(walletId, mode), GCMParameterSpec(GCM_TAG_LENGTH_BITS, iv))
461
+ }
462
+ return cipher
463
+ }
464
+
465
+ /** Centralizes the two ways a Keystore-backed `Cipher.init()` can fail for an auth-gated key:
466
+ * permanently (enrollment/lock-screen changed since the key was created — unrecoverable) or
467
+ * transiently (a `DEVICE_CREDENTIAL`-mode window that closed before the cipher was opened —
468
+ * the caller should just prompt again). */
469
+ private inline fun initCipherOrThrow(walletId: String, init: () -> Unit) {
470
+ try {
471
+ init()
472
+ } catch (e: KeyPermanentlyInvalidatedException) {
473
+ throw NativeWalletStoreError.KeyInvalidated(walletId, e)
474
+ } catch (e: UserNotAuthenticatedException) {
475
+ throw NativeWalletStoreError.AuthenticationFailed("authentication window expired before the cipher could be opened")
476
+ }
477
+ }
478
+
479
+ // MARK: - Mnemonic (biometry/device-credential gated)
480
+
481
+ /** Encrypts and writes the mnemonic. Also requires user authentication (the key itself is
482
+ * auth-gated for every use, encrypt included) — callers should invoke this right after a
483
+ * successful [authenticateForNewWallet] call, same as [loadMnemonic] after
484
+ * [authenticateForExistingWallet]. */
485
+ fun saveMnemonic(context: Context, walletId: String, mnemonic: String, authenticatedCipher: Cipher) {
486
+ val iv = authenticatedCipher.iv
487
+ val ciphertext = authenticatedCipher.doFinal(mnemonic.toByteArray(Charsets.UTF_8))
488
+ mnemonicFile(context, walletId).writeBytes(iv + ciphertext)
489
+ }
490
+
491
+ fun loadMnemonic(context: Context, walletId: String, authenticatedCipher: Cipher): String {
492
+ val file = mnemonicFile(context, walletId)
493
+ if (!file.exists()) throw NativeWalletStoreError.NotFound(walletId)
494
+ val bytes = file.readBytes()
495
+ val ciphertext = bytes.copyOfRange(GCM_IV_LENGTH, bytes.size)
496
+ return String(authenticatedCipher.doFinal(ciphertext), Charsets.UTF_8)
497
+ }
498
+
499
+ /** Idempotent: deleting a wallet id whose file is already gone is a no-op, matching normal
500
+ * `deleteWallet` semantics. The delete result is checked and propagates on failure rather
501
+ * than being silently discarded. Pure/`File`-based (no `Context`) so it's unit-testable on
502
+ * the plain JVM without an Android `Context`/Keystore, unlike [deleteMnemonic] as a whole. */
503
+ internal fun deleteFileChecked(file: File, walletId: String) {
504
+ if (file.exists() && !file.delete()) {
505
+ throw NativeWalletStoreError.DeleteFailed(walletId)
506
+ }
507
+ }
508
+
509
+ /** Idempotent: deleting a wallet id whose file is already gone is a no-op, matching normal
510
+ * `deleteWallet` semantics. Both the file deletion and the Keystore-entry deletion results
511
+ * are checked and propagate on failure — neither is silently discarded. Tries all three
512
+ * possible aliases, including [AuthMode.LEGACY_COMBINED] (only one will ever exist for a given
513
+ * wallet), so cleanup doesn't need to know which mode a wallet used — `KeyStore.deleteEntry` on
514
+ * the AndroidKeyStore provider is a documented no-op (not a throw) for an alias that doesn't
515
+ * exist. */
516
+ fun deleteMnemonic(context: Context, walletId: String) {
517
+ deleteFileChecked(mnemonicFile(context, walletId), walletId)
518
+ try {
519
+ val ks = keyStore()
520
+ ks.deleteEntry(keyAlias(AuthMode.BIOMETRIC_STRONG, walletId))
521
+ ks.deleteEntry(keyAlias(AuthMode.DEVICE_CREDENTIAL, walletId))
522
+ ks.deleteEntry(keyAlias(AuthMode.LEGACY_COMBINED, walletId))
523
+ } catch (e: Exception) {
524
+ throw NativeWalletStoreError.DeleteFailed(walletId, e)
525
+ }
526
+ }
527
+
528
+ // MARK: - Metadata (ungated: walletId -> { chain: address })
529
+
530
+ /** Atomically replaces [target] via a temp-file write + `File.renameTo` (an atomic
531
+ * `rename(2)` on the same filesystem/mount, since the temp file is created alongside
532
+ * [target] in the same directory) — never a direct in-place overwrite, which could leave a
533
+ * torn file if the process is killed mid-write. Pure/`File`-based so it's unit-testable on
534
+ * the plain JVM without an Android `Context`. */
535
+ internal fun saveMetadataToFile(target: File, wallets: Map<String, Map<String, String>>) {
536
+ val root = JSONObject()
537
+ for ((walletId, addresses) in wallets) {
538
+ root.put(walletId, JSONObject(addresses as Map<*, *>))
539
+ }
540
+ val temp = File(target.parentFile, "$METADATA_FILE.tmp-${System.nanoTime()}")
541
+ try {
542
+ temp.writeText(root.toString())
543
+ } catch (e: Exception) {
544
+ temp.delete()
545
+ throw NativeWalletStoreError.PermissionDenied("could not write metadata temp file", e)
546
+ }
547
+ if (!temp.renameTo(target)) {
548
+ temp.delete()
549
+ throw NativeWalletStoreError.Corrupted("failed to atomically replace metadata file")
550
+ }
551
+ }
552
+
553
+ /** Distinguishes "no metadata has ever been written" (legitimately empty) from a genuine
554
+ * parse/corruption failure, which now throws a typed [NativeWalletStoreError.Corrupted]
555
+ * instead of letting a raw, uncaught `JSONException` leak through the Expo bridge.
556
+ * Pure/`File`-based so it's unit-testable on the plain JVM without an Android `Context`. */
557
+ internal fun loadMetadataFromFile(file: File): Map<String, Map<String, String>> {
558
+ if (!file.exists()) return emptyMap()
559
+ val root = try {
560
+ JSONObject(file.readText())
561
+ } catch (e: JSONException) {
562
+ throw NativeWalletStoreError.Corrupted("metadata.json is not valid JSON", e)
563
+ }
564
+ val result = mutableMapOf<String, Map<String, String>>()
565
+ for (walletId in root.keys()) {
566
+ val addressesJson = root.getJSONObject(walletId)
567
+ val addresses = mutableMapOf<String, String>()
568
+ for (chain in addressesJson.keys()) {
569
+ addresses[chain] = addressesJson.getString(chain)
570
+ }
571
+ result[walletId] = addresses
572
+ }
573
+ return result
574
+ }
575
+
576
+ fun saveMetadata(context: Context, wallets: Map<String, Map<String, String>>) {
577
+ saveMetadataToFile(metadataFile(context), wallets)
578
+ }
579
+
580
+ fun loadMetadata(context: Context): Map<String, Map<String, String>> {
581
+ return loadMetadataFromFile(metadataFile(context))
582
+ }
583
+
584
+ // MARK: - Biometric/device-credential prompt
585
+
586
+ /** Authenticates and returns a `Cipher` ready for [saveMnemonic], for a brand-new wallet id.
587
+ * Resolves which [AuthMode] to gate the new key with via [resolveAvailableMode] (a
588
+ * `canAuthenticate()`-based precheck) before creating anything. */
589
+ suspend fun authenticateForNewWallet(
590
+ activity: FragmentActivity,
591
+ context: Context,
592
+ walletId: String,
593
+ title: String,
594
+ ): Cipher =
595
+ when (val mode = resolveAvailableMode(context)) {
596
+ AuthMode.BIOMETRIC_STRONG -> authenticateBiometric(activity, buildEncryptCipher(walletId, mode), title)
597
+ AuthMode.DEVICE_CREDENTIAL -> {
598
+ confirmDeviceCredential(activity, title)
599
+ buildEncryptCipher(walletId, mode)
600
+ }
601
+ // Unreachable: resolveAvailableMode never returns LEGACY_COMBINED — see that case's doc.
602
+ AuthMode.LEGACY_COMBINED -> error("resolveAvailableMode returned LEGACY_COMBINED, which it must never do")
603
+ }
604
+
605
+ /** Authenticates and returns a `Cipher` ready for [loadMnemonic], for an existing wallet id.
606
+ * Recovers the wallet's already-committed [AuthMode] via [resolveExistingMode] and runs the
607
+ * [precheckExistingMode] availability check before opening any prompt UI. */
608
+ suspend fun authenticateForExistingWallet(
609
+ activity: FragmentActivity,
610
+ context: Context,
611
+ walletId: String,
612
+ title: String,
613
+ ): Cipher {
614
+ val mode = resolveExistingMode(walletId)
615
+ precheckExistingMode(context, mode)
616
+ return when (mode) {
617
+ AuthMode.BIOMETRIC_STRONG -> authenticateBiometric(activity, buildDecryptCipher(context, walletId, mode), title)
618
+ AuthMode.DEVICE_CREDENTIAL -> {
619
+ confirmDeviceCredential(activity, title)
620
+ buildDecryptCipher(context, walletId, mode)
621
+ }
622
+ AuthMode.LEGACY_COMBINED ->
623
+ authenticateCombinedLegacy(activity, buildDecryptCipher(context, walletId, mode), title)
624
+ }
625
+ }
626
+
627
+ /** `BIOMETRIC_STRONG`-only, `CryptoObject`-bound prompt — the cipher is built+`init()`'d
628
+ * *before* this is called; the prompt authorizes that specific already-initialized operation
629
+ * handle, per the standard Keystore per-op-key pattern (unchanged from before this
630
+ * remediation). Same main-thread requirement as [confirmDeviceCredential]: `BiometricPrompt`
631
+ * drives a `FragmentManager` transaction, so this — and this call — must run on the main
632
+ * thread; callers reach this via `AsyncFunction(...) Coroutine { ... }`, which Expo dispatches
633
+ * on a background HandlerThread, not main. */
634
+ private suspend fun authenticateBiometric(activity: FragmentActivity, cipher: Cipher, title: String): Cipher =
635
+ withContext(Dispatchers.Main) {
636
+ suspendCoroutine { continuation ->
637
+ val prompt = biometricPrompt(
638
+ activity,
639
+ onSucceeded = { result ->
640
+ val authenticatedCipher = result.cryptoObject?.cipher
641
+ if (authenticatedCipher == null) {
642
+ continuation.resumeWithException(
643
+ NativeWalletStoreError.AuthenticationFailed("no authenticated cipher returned")
644
+ )
645
+ } else {
646
+ continuation.resume(authenticatedCipher)
647
+ }
648
+ },
649
+ onError = { code, errString -> continuation.resumeWithException(classifyPromptError(code, errString)) }
650
+ )
651
+ prompt.authenticate(
652
+ singleAuthenticatorPromptInfo(title, BiometricManager.Authenticators.BIOMETRIC_STRONG),
653
+ BiometricPrompt.CryptoObject(cipher)
654
+ )
655
+ }
656
+ }
657
+
658
+ /** Pre-migration combined `BIOMETRIC_STRONG | DEVICE_CREDENTIAL`, `CryptoObject`-bound prompt —
659
+ * this is exactly what [authenticateBiometric] replaced, preserved solely so an
660
+ * [AuthMode.LEGACY_COMBINED] wallet (created before the split) stays unlockable. Never used for
661
+ * a new key. Carries the same reliability caveat the split was written to fix: this combination
662
+ * isn't reliably supported by `BiometricPrompt` on API 29 and below — an existing, unavoidable
663
+ * (short of forcing every such wallet through a re-encrypt) limitation for old wallets on old
664
+ * API levels, not a new regression. */
665
+ private suspend fun authenticateCombinedLegacy(activity: FragmentActivity, cipher: Cipher, title: String): Cipher =
666
+ withContext(Dispatchers.Main) {
667
+ suspendCoroutine { continuation ->
668
+ val prompt = biometricPrompt(
669
+ activity,
670
+ onSucceeded = { result ->
671
+ val authenticatedCipher = result.cryptoObject?.cipher
672
+ if (authenticatedCipher == null) {
673
+ continuation.resumeWithException(
674
+ NativeWalletStoreError.AuthenticationFailed("no authenticated cipher returned")
675
+ )
676
+ } else {
677
+ continuation.resume(authenticatedCipher)
678
+ }
679
+ },
680
+ onError = { code, errString -> continuation.resumeWithException(classifyPromptError(code, errString)) }
681
+ )
682
+ prompt.authenticate(
683
+ singleAuthenticatorPromptInfo(
684
+ title,
685
+ BiometricManager.Authenticators.BIOMETRIC_STRONG or BiometricManager.Authenticators.DEVICE_CREDENTIAL
686
+ ),
687
+ BiometricPrompt.CryptoObject(cipher)
688
+ )
689
+ }
690
+ }
691
+
692
+ /** Crypto-object-less confirmation prompt — for gating operations like `deleteWallet` that
693
+ * don't perform a Keystore encrypt/decrypt themselves, so there's no `Cipher` to bind the
694
+ * prompt to (and binding to one would wrongly fail when e.g. cleaning up a wallet whose key
695
+ * is already broken/missing). Resolves availability the same way wallet creation does, since
696
+ * deleting isn't tied to any specific wallet's committed key mode — this always chooses between
697
+ * the two *creatable* modes, never [AuthMode.LEGACY_COMBINED] (see [resolveAvailableMode]). */
698
+ suspend fun confirmIdentity(activity: FragmentActivity, context: Context, title: String) {
699
+ when (resolveAvailableMode(context)) {
700
+ AuthMode.BIOMETRIC_STRONG -> confirmSingleAuthenticator(activity, title, BiometricManager.Authenticators.BIOMETRIC_STRONG)
701
+ AuthMode.DEVICE_CREDENTIAL -> confirmDeviceCredential(activity, title)
702
+ // Unreachable: resolveAvailableMode never returns LEGACY_COMBINED — see that case's doc.
703
+ AuthMode.LEGACY_COMBINED -> error("resolveAvailableMode returned LEGACY_COMBINED, which it must never do")
704
+ }
705
+ }
706
+
707
+ private suspend fun confirmSingleAuthenticator(activity: FragmentActivity, title: String, authenticator: Int) {
708
+ withContext(Dispatchers.Main) {
709
+ suspendCoroutine<Unit> { continuation ->
710
+ val prompt = biometricPrompt(
711
+ activity,
712
+ onSucceeded = { continuation.resume(Unit) },
713
+ onError = { code, errString -> continuation.resumeWithException(classifyPromptError(code, errString)) }
714
+ )
715
+ prompt.authenticate(singleAuthenticatorPromptInfo(title, authenticator))
716
+ }
717
+ }
718
+ }
719
+
720
+ /** Device-credential-only confirmation, deliberately never `CryptoObject`-bound on any API
721
+ * level (see the class doc for why). Used both directly by [confirmIdentity] and as the first
722
+ * step of [authenticateForNewWallet]/[authenticateForExistingWallet]'s `DEVICE_CREDENTIAL`
723
+ * branch, where the cipher is built immediately *after* this succeeds instead of being bound
724
+ * to the prompt. */
725
+ private suspend fun confirmDeviceCredential(activity: FragmentActivity, title: String) {
726
+ withContext(Dispatchers.Main) {
727
+ suspendCoroutine<Unit> { continuation ->
728
+ val prompt = biometricPrompt(
729
+ activity,
730
+ onSucceeded = { continuation.resume(Unit) },
731
+ onError = { code, errString -> continuation.resumeWithException(classifyPromptError(code, errString)) }
732
+ )
733
+ prompt.authenticate(deviceCredentialPromptInfo(title))
734
+ }
735
+ }
736
+ }
737
+
738
+ private fun singleAuthenticatorPromptInfo(title: String, authenticator: Int): BiometricPrompt.PromptInfo =
739
+ BiometricPrompt.PromptInfo.Builder()
740
+ .setTitle(title)
741
+ .setAllowedAuthenticators(authenticator)
742
+ .build()
743
+
744
+ private fun deviceCredentialPromptInfo(title: String): BiometricPrompt.PromptInfo =
745
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
746
+ singleAuthenticatorPromptInfo(title, BiometricManager.Authenticators.DEVICE_CREDENTIAL)
747
+ } else {
748
+ // setAllowedAuthenticators(DEVICE_CREDENTIAL) alone is unsupported pre-API-30; the
749
+ // deprecated setDeviceCredentialAllowed(true) is the only way to request a
750
+ // device-credential-only confirmation on API 24-29, and never supports a CryptoObject —
751
+ // exactly why DEVICE_CREDENTIAL mode never binds one, on any API level.
752
+ @Suppress("DEPRECATION")
753
+ BiometricPrompt.PromptInfo.Builder()
754
+ .setTitle(title)
755
+ .setDeviceCredentialAllowed(true)
756
+ .build()
757
+ }
758
+
759
+ /** Pure `errorCode` -> typed-error mapping, extracted so it's JVM-testable without a real
760
+ * `BiometricPrompt`. */
761
+ internal fun classifyPromptError(errorCode: Int, errString: String): NativeWalletStoreError = when (errorCode) {
762
+ BiometricPrompt.ERROR_LOCKOUT -> NativeWalletStoreError.AuthLockedOutTemporary()
763
+ BiometricPrompt.ERROR_LOCKOUT_PERMANENT -> NativeWalletStoreError.AuthLockedOutPermanent()
764
+ BiometricPrompt.ERROR_USER_CANCELED,
765
+ BiometricPrompt.ERROR_NEGATIVE_BUTTON,
766
+ BiometricPrompt.ERROR_CANCELED,
767
+ -> NativeWalletStoreError.AuthCancelled()
768
+ else -> NativeWalletStoreError.AuthenticationFailed(errString, errorCode)
769
+ }
770
+
771
+ private fun biometricPrompt(
772
+ activity: FragmentActivity,
773
+ onSucceeded: (BiometricPrompt.AuthenticationResult) -> Unit,
774
+ onError: (Int, String) -> Unit,
775
+ ): BiometricPrompt {
776
+ val executor = ContextCompat.getMainExecutor(activity)
777
+ return BiometricPrompt(
778
+ activity,
779
+ executor,
780
+ object : BiometricPrompt.AuthenticationCallback() {
781
+ override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
782
+ onSucceeded(result)
783
+ }
784
+
785
+ override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
786
+ onError(errorCode, errString.toString())
787
+ }
788
+
789
+ override fun onAuthenticationFailed() {
790
+ // Not terminal — BiometricPrompt keeps the sheet open for retry; only
791
+ // onAuthenticationError/onAuthenticationSucceeded resolve the continuation.
792
+ }
793
+ }
794
+ )
795
+ }
796
+ }