@chainberry/trust-wallet-core 2.5.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.
@@ -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
@@ -236,6 +237,22 @@ object NativeWalletStore {
236
237
  * metadata field needed. */
237
238
  internal fun keyAlias(mode: AuthMode, walletId: String): String = KEY_ALIAS_PREFIX + mode.aliasInfix + walletId
238
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
+
239
256
  private fun getOrCreateKey(walletId: String, mode: AuthMode): SecretKey {
240
257
  val alias = keyAlias(mode, walletId)
241
258
  val ks = keyStore()
@@ -581,6 +598,81 @@ object NativeWalletStore {
581
598
  return loadMetadataFromFile(metadataFile(context))
582
599
  }
583
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
+
584
676
  // MARK: - Biometric/device-credential prompt
585
677
 
586
678
  /** Authenticates and returns a `Cipher` ready for [saveMnemonic], for a brand-new wallet id.
@@ -271,4 +271,74 @@ class NativeWalletStoreTest {
271
271
  fun describeLegacySecurityLevel_notInsideSecureHardwareIsSoftware() {
272
272
  assertEquals("SOFTWARE", NativeWalletStore.describeLegacySecurityLevel(false))
273
273
  }
274
+
275
+ // ─── parseKeyAlias ───────────────────────────────────────────────────────────
276
+
277
+ @Test
278
+ fun parseKeyAlias_isTheInverseOfKeyAlias() {
279
+ val id = UUID.randomUUID().toString()
280
+ for (mode in AuthMode.entries) {
281
+ val alias = NativeWalletStore.keyAlias(mode, id)
282
+ assertEquals(mode to id, NativeWalletStore.parseKeyAlias(alias))
283
+ }
284
+ }
285
+
286
+ @Test
287
+ fun parseKeyAlias_legacyInfixDoesNotSwallowBioOrCredAliases() {
288
+ // Regression check: LEGACY_COMBINED's infix is "", which is a prefix of every alias under
289
+ // KEY_ALIAS_PREFIX — parseKeyAlias must still resolve a bio_/cred_ alias to its actual mode,
290
+ // not fall through to LEGACY_COMBINED just because the empty-infix check would also match.
291
+ val id = UUID.randomUUID().toString()
292
+ assertEquals(
293
+ AuthMode.BIOMETRIC_STRONG to id,
294
+ NativeWalletStore.parseKeyAlias(NativeWalletStore.keyAlias(AuthMode.BIOMETRIC_STRONG, id)),
295
+ )
296
+ assertEquals(
297
+ AuthMode.DEVICE_CREDENTIAL to id,
298
+ NativeWalletStore.parseKeyAlias(NativeWalletStore.keyAlias(AuthMode.DEVICE_CREDENTIAL, id)),
299
+ )
300
+ }
301
+
302
+ @Test
303
+ fun parseKeyAlias_unrelatedAliasReturnsNull() {
304
+ assertEquals(null, NativeWalletStore.parseKeyAlias("some_other_features_key"))
305
+ }
306
+
307
+ // ─── findOrphanFiles / findStaleMetadataTempFiles (reconciliation, docs/adr/0001) ────────────
308
+
309
+ @Test
310
+ fun findOrphanFiles_fileWithNoMetadataEntryIsOrphaned() {
311
+ File(tmp.root, "orphan-id.enc").writeBytes(byteArrayOf(1))
312
+ val orphans = NativeWalletStore.findOrphanFiles(tmp.root, liveIds = emptySet())
313
+ assertEquals(listOf("orphan-id.enc"), orphans.map { it.name })
314
+ }
315
+
316
+ @Test
317
+ fun findOrphanFiles_fileWithMatchingMetadataEntryIsNotOrphaned() {
318
+ File(tmp.root, "live-id.enc").writeBytes(byteArrayOf(1))
319
+ val orphans = NativeWalletStore.findOrphanFiles(tmp.root, liveIds = setOf("live-id"))
320
+ assertTrue(orphans.isEmpty())
321
+ }
322
+
323
+ @Test
324
+ fun findOrphanFiles_ignoresNonEncFiles() {
325
+ // metadata.json itself (and any stray temp file) must never be swept up as a wallet orphan.
326
+ File(tmp.root, "metadata.json").writeText("{}")
327
+ val orphans = NativeWalletStore.findOrphanFiles(tmp.root, liveIds = emptySet())
328
+ assertTrue(orphans.isEmpty())
329
+ }
330
+
331
+ @Test
332
+ fun findStaleMetadataTempFiles_findsLeftoverTempFile() {
333
+ // Simulates a saveMetadataToFile interrupted after the temp write but before the rename.
334
+ File(tmp.root, "metadata.json.tmp-12345").writeText("{}")
335
+ val stale = NativeWalletStore.findStaleMetadataTempFiles(tmp.root)
336
+ assertEquals(listOf("metadata.json.tmp-12345"), stale.map { it.name })
337
+ }
338
+
339
+ @Test
340
+ fun findStaleMetadataTempFiles_ignoresCommittedMetadataFile() {
341
+ File(tmp.root, "metadata.json").writeText("{}")
342
+ assertTrue(NativeWalletStore.findStaleMetadataTempFiles(tmp.root).isEmpty())
343
+ }
274
344
  }