@chainberry/trust-wallet-core 2.5.0 → 2.5.2
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/ChainberryTrustWalletCoreModule.podspec +9 -2
- package/README.md +42 -5
- package/android/src/androidTest/java/com/chainberry/trustwalletcore/AddressDerivationConformanceTest.kt +4 -4
- package/android/src/androidTest/java/com/chainberry/trustwalletcore/SigningConformanceTest.kt +2 -2
- package/android/src/main/java/com/chainberry/trustwalletcore/ChainSigning.kt +393 -18
- package/android/src/main/java/com/chainberry/trustwalletcore/ChainberryTrustWalletCoreModule.kt +101 -18
- package/android/src/main/java/com/chainberry/trustwalletcore/NativeWalletStore.kt +128 -18
- package/android/src/test/java/com/chainberry/trustwalletcore/NativeWalletStoreTest.kt +92 -4
- package/ios/ChainSigning.swift +430 -40
- package/ios/ChainberryTrustWalletCoreModule.swift +131 -20
- package/ios/ConformanceTests/AddressDerivationConformanceTests.swift +39 -0
- package/ios/ConformanceTests/SigningConformanceTests.swift +292 -0
- package/ios/NativeWalletStore.swift +73 -6
- package/package.json +1 -1
- package/src/index.ts +24 -10
package/android/src/main/java/com/chainberry/trustwalletcore/ChainberryTrustWalletCoreModule.kt
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
package com.chainberry.trustwalletcore
|
|
2
2
|
|
|
3
3
|
import android.app.AlertDialog
|
|
4
|
+
import android.util.Log
|
|
4
5
|
import androidx.fragment.app.FragmentActivity
|
|
5
6
|
import expo.modules.kotlin.exception.CodedException
|
|
6
7
|
import expo.modules.kotlin.functions.Coroutine
|
|
7
8
|
import expo.modules.kotlin.modules.Module
|
|
8
9
|
import expo.modules.kotlin.modules.ModuleDefinition
|
|
9
10
|
import kotlinx.coroutines.suspendCancellableCoroutine
|
|
11
|
+
import kotlinx.coroutines.sync.Mutex
|
|
10
12
|
import wallet.core.jni.HDWallet
|
|
11
13
|
import java.util.UUID
|
|
12
14
|
import kotlin.coroutines.resume
|
|
@@ -21,6 +23,13 @@ class ChainberryTrustWalletCoreModule : Module() {
|
|
|
21
23
|
// Must be loaded once before any JNI calls
|
|
22
24
|
System.loadLibrary("TrustWalletCore")
|
|
23
25
|
}
|
|
26
|
+
|
|
27
|
+
// Global, not per-walletId: createWallet/importWallet/deleteWallet all read-modify-write the
|
|
28
|
+
// *entire* metadata map, so two concurrent calls touching different wallet ids would still
|
|
29
|
+
// race each other on that shared blob — a per-id lock wouldn't protect against that. See
|
|
30
|
+
// docs/adr/0002. A single companion-object Mutex (rather than an instance property) keeps
|
|
31
|
+
// this a true process-wide lock even if more than one module instance is ever created.
|
|
32
|
+
private val lifecycleMutex = Mutex()
|
|
24
33
|
}
|
|
25
34
|
|
|
26
35
|
private val context get() = appContext.reactContext
|
|
@@ -30,22 +39,68 @@ class ChainberryTrustWalletCoreModule : Module() {
|
|
|
30
39
|
get() = appContext.currentActivity as? FragmentActivity
|
|
31
40
|
?: throw CodedException("NoActivity", "No foreground FragmentActivity to host the biometric prompt", null)
|
|
32
41
|
|
|
42
|
+
/**
|
|
43
|
+
* Serializes the whole body — including the biometric/device-credential prompt, not just the
|
|
44
|
+
* store writes — against every other lifecycle-mutating call, so at most one is ever touching
|
|
45
|
+
* the shared metadata store at a time (see docs/adr/0002). Also forecloses a second, separate
|
|
46
|
+
* bug: two concurrent `BiometricPrompt` invocations racing each other.
|
|
47
|
+
*
|
|
48
|
+
* [rejectIfBusy] chooses the policy for a caller that finds the lock already held:
|
|
49
|
+
* `createWallet`/`importWallet` reject immediately (`ERR_WALLET_OPERATION_IN_PROGRESS`) so a
|
|
50
|
+
* double-tap can never mint two wallets; `deleteWallet` queues instead, since two distinct
|
|
51
|
+
* deletes are both legitimate and should both eventually happen.
|
|
52
|
+
*/
|
|
53
|
+
private suspend fun <T> withLifecycleLock(rejectIfBusy: Boolean, body: suspend () -> T): T {
|
|
54
|
+
if (rejectIfBusy) {
|
|
55
|
+
if (!lifecycleMutex.tryLock()) {
|
|
56
|
+
throw CodedException(
|
|
57
|
+
"ERR_WALLET_OPERATION_IN_PROGRESS",
|
|
58
|
+
"Another wallet operation is already in progress",
|
|
59
|
+
null,
|
|
60
|
+
)
|
|
61
|
+
}
|
|
62
|
+
} else {
|
|
63
|
+
lifecycleMutex.lock()
|
|
64
|
+
}
|
|
65
|
+
try {
|
|
66
|
+
return body()
|
|
67
|
+
} finally {
|
|
68
|
+
lifecycleMutex.unlock()
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
33
72
|
override fun definition() = ModuleDefinition {
|
|
34
73
|
Name("TrustWalletCore")
|
|
35
74
|
|
|
36
|
-
//
|
|
75
|
+
// Runs once, right after module init, before any of the AsyncFunctions below can be
|
|
76
|
+
// reached from JS — so nothing can legitimately be mid-operation yet, which is exactly what
|
|
77
|
+
// makes a one-shot pass here sufficient (no lock/grace-period needed against an in-flight
|
|
78
|
+
// call). This is the actual crash-safety mechanism for an interrupted create/delete; see
|
|
79
|
+
// CONTEXT.md and docs/adr/0001. Never allowed to block or crash startup.
|
|
80
|
+
OnCreate {
|
|
81
|
+
val reactContext = appContext.reactContext
|
|
82
|
+
if (reactContext == null) {
|
|
83
|
+
Log.w("TrustWalletCoreModule", "reconciliation: react context unavailable at module init, skipping")
|
|
84
|
+
} else {
|
|
85
|
+
NativeWalletStore.reconcileOrphans(reactContext)
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// strength 128 = 12 words, 256 = 24 words. Returns { walletId, addresses, isTestnet }.
|
|
37
90
|
// No BIP-39 passphrase support: signTransaction always reconstructs the wallet with an
|
|
38
91
|
// empty passphrase, so accepting one here would derive addresses from a seed different
|
|
39
92
|
// from the one actually used to sign — always pass "" to stay consistent with that.
|
|
40
93
|
// isTestnet selects the address format for BTC/LTC/BCH (see ChainSigner.addressForChain) —
|
|
41
|
-
// every other chain's address is the same on mainnet and testnet.
|
|
94
|
+
// every other chain's address is the same on mainnet and testnet. This value is persisted
|
|
95
|
+
// as immutable per-wallet metadata (NativeWalletStore.WalletRecord) — signTransaction reads
|
|
96
|
+
// it back from there instead of accepting it as a parameter, so it can never drift.
|
|
42
97
|
AsyncFunction("createWallet") Coroutine { strength: Int, isTestnet: Boolean ->
|
|
43
98
|
val wallet = HDWallet(strength, "")
|
|
44
99
|
persistNewWallet(wallet, isTestnet)
|
|
45
100
|
}
|
|
46
101
|
|
|
47
102
|
// One-time mnemonic exposure from JS, at import only — never retained after this call.
|
|
48
|
-
// Returns { walletId, addresses }. No BIP-39 passphrase support (see `createWallet`).
|
|
103
|
+
// Returns { walletId, addresses, isTestnet }. No BIP-39 passphrase support (see `createWallet`).
|
|
49
104
|
AsyncFunction("importWallet") Coroutine { mnemonic: String, isTestnet: Boolean ->
|
|
50
105
|
val wallet = HDWallet(mnemonic, "") // throws on invalid mnemonic
|
|
51
106
|
persistNewWallet(wallet, isTestnet)
|
|
@@ -53,8 +108,8 @@ class ChainberryTrustWalletCoreModule : Module() {
|
|
|
53
108
|
|
|
54
109
|
// Reads only the ungated metadata store — no biometric prompt.
|
|
55
110
|
AsyncFunction("listWallets") {
|
|
56
|
-
NativeWalletStore.loadMetadata(context).map { (walletId,
|
|
57
|
-
mapOf("walletId" to walletId, "addresses" to addresses)
|
|
111
|
+
NativeWalletStore.loadMetadata(context).map { (walletId, record) ->
|
|
112
|
+
mapOf("walletId" to walletId, "addresses" to record.addresses, "isTestnet" to record.isTestnet)
|
|
58
113
|
}
|
|
59
114
|
}
|
|
60
115
|
|
|
@@ -62,25 +117,53 @@ class ChainberryTrustWalletCoreModule : Module() {
|
|
|
62
117
|
// anything is deleted, same gate as `signTransaction`/`exportMnemonic`. A
|
|
63
118
|
// compromised/malicious JS caller can still invoke this directly (there's no UI call
|
|
64
119
|
// site today), so the gate must live here rather than in JS.
|
|
120
|
+
//
|
|
121
|
+
// Removes the metadata entry *before* the secret (mnemonic file + Keystore key) — the
|
|
122
|
+
// reverse of the old ordering. If this is interrupted between the two steps, the wallet is
|
|
123
|
+
// already gone from `listWallets` and only an orphaned secret-side resource is left behind,
|
|
124
|
+
// which the next app launch's reconciliation pass cleans up (see docs/adr/0001) — never a
|
|
125
|
+
// metadata record still pointing at a secret that's already gone.
|
|
65
126
|
AsyncFunction("deleteWallet") Coroutine { walletId: String ->
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
127
|
+
withLifecycleLock(rejectIfBusy = false) {
|
|
128
|
+
val id = NativeWalletStore.validateWalletId(walletId)
|
|
129
|
+
NativeWalletStore.confirmIdentity(activity, context, "Delete wallet")
|
|
130
|
+
val metadata = NativeWalletStore.loadMetadata(context).toMutableMap()
|
|
131
|
+
metadata.remove(id)
|
|
132
|
+
NativeWalletStore.saveMetadata(context, metadata)
|
|
133
|
+
NativeWalletStore.deleteMnemonic(context, id)
|
|
134
|
+
}
|
|
72
135
|
}
|
|
73
136
|
|
|
74
137
|
// Triggers the native biometry/device-credential prompt, then signs entirely in-process.
|
|
75
|
-
// Returns { signedTx, meta? }.
|
|
76
|
-
//
|
|
77
|
-
|
|
138
|
+
// Returns { signedTx, meta? }. Network mode (mainnet/testnet) is read from the wallet's own
|
|
139
|
+
// persisted record, not accepted as a parameter — see ChainSigner.keyForChain and
|
|
140
|
+
// NativeWalletStore.WalletRecord for why a caller-supplied value here could sign with the
|
|
141
|
+
// wrong key for BTC/LTC.
|
|
142
|
+
AsyncFunction("signTransaction") Coroutine { walletId: String, chain: String, unsignedTx: Map<String, Any> ->
|
|
78
143
|
val id = NativeWalletStore.validateWalletId(walletId)
|
|
79
144
|
val chainKey = ChainKey.fromJs(chain)
|
|
80
145
|
confirmTransaction(chainKey, unsignedTx)
|
|
81
146
|
val cipher = NativeWalletStore.authenticateForExistingWallet(activity, context, id, "Sign transaction")
|
|
82
147
|
val mnemonic = NativeWalletStore.loadMnemonic(context, id, cipher)
|
|
83
148
|
val wallet = HDWallet(mnemonic, "")
|
|
149
|
+
// Backfill any addresses missing from metadata (chains added after wallet was created).
|
|
150
|
+
// Runs silently after the biometric gate — no extra prompt needed.
|
|
151
|
+
val metadata = NativeWalletStore.loadMetadata(context).toMutableMap()
|
|
152
|
+
val record = metadata[id] ?: throw NativeWalletStoreError.NotFound(id)
|
|
153
|
+
val isTestnet = record.isTestnet
|
|
154
|
+
val storedAddresses = record.addresses.toMutableMap()
|
|
155
|
+
var changed = false
|
|
156
|
+
for (chainEntry in ChainKey.entries) {
|
|
157
|
+
val key = chainEntry.name.lowercase()
|
|
158
|
+
if (!storedAddresses.containsKey(key)) {
|
|
159
|
+
storedAddresses[key] = ChainSigner.addressForChain(wallet, chainEntry, isTestnet)
|
|
160
|
+
changed = true
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
if (changed) {
|
|
164
|
+
metadata[id] = record.copy(addresses = storedAddresses)
|
|
165
|
+
runCatching { NativeWalletStore.saveMetadata(context, metadata) }
|
|
166
|
+
}
|
|
84
167
|
val result = ChainSigner.sign(chainKey, wallet, unsignedTx, isTestnet)
|
|
85
168
|
val response = mutableMapOf<String, Any>("signedTx" to result.signedTx)
|
|
86
169
|
result.meta?.let { response["meta"] = it }
|
|
@@ -95,8 +178,8 @@ class ChainberryTrustWalletCoreModule : Module() {
|
|
|
95
178
|
}
|
|
96
179
|
}
|
|
97
180
|
|
|
98
|
-
|
|
99
|
-
|
|
181
|
+
// Shows a native AlertDialog with decoded tx details before biometric auth fires.
|
|
182
|
+
// The user must tap "Confirm & Sign" — cancelling throws UserCancelled.
|
|
100
183
|
private suspend fun confirmTransaction(chain: ChainKey, unsignedTx: Map<String, Any>) {
|
|
101
184
|
val message = ChainSigner.buildSummary(chain, unsignedTx)
|
|
102
185
|
suspendCancellableCoroutine<Unit> { continuation ->
|
|
@@ -131,7 +214,7 @@ class ChainberryTrustWalletCoreModule : Module() {
|
|
|
131
214
|
|
|
132
215
|
try {
|
|
133
216
|
val metadata = NativeWalletStore.loadMetadata(context).toMutableMap()
|
|
134
|
-
metadata[walletId] = addresses
|
|
217
|
+
metadata[walletId] = NativeWalletStore.WalletRecord(isTestnet, addresses)
|
|
135
218
|
NativeWalletStore.saveMetadata(context, metadata)
|
|
136
219
|
} catch (e: Exception) {
|
|
137
220
|
// The mnemonic/key is already persisted but has no metadata pointer — compensate by
|
|
@@ -142,6 +225,6 @@ class ChainberryTrustWalletCoreModule : Module() {
|
|
|
142
225
|
throw e
|
|
143
226
|
}
|
|
144
227
|
|
|
145
|
-
return mapOf("walletId" to walletId, "addresses" to addresses)
|
|
228
|
+
return mapOf("walletId" to walletId, "addresses" to addresses, "isTestnet" to isTestnet)
|
|
146
229
|
}
|
|
147
230
|
}
|
|
@@ -21,6 +21,7 @@ import org.json.JSONException
|
|
|
21
21
|
import org.json.JSONObject
|
|
22
22
|
import java.io.File
|
|
23
23
|
import java.security.KeyStore
|
|
24
|
+
import java.util.Collections
|
|
24
25
|
import java.util.UUID
|
|
25
26
|
import javax.crypto.Cipher
|
|
26
27
|
import javax.crypto.KeyGenerator
|
|
@@ -79,8 +80,7 @@ enum class AuthMode(val aliasInfix: String) {
|
|
|
79
80
|
* Extends `CodedException` directly (rather than a flat `Exception`) so `.code` survives the
|
|
80
81
|
* Expo bridge losslessly with no extra wrapping step.
|
|
81
82
|
*/
|
|
82
|
-
sealed class NativeWalletStoreError private constructor(code: String, message: String, cause: Throwable? = null) :
|
|
83
|
-
CodedException(code, message, cause) {
|
|
83
|
+
sealed class NativeWalletStoreError private constructor(code: String, message: String, cause: Throwable? = null) : CodedException(code, message, cause) {
|
|
84
84
|
|
|
85
85
|
class NotFound(walletId: String) :
|
|
86
86
|
NativeWalletStoreError("ERR_WALLET_NOT_FOUND", "Wallet not found: $walletId")
|
|
@@ -236,6 +236,22 @@ object NativeWalletStore {
|
|
|
236
236
|
* metadata field needed. */
|
|
237
237
|
internal fun keyAlias(mode: AuthMode, walletId: String): String = KEY_ALIAS_PREFIX + mode.aliasInfix + walletId
|
|
238
238
|
|
|
239
|
+
/** Inverse of [keyAlias]: recovers `(mode, walletId)` from a raw Keystore alias string, or
|
|
240
|
+
* `null` if it doesn't match this module's alias scheme at all (some other feature's Keystore
|
|
241
|
+
* entry, sharing the same `AndroidKeyStore` provider). Checks the longer/specific infixes
|
|
242
|
+
* (`bio_`, `cred_`) before the empty [AuthMode.LEGACY_COMBINED] infix, which would otherwise
|
|
243
|
+
* match every alias under [KEY_ALIAS_PREFIX] — relies on [AuthMode.entries] iterating in
|
|
244
|
+
* declaration order, same assumption [resolveExistingMode] makes. Used by [reconcileOrphans]
|
|
245
|
+
* to identify which Keystore aliases belong to which wallet id, without needing a Context. */
|
|
246
|
+
internal fun parseKeyAlias(alias: String): Pair<AuthMode, String>? {
|
|
247
|
+
if (!alias.startsWith(KEY_ALIAS_PREFIX)) return null
|
|
248
|
+
for (mode in AuthMode.entries) {
|
|
249
|
+
val prefix = KEY_ALIAS_PREFIX + mode.aliasInfix
|
|
250
|
+
if (alias.startsWith(prefix)) return mode to alias.removePrefix(prefix)
|
|
251
|
+
}
|
|
252
|
+
return null
|
|
253
|
+
}
|
|
254
|
+
|
|
239
255
|
private fun getOrCreateKey(walletId: String, mode: AuthMode): SecretKey {
|
|
240
256
|
val alias = keyAlias(mode, walletId)
|
|
241
257
|
val ks = keyStore()
|
|
@@ -525,17 +541,27 @@ object NativeWalletStore {
|
|
|
525
541
|
}
|
|
526
542
|
}
|
|
527
543
|
|
|
528
|
-
// MARK: - Metadata (ungated: walletId -> { chain: address })
|
|
544
|
+
// MARK: - Metadata (ungated: walletId -> { isTestnet, addresses: { chain: address } })
|
|
545
|
+
|
|
546
|
+
/** A wallet's network mode is fixed at creation time ([ChainberryTrustWalletCoreModule
|
|
547
|
+
* .persistNewWallet]) and never changes thereafter — [isTestnet] is the authoritative value
|
|
548
|
+
* `signTransaction` must derive/sign with, replacing the old pattern of accepting it fresh as
|
|
549
|
+
* a parameter on every call (see docs/adr and the security remediation this type was
|
|
550
|
+
* introduced for). */
|
|
551
|
+
data class WalletRecord(val isTestnet: Boolean, val addresses: Map<String, String>)
|
|
529
552
|
|
|
530
553
|
/** Atomically replaces [target] via a temp-file write + `File.renameTo` (an atomic
|
|
531
554
|
* `rename(2)` on the same filesystem/mount, since the temp file is created alongside
|
|
532
555
|
* [target] in the same directory) — never a direct in-place overwrite, which could leave a
|
|
533
556
|
* torn file if the process is killed mid-write. Pure/`File`-based so it's unit-testable on
|
|
534
557
|
* the plain JVM without an Android `Context`. */
|
|
535
|
-
internal fun saveMetadataToFile(target: File, wallets: Map<String,
|
|
558
|
+
internal fun saveMetadataToFile(target: File, wallets: Map<String, WalletRecord>) {
|
|
536
559
|
val root = JSONObject()
|
|
537
|
-
for ((walletId,
|
|
538
|
-
|
|
560
|
+
for ((walletId, record) in wallets) {
|
|
561
|
+
val entry = JSONObject()
|
|
562
|
+
entry.put("isTestnet", record.isTestnet)
|
|
563
|
+
entry.put("addresses", JSONObject(record.addresses as Map<*, *>))
|
|
564
|
+
root.put(walletId, entry)
|
|
539
565
|
}
|
|
540
566
|
val temp = File(target.parentFile, "$METADATA_FILE.tmp-${System.nanoTime()}")
|
|
541
567
|
try {
|
|
@@ -552,35 +578,119 @@ object NativeWalletStore {
|
|
|
552
578
|
|
|
553
579
|
/** Distinguishes "no metadata has ever been written" (legitimately empty) from a genuine
|
|
554
580
|
* parse/corruption failure, which now throws a typed [NativeWalletStoreError.Corrupted]
|
|
555
|
-
* instead of letting a raw, uncaught `JSONException
|
|
556
|
-
*
|
|
557
|
-
|
|
581
|
+
* instead of letting a raw, uncaught `JSONException`/[org.json.JSONException] leak through
|
|
582
|
+
* the Expo bridge. A record missing `isTestnet` or `addresses` (e.g. data written by a
|
|
583
|
+
* pre-migration build under the old flat schema) is treated the same way — corrupted, not
|
|
584
|
+
* silently reinterpreted — there is no legacy-shape fallback. Pure/`File`-based so it's
|
|
585
|
+
* unit-testable on the plain JVM without an Android `Context`. */
|
|
586
|
+
internal fun loadMetadataFromFile(file: File): Map<String, WalletRecord> {
|
|
558
587
|
if (!file.exists()) return emptyMap()
|
|
559
588
|
val root = try {
|
|
560
589
|
JSONObject(file.readText())
|
|
561
590
|
} catch (e: JSONException) {
|
|
562
591
|
throw NativeWalletStoreError.Corrupted("metadata.json is not valid JSON", e)
|
|
563
592
|
}
|
|
564
|
-
val result = mutableMapOf<String,
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
593
|
+
val result = mutableMapOf<String, WalletRecord>()
|
|
594
|
+
try {
|
|
595
|
+
for (walletId in root.keys()) {
|
|
596
|
+
val entry = root.getJSONObject(walletId)
|
|
597
|
+
val isTestnet = entry.getBoolean("isTestnet")
|
|
598
|
+
val addressesJson = entry.getJSONObject("addresses")
|
|
599
|
+
val addresses = mutableMapOf<String, String>()
|
|
600
|
+
for (chain in addressesJson.keys()) {
|
|
601
|
+
addresses[chain] = addressesJson.getString(chain)
|
|
602
|
+
}
|
|
603
|
+
result[walletId] = WalletRecord(isTestnet, addresses)
|
|
570
604
|
}
|
|
571
|
-
|
|
605
|
+
} catch (e: JSONException) {
|
|
606
|
+
throw NativeWalletStoreError.Corrupted("metadata.json entry has an unexpected shape", e)
|
|
572
607
|
}
|
|
573
608
|
return result
|
|
574
609
|
}
|
|
575
610
|
|
|
576
|
-
fun saveMetadata(context: Context, wallets: Map<String,
|
|
611
|
+
fun saveMetadata(context: Context, wallets: Map<String, WalletRecord>) {
|
|
577
612
|
saveMetadataToFile(metadataFile(context), wallets)
|
|
578
613
|
}
|
|
579
614
|
|
|
580
|
-
fun loadMetadata(context: Context): Map<String,
|
|
615
|
+
fun loadMetadata(context: Context): Map<String, WalletRecord> {
|
|
581
616
|
return loadMetadataFromFile(metadataFile(context))
|
|
582
617
|
}
|
|
583
618
|
|
|
619
|
+
// MARK: - Reconciliation (see CONTEXT.md "orphan"/"reconciliation pass", docs/adr/0001)
|
|
620
|
+
|
|
621
|
+
/** Every `.enc` file in [walletsDir] whose wallet id has no entry in [liveIds] — the
|
|
622
|
+
* file-side half of an orphan. Pure/`File`-based so it's unit-testable on the plain JVM.
|
|
623
|
+
* Deliberately does not also match `METADATA_FILE` itself: that file has no `.enc` suffix. */
|
|
624
|
+
internal fun findOrphanFiles(walletsDir: File, liveIds: Set<String>): List<File> =
|
|
625
|
+
walletsDir.listFiles { f -> f.name.endsWith(".enc") }
|
|
626
|
+
?.filter { it.name.removeSuffix(".enc") !in liveIds }
|
|
627
|
+
?: emptyList()
|
|
628
|
+
|
|
629
|
+
/** Every leftover `metadata.json.tmp-*` file in [walletsDir] — pure litter from a
|
|
630
|
+
* [saveMetadataToFile] interrupted between writing the temp file and renaming it over the
|
|
631
|
+
* target (the rename itself is atomic, so the target is never at risk; only the temp file
|
|
632
|
+
* can be left behind). Not a security or correctness concern, just disk hygiene swept up
|
|
633
|
+
* alongside the real orphan checks since reconciliation is already scanning this directory. */
|
|
634
|
+
internal fun findStaleMetadataTempFiles(walletsDir: File): List<File> =
|
|
635
|
+
walletsDir.listFiles { f -> f.name.startsWith("$METADATA_FILE.tmp-") }?.toList() ?: emptyList()
|
|
636
|
+
|
|
637
|
+
/**
|
|
638
|
+
* Runs once at module init (see `TrustWalletCoreModule`'s `OnCreate`), before the JS layer can
|
|
639
|
+
* issue its first `createWallet`/`importWallet`/`deleteWallet` call — the actual source of
|
|
640
|
+
* crash-safety for an interrupted create or delete, not the in-call rollback in
|
|
641
|
+
* `persistNewWallet`. Android has *two* independently-persistable secret-side resources per
|
|
642
|
+
* wallet — the `.enc` file and its Keystore key alias, since [getOrCreateKey] creates the key
|
|
643
|
+
* before [saveMnemonic] ever writes the file — so both are checked against the metadata store
|
|
644
|
+
* independently, neither gated on the other's presence.
|
|
645
|
+
*
|
|
646
|
+
* Best-effort and never throws: any failure here is logged and skipped rather than propagated,
|
|
647
|
+
* since a broken reconciliation pass must never become "the app won't launch." A metadata entry
|
|
648
|
+
* with no matching secret-side resource (the reverse shape — a "zombie") is deliberately left
|
|
649
|
+
* untouched here; see [NativeWalletStoreError.NotFound] and docs/adr/0001 for why.
|
|
650
|
+
*/
|
|
651
|
+
fun reconcileOrphans(context: Context) {
|
|
652
|
+
val liveIds = try {
|
|
653
|
+
loadMetadata(context).keys
|
|
654
|
+
} catch (e: Exception) {
|
|
655
|
+
Log.w(TAG, "reconciliation: failed to load metadata, skipping this pass entirely", e)
|
|
656
|
+
return
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
val dir = walletsDir(context)
|
|
660
|
+
|
|
661
|
+
try {
|
|
662
|
+
for (file in findOrphanFiles(dir, liveIds)) {
|
|
663
|
+
if (!file.delete()) {
|
|
664
|
+
Log.w(TAG, "reconciliation: failed to delete orphaned file ${file.name}")
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
} catch (e: Exception) {
|
|
668
|
+
Log.w(TAG, "reconciliation: failed while cleaning up orphaned files", e)
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
try {
|
|
672
|
+
val ks = keyStore()
|
|
673
|
+
for (alias in Collections.list(ks.aliases())) {
|
|
674
|
+
val (_, walletId) = parseKeyAlias(alias) ?: continue
|
|
675
|
+
if (walletId !in liveIds) {
|
|
676
|
+
try {
|
|
677
|
+
ks.deleteEntry(alias)
|
|
678
|
+
} catch (e: Exception) {
|
|
679
|
+
Log.w(TAG, "reconciliation: failed to delete orphaned key alias $alias", e)
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
} catch (e: Exception) {
|
|
684
|
+
Log.w(TAG, "reconciliation: failed while enumerating Keystore aliases", e)
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
try {
|
|
688
|
+
findStaleMetadataTempFiles(dir).forEach { it.delete() }
|
|
689
|
+
} catch (e: Exception) {
|
|
690
|
+
Log.w(TAG, "reconciliation: failed while cleaning up stale metadata temp files", e)
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
|
|
584
694
|
// MARK: - Biometric/device-credential prompt
|
|
585
695
|
|
|
586
696
|
/** Authenticates and returns a `Cipher` ready for [saveMnemonic], for a brand-new wallet id.
|
|
@@ -69,13 +69,28 @@ class NativeWalletStoreTest {
|
|
|
69
69
|
fun metadata_roundTrips() {
|
|
70
70
|
val file = File(tmp.root, "metadata.json")
|
|
71
71
|
val wallets = mapOf(
|
|
72
|
-
"id-1" to mapOf("ethereum" to "0xabc"),
|
|
73
|
-
"id-2" to mapOf("bitcoin" to "bc1abc"),
|
|
72
|
+
"id-1" to NativeWalletStore.WalletRecord(isTestnet = false, addresses = mapOf("ethereum" to "0xabc")),
|
|
73
|
+
"id-2" to NativeWalletStore.WalletRecord(isTestnet = true, addresses = mapOf("bitcoin" to "bc1abc")),
|
|
74
74
|
)
|
|
75
75
|
NativeWalletStore.saveMetadataToFile(file, wallets)
|
|
76
76
|
assertEquals(wallets, NativeWalletStore.loadMetadataFromFile(file))
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
+
@Test
|
|
80
|
+
fun metadata_legacyFlatShapeThrowsCorrupted_notSilentlyMisread() {
|
|
81
|
+
// Data written by a pre-migration build (walletId -> {chain: address} with no
|
|
82
|
+
// isTestnet/addresses wrapper) must never be silently reinterpreted — there is no
|
|
83
|
+
// migration path, so this must surface as Corrupted, same as any other unexpected shape.
|
|
84
|
+
val file = File(tmp.root, "metadata.json")
|
|
85
|
+
file.writeText("""{"id-1":{"ethereum":"0xabc"}}""")
|
|
86
|
+
try {
|
|
87
|
+
NativeWalletStore.loadMetadataFromFile(file)
|
|
88
|
+
fail("expected Corrupted for legacy flat-shape metadata")
|
|
89
|
+
} catch (e: NativeWalletStoreError.Corrupted) {
|
|
90
|
+
// expected
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
79
94
|
@Test
|
|
80
95
|
fun metadata_missingFileIsEmptyMap() {
|
|
81
96
|
val file = File(tmp.root, "does-not-exist.json")
|
|
@@ -99,7 +114,7 @@ class NativeWalletStoreTest {
|
|
|
99
114
|
@Test
|
|
100
115
|
fun metadata_failedWrite_leavesExistingFileUntouched() {
|
|
101
116
|
val target = File(tmp.root, "metadata.json")
|
|
102
|
-
val original = mapOf("id-1" to mapOf("ethereum" to "0xabc"))
|
|
117
|
+
val original = mapOf("id-1" to NativeWalletStore.WalletRecord(isTestnet = false, addresses = mapOf("ethereum" to "0xabc")))
|
|
103
118
|
NativeWalletStore.saveMetadataToFile(target, original)
|
|
104
119
|
|
|
105
120
|
// Making the parent directory read-only blocks creating the temp file at all (the
|
|
@@ -111,7 +126,10 @@ class NativeWalletStoreTest {
|
|
|
111
126
|
target.parentFile!!.setWritable(false)
|
|
112
127
|
try {
|
|
113
128
|
try {
|
|
114
|
-
NativeWalletStore.saveMetadataToFile(
|
|
129
|
+
NativeWalletStore.saveMetadataToFile(
|
|
130
|
+
target,
|
|
131
|
+
mapOf("id-2" to NativeWalletStore.WalletRecord(isTestnet = false, addresses = mapOf("bitcoin" to "bc1xyz"))),
|
|
132
|
+
)
|
|
115
133
|
// Some filesystems/CI runners ignore setWritable(false) for the owner (e.g. root).
|
|
116
134
|
// If the write unexpectedly succeeded, there's nothing to assert here.
|
|
117
135
|
} catch (e: NativeWalletStoreError.PermissionDenied) {
|
|
@@ -271,4 +289,74 @@ class NativeWalletStoreTest {
|
|
|
271
289
|
fun describeLegacySecurityLevel_notInsideSecureHardwareIsSoftware() {
|
|
272
290
|
assertEquals("SOFTWARE", NativeWalletStore.describeLegacySecurityLevel(false))
|
|
273
291
|
}
|
|
292
|
+
|
|
293
|
+
// ─── parseKeyAlias ───────────────────────────────────────────────────────────
|
|
294
|
+
|
|
295
|
+
@Test
|
|
296
|
+
fun parseKeyAlias_isTheInverseOfKeyAlias() {
|
|
297
|
+
val id = UUID.randomUUID().toString()
|
|
298
|
+
for (mode in AuthMode.entries) {
|
|
299
|
+
val alias = NativeWalletStore.keyAlias(mode, id)
|
|
300
|
+
assertEquals(mode to id, NativeWalletStore.parseKeyAlias(alias))
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
@Test
|
|
305
|
+
fun parseKeyAlias_legacyInfixDoesNotSwallowBioOrCredAliases() {
|
|
306
|
+
// Regression check: LEGACY_COMBINED's infix is "", which is a prefix of every alias under
|
|
307
|
+
// KEY_ALIAS_PREFIX — parseKeyAlias must still resolve a bio_/cred_ alias to its actual mode,
|
|
308
|
+
// not fall through to LEGACY_COMBINED just because the empty-infix check would also match.
|
|
309
|
+
val id = UUID.randomUUID().toString()
|
|
310
|
+
assertEquals(
|
|
311
|
+
AuthMode.BIOMETRIC_STRONG to id,
|
|
312
|
+
NativeWalletStore.parseKeyAlias(NativeWalletStore.keyAlias(AuthMode.BIOMETRIC_STRONG, id)),
|
|
313
|
+
)
|
|
314
|
+
assertEquals(
|
|
315
|
+
AuthMode.DEVICE_CREDENTIAL to id,
|
|
316
|
+
NativeWalletStore.parseKeyAlias(NativeWalletStore.keyAlias(AuthMode.DEVICE_CREDENTIAL, id)),
|
|
317
|
+
)
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
@Test
|
|
321
|
+
fun parseKeyAlias_unrelatedAliasReturnsNull() {
|
|
322
|
+
assertEquals(null, NativeWalletStore.parseKeyAlias("some_other_features_key"))
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// ─── findOrphanFiles / findStaleMetadataTempFiles (reconciliation, docs/adr/0001) ────────────
|
|
326
|
+
|
|
327
|
+
@Test
|
|
328
|
+
fun findOrphanFiles_fileWithNoMetadataEntryIsOrphaned() {
|
|
329
|
+
File(tmp.root, "orphan-id.enc").writeBytes(byteArrayOf(1))
|
|
330
|
+
val orphans = NativeWalletStore.findOrphanFiles(tmp.root, liveIds = emptySet())
|
|
331
|
+
assertEquals(listOf("orphan-id.enc"), orphans.map { it.name })
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
@Test
|
|
335
|
+
fun findOrphanFiles_fileWithMatchingMetadataEntryIsNotOrphaned() {
|
|
336
|
+
File(tmp.root, "live-id.enc").writeBytes(byteArrayOf(1))
|
|
337
|
+
val orphans = NativeWalletStore.findOrphanFiles(tmp.root, liveIds = setOf("live-id"))
|
|
338
|
+
assertTrue(orphans.isEmpty())
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
@Test
|
|
342
|
+
fun findOrphanFiles_ignoresNonEncFiles() {
|
|
343
|
+
// metadata.json itself (and any stray temp file) must never be swept up as a wallet orphan.
|
|
344
|
+
File(tmp.root, "metadata.json").writeText("{}")
|
|
345
|
+
val orphans = NativeWalletStore.findOrphanFiles(tmp.root, liveIds = emptySet())
|
|
346
|
+
assertTrue(orphans.isEmpty())
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
@Test
|
|
350
|
+
fun findStaleMetadataTempFiles_findsLeftoverTempFile() {
|
|
351
|
+
// Simulates a saveMetadataToFile interrupted after the temp write but before the rename.
|
|
352
|
+
File(tmp.root, "metadata.json.tmp-12345").writeText("{}")
|
|
353
|
+
val stale = NativeWalletStore.findStaleMetadataTempFiles(tmp.root)
|
|
354
|
+
assertEquals(listOf("metadata.json.tmp-12345"), stale.map { it.name })
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
@Test
|
|
358
|
+
fun findStaleMetadataTempFiles_ignoresCommittedMetadataFile() {
|
|
359
|
+
File(tmp.root, "metadata.json").writeText("{}")
|
|
360
|
+
assertTrue(NativeWalletStore.findStaleMetadataTempFiles(tmp.root).isEmpty())
|
|
361
|
+
}
|
|
274
362
|
}
|