@chainberry/trust-wallet-core 2.0.0 → 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.
- package/{TrustWalletCoreModule.podspec → ChainberryTrustWalletCoreModule.podspec} +2 -2
- package/README.md +15 -22
- package/android/build.gradle +29 -6
- package/android/libs/README.md +34 -0
- package/android/libs/com/trustwallet/wallet-core/4.1.19/wallet-core-4.1.19.aar +0 -0
- package/android/libs/com/trustwallet/wallet-core/4.1.19/wallet-core-4.1.19.aar.md5 +1 -0
- package/android/libs/com/trustwallet/wallet-core/4.1.19/wallet-core-4.1.19.aar.sha1 +1 -0
- package/android/libs/com/trustwallet/wallet-core/4.1.19/wallet-core-4.1.19.pom +22 -0
- package/android/libs/com/trustwallet/wallet-core/4.1.19/wallet-core-4.1.19.pom.md5 +1 -0
- package/android/libs/com/trustwallet/wallet-core/4.1.19/wallet-core-4.1.19.pom.sha1 +1 -0
- package/android/libs/com/trustwallet/wallet-core-proto/4.1.19/wallet-core-proto-4.1.19.jar +0 -0
- package/android/libs/com/trustwallet/wallet-core-proto/4.1.19/wallet-core-proto-4.1.19.jar.md5 +1 -0
- package/android/libs/com/trustwallet/wallet-core-proto/4.1.19/wallet-core-proto-4.1.19.jar.sha1 +1 -0
- package/android/libs/com/trustwallet/wallet-core-proto/4.1.19/wallet-core-proto-4.1.19.pom +21 -0
- package/android/libs/com/trustwallet/wallet-core-proto/4.1.19/wallet-core-proto-4.1.19.pom.md5 +1 -0
- package/android/libs/com/trustwallet/wallet-core-proto/4.1.19/wallet-core-proto-4.1.19.pom.sha1 +1 -0
- package/android/libs/download.sh +52 -0
- package/android/src/androidTest/java/com/chainberry/trustwalletcore/AddressDerivationConformanceTest.kt +106 -0
- package/android/src/androidTest/java/com/chainberry/trustwalletcore/SigningConformanceTest.kt +186 -0
- package/android/src/main/java/com/chainberry/trustwalletcore/AmountParsing.kt +45 -0
- package/android/src/main/java/com/chainberry/trustwalletcore/Bech32.kt +68 -0
- package/android/src/main/java/com/chainberry/trustwalletcore/ChainSigning.kt +526 -0
- package/android/src/main/java/com/chainberry/trustwalletcore/ChainberryTrustWalletCoreModule.kt +147 -0
- package/android/src/main/java/com/chainberry/trustwalletcore/NativeWalletStore.kt +796 -0
- package/android/src/test/java/com/chainberry/trustwalletcore/AmountParsingConformanceTest.kt +57 -0
- package/android/src/test/java/com/chainberry/trustwalletcore/Bech32Test.kt +35 -0
- package/android/src/test/java/com/chainberry/trustwalletcore/NativeWalletStoreTest.kt +274 -0
- package/expo-module.config.json +3 -2
- package/ios/AmountParsing.swift +62 -0
- package/ios/Bech32.swift +66 -0
- package/ios/ChainSigning.swift +602 -0
- package/ios/ChainberryTrustWalletCoreModule.swift +231 -0
- package/ios/NativeWalletStore.swift +232 -0
- package/package.json +4 -3
- package/src/index.ts +27 -12
- package/android/src/main/java/expo/modules/trustwalletcore/ChainSigning.kt +0 -299
- package/android/src/main/java/expo/modules/trustwalletcore/NativeWalletStore.kt +0 -182
- package/android/src/main/java/expo/modules/trustwalletcore/TrustWalletCoreModule.kt +0 -91
- package/ios/TrustWalletCoreModule.swift +0 -107
|
@@ -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
|
+
}
|