@chainberry/trust-wallet-core 2.0.0 → 2.5.1

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