11package com.flowfoundation.wallet.manager.account
22
3+ import com.flow.wallet.KeyManager
4+ import com.flow.wallet.toFormatString
5+ import com.flowfoundation.wallet.cache.AccountCacheManager
6+ import com.flowfoundation.wallet.cache.UserPrefixCacheManager
7+ import com.flowfoundation.wallet.manager.account.Account
8+ import com.flowfoundation.wallet.manager.account.Accounts
9+ import com.flowfoundation.wallet.manager.account.UserPrefix
10+ import com.flowfoundation.wallet.manager.account.UserPrefixes
11+ import com.flowfoundation.wallet.manager.app.chainNetWorkString
12+ import com.flowfoundation.wallet.manager.flow.FlowCadenceApi
13+ import com.flowfoundation.wallet.manager.walletdata.FlowWallet
14+ import com.flowfoundation.wallet.network.model.UserInfoData
15+ import com.flowfoundation.wallet.utils.Env
316import com.flowfoundation.wallet.utils.logd
17+ import com.flowfoundation.wallet.utils.loge
418import java.security.KeyStore
19+ import java.security.cert.Certificate
20+ import java.security.interfaces.ECPublicKey
21+ import java.security.spec.ECPoint
22+ import kotlinx.coroutines.runBlocking
23+ import androidx.core.content.edit
524
625/* *
726 * Manages migration of private keys from the old Android Keystore system
@@ -10,9 +29,27 @@ import java.security.KeyStore
1029 * This is critical for users upgrading from versions that used direct Android Keystore access
1130 * to versions that use the Flow-Wallet-Kit storage abstraction.
1231 *
32+ * ## Orphaned Key Recovery
33+ *
34+ * When the app performs a key rotation, it:
35+ * 1. Generates a new key pair in the Android Keystore (alias = `{KEYSTORE_ALIAS_PREFIX}{prefix}`)
36+ * 2. Submits the rotation transaction on-chain (adding the new public key)
37+ * 3. Persists the new `prefix` in AccountManager's cache
38+ *
39+ * If step 3 fails (crash, timeout, serialization error), the key physically exists in the
40+ * Android Keystore hardware but the app has no record of the alias/prefix.
41+ *
42+ * [recoverOrphanedKeystoreKeys] bridges this gap by:
43+ * - Enumerating all aliases in the Android Keystore
44+ * - Extracting the EC public key from each entry's certificate
45+ * - Matching it against the on-chain public keys for all known Flow addresses
46+ * - Writing matched prefixes back into [KeyStorageManager] so that
47+ * `AccountManager.buildLocalKeyAccounts()` can surface them in the account switcher
1348 */
1449object KeyStoreMigrationManager {
1550 private const val TAG = " KeyStoreMigration"
51+ private const val RECOVERY_PREFS = " keystore_recovery_state"
52+ private const val KEY_LAST_SCAN_ALIASES_HASH = " last_scan_aliases_hash"
1653
1754 /* *
1855 * Diagnostic function to list all keys in Android Keystore
@@ -38,4 +75,305 @@ object KeyStoreMigrationManager {
3875 }
3976 }
4077
78+ /* *
79+ * Scans the Android Keystore for keys that match on-chain public keys but have no
80+ * corresponding entry in [KeyStorageManager]. When a match is found, the prefix is
81+ * saved so that `buildLocalKeyAccounts()` can surface the account in the switcher.
82+ *
83+ * This function is idempotent and safe to call on every cold start. It short-circuits
84+ * if the set of Keystore aliases has not changed since the last successful scan.
85+ *
86+ * @param knownAddresses Flow addresses to check against on-chain keys. If empty,
87+ * the function will collect addresses from the current AccountManager cache.
88+ * @return The number of newly recovered key mappings.
89+ */
90+ fun recoverOrphanedKeystoreKeys (knownAddresses : Set <String > = emptySet()): Int {
91+ logd(TAG , " === recoverOrphanedKeystoreKeys START ===" )
92+
93+ val keyStore: KeyStore
94+ val walletAliases: List <String >
95+
96+ try {
97+ keyStore = KeyStore .getInstance(" AndroidKeyStore" )
98+ keyStore.load(null )
99+
100+ // Collect only aliases that follow the Flow Wallet naming convention
101+ val aliasPrefix = KeyManager .KEYSTORE_ALIAS_PREFIX
102+ walletAliases = mutableListOf<String >()
103+ val enumeration = keyStore.aliases()
104+ while (enumeration.hasMoreElements()) {
105+ val alias = enumeration.nextElement()
106+ if (alias.startsWith(aliasPrefix)) {
107+ walletAliases.add(alias)
108+ }
109+ }
110+
111+ logd(TAG , " Found ${walletAliases.size} wallet-related aliases in Android Keystore" )
112+
113+ if (walletAliases.isEmpty()) {
114+ logd(TAG , " No wallet aliases found, nothing to recover" )
115+ return 0
116+ }
117+ } catch (e: Exception ) {
118+ loge(TAG , " Failed to enumerate Android Keystore: ${e.message} " )
119+ return 0
120+ }
121+
122+ // Short-circuit if the alias set hasn't changed since the last scan
123+ val aliasesHash = walletAliases.sorted().hashCode()
124+ val prefs = Env .getApp().getSharedPreferences(RECOVERY_PREFS , 0 )
125+ val lastHash = prefs.getInt(KEY_LAST_SCAN_ALIASES_HASH , 0 )
126+ if (aliasesHash == lastHash && lastHash != 0 ) {
127+ logd(TAG , " Alias set unchanged since last scan, skipping" )
128+ return 0
129+ }
130+
131+ // Collect all known Flow addresses to check against
132+ val addresses = if (knownAddresses.isNotEmpty()) {
133+ knownAddresses
134+ } else {
135+ collectKnownAddresses()
136+ }
137+
138+ if (addresses.isEmpty()) {
139+ logd(TAG , " No known Flow addresses to match against" )
140+ return 0
141+ }
142+
143+ logd(TAG , " Checking ${walletAliases.size} aliases against ${addresses.size} addresses" )
144+
145+ // Build a map of public key hex -> on-chain address for all known accounts
146+ val onChainKeyMap = buildOnChainKeyMap(addresses)
147+
148+ if (onChainKeyMap.isEmpty()) {
149+ logd(TAG , " No on-chain keys fetched, cannot match" )
150+ // Don't cache the hash - we want to retry when network is available
151+ return 0
152+ }
153+
154+ // Extract public keys from each Keystore alias and match against on-chain keys
155+ var recoveredCount = 0
156+ for (alias in walletAliases) {
157+ val prefix = alias.removePrefix(KeyManager .KEYSTORE_ALIAS_PREFIX )
158+
159+ // Skip if this prefix is already known to KeyStorageManager
160+ // We need to check all UIDs since we don't know which UID this prefix belongs to
161+ if (isAliasAlreadyMapped(prefix)) {
162+ logd(TAG , " Alias already mapped: $alias -> prefix $prefix " )
163+ continue
164+ }
165+
166+ try {
167+ val publicKeyHex = extractPublicKeyHex(keyStore, alias)
168+ if (publicKeyHex == null ) {
169+ logd(TAG , " Could not extract public key from alias: $alias " )
170+ continue
171+ }
172+
173+ logd(TAG , " Extracted public key from $alias : ${publicKeyHex.take(20 )} ..." )
174+
175+ // Check if this public key matches any on-chain key
176+ val matchedAddress = onChainKeyMap[publicKeyHex.lowercase()]
177+ if (matchedAddress != null ) {
178+ logd(TAG , " ✓ MATCH FOUND: alias=$alias matches on-chain key for address=$matchedAddress " )
179+
180+ // Generate a synthetic UID for this orphaned key.
181+ // We use a deterministic ID based on the prefix so repeated runs
182+ // produce the same UID and don't create duplicates.
183+ val syntheticUid = " recovered_${prefix.take(16 )} "
184+
185+ // Create UserPrefix mapping
186+ val currentPrefixes = UserPrefixCacheManager .read()?.toMutableList() ? : mutableListOf ()
187+ currentPrefixes.removeAll { it.userId == syntheticUid }
188+ currentPrefixes.add(UserPrefix (syntheticUid, prefix))
189+ UserPrefixCacheManager .cache(UserPrefixes ().apply { addAll(currentPrefixes) })
190+
191+ // Create a mock Account so it appears in the Switch Account list
192+ val newAccount = Account (
193+ userInfo = UserInfoData (
194+ username = " Recovered Account" ,
195+ avatar = " https://source.boringavatars.com/marble/120/$matchedAddress " ,
196+ created = System .currentTimeMillis().toString(),
197+ nickname = " Recovered Key"
198+ ),
199+ isActive = false ,
200+ prefix = prefix,
201+ walletNodes = listOf (FlowWallet (address = matchedAddress, name = " Flow Wallet" , emojiId = 1 , chainIdString = chainNetWorkString()))
202+ )
203+
204+ val currentAccounts = AccountCacheManager .read()?.toMutableList() ? : mutableListOf ()
205+ currentAccounts.removeAll { it.prefix == prefix }
206+ currentAccounts.add(newAccount)
207+ AccountCacheManager .cache(Accounts ().apply { addAll(currentAccounts) })
208+
209+ // NOTE: Injecting directly into CacheManager as a temporary fallback.
210+ // Ideally, this should be handled by a dedicated Keystore abstraction layer in the future.
211+
212+ logd(TAG , " Saved recovered mapping: uid=$syntheticUid , prefix=$prefix , address=$matchedAddress " )
213+ recoveredCount++
214+ } else {
215+ logd(TAG , " No on-chain match for alias: $alias " )
216+ }
217+ } catch (e: Exception ) {
218+ loge(TAG , " Error processing alias $alias : ${e.message} " )
219+ }
220+ }
221+
222+ // Cache the aliases hash so we don't re-scan unnecessarily
223+ prefs.edit { putInt(KEY_LAST_SCAN_ALIASES_HASH , aliasesHash) }
224+
225+ logd(TAG , " === recoverOrphanedKeystoreKeys DONE: recovered $recoveredCount keys ===" )
226+ return recoveredCount
227+ }
228+
229+ /* *
230+ * Forces a re-scan on the next call to [recoverOrphanedKeystoreKeys].
231+ * Call this whenever a key rotation occurs or an account is removed.
232+ */
233+ fun invalidateRecoveryCache () {
234+ val prefs = Env .getApp().getSharedPreferences(RECOVERY_PREFS , 0 )
235+ prefs.edit { remove(KEY_LAST_SCAN_ALIASES_HASH ) }
236+ logd(TAG , " Recovery cache invalidated" )
237+ }
238+
239+ // ──────────────────────────────────────────────────────────────────────
240+ // Private helpers
241+ // ──────────────────────────────────────────────────────────────────────
242+
243+ /* *
244+ * Collects all Flow addresses known to AccountManager (from the cache and walletNodes).
245+ */
246+ private fun collectKnownAddresses (): Set <String > {
247+ val addresses = mutableSetOf<String >()
248+ try {
249+ val accounts = AccountManager .list()
250+ for (account in accounts) {
251+ // Get address from walletNodes
252+ account.firstFlowWalletAddress()?.let { addresses.add(it) }
253+
254+ // Get address from wallet blockchain data
255+ account.wallet?.wallets?.forEach { wallet ->
256+ wallet.blockchain?.forEach { chain ->
257+ if (chain.address.isNotBlank()) {
258+ val addr = if (chain.address.startsWith(" 0x" )) chain.address else " 0x${chain.address} "
259+ addresses.add(addr)
260+ }
261+ }
262+ }
263+ }
264+ } catch (e: Exception ) {
265+ loge(TAG , " Error collecting known addresses: ${e.message} " )
266+ }
267+ logd(TAG , " Collected ${addresses.size} known Flow addresses: $addresses " )
268+ return addresses
269+ }
270+
271+ /* *
272+ * For each Flow address, fetches the on-chain account keys and builds a map of
273+ * `normalizedPublicKeyHex -> flowAddress` for all non-revoked keys.
274+ */
275+ private fun buildOnChainKeyMap (addresses : Set <String >): Map <String , String > {
276+ val keyMap = mutableMapOf<String , String >()
277+
278+ for (address in addresses) {
279+ try {
280+ val account = runBlocking { FlowCadenceApi .getAccount(address) }
281+ val keys = account.keys ? : continue
282+
283+ for (key in keys) {
284+ if (key.revoked) continue
285+
286+ // Normalize the public key: strip 0x prefix, strip 04 uncompressed prefix, lowercase
287+ val rawKey = key.publicKey.removePrefix(" 0x" ).lowercase()
288+ val stripped = if (rawKey.startsWith(" 04" ) && rawKey.length == 130 ) {
289+ rawKey.substring(2 )
290+ } else {
291+ rawKey
292+ }
293+
294+ keyMap[rawKey] = address
295+ keyMap[stripped] = address
296+
297+ logd(TAG , " On-chain key for $address : index=${key.index} , " +
298+ " revoked=${key.revoked} , pubKey=${rawKey.take(20 )} ..." )
299+ }
300+ } catch (e: Exception ) {
301+ loge(TAG , " Failed to fetch on-chain keys for $address : ${e.message} " )
302+ }
303+ }
304+
305+ logd(TAG , " Built on-chain key map with ${keyMap.size} entries" )
306+ return keyMap
307+ }
308+
309+ /* *
310+ * Extracts the EC public key from an Android Keystore alias as a hex string.
311+ *
312+ * The public key is extracted from the self-signed certificate associated with the
313+ * Keystore entry. This works for both hardware-backed and software-backed keys
314+ * because the certificate always contains the public key, even when the private key
315+ * is non-extractable.
316+ *
317+ * The returned hex string is the raw uncompressed point (x || y) without the 04 prefix,
318+ * matching Flow's on-chain key format.
319+ */
320+ private fun extractPublicKeyHex (keyStore : KeyStore , alias : String ): String? {
321+ return try {
322+ val cert: Certificate = keyStore.getCertificate(alias) ? : return null
323+ val publicKey = cert.publicKey
324+
325+ if (publicKey !is ECPublicKey ) {
326+ logd(TAG , " Key $alias is not an EC key: ${publicKey.javaClass.simpleName} " )
327+ return null
328+ }
329+
330+ val ecPoint: ECPoint = publicKey.w
331+ val xBytes = ecPoint.affineX.toByteArray()
332+ val yBytes = ecPoint.affineY.toByteArray()
333+
334+ // Normalize to exactly 32 bytes each (strip leading zero, or left-pad)
335+ val x = normalizeCoordinate(xBytes)
336+ val y = normalizeCoordinate(yBytes)
337+
338+ // Return as raw 64-byte hex (no 04 prefix), matching Flow's format
339+ (x + y).joinToString(" " ) { " %02x" .format(it) }
340+ } catch (e: Exception ) {
341+ loge(TAG , " Failed to extract public key from $alias : ${e.message} " )
342+ null
343+ }
344+ }
345+
346+ /* *
347+ * Normalizes a BigInteger byte array to exactly 32 bytes:
348+ * - Strips leading zero byte (from positive BigInteger encoding)
349+ * - Left-pads with zeros if shorter than 32 bytes
350+ */
351+ private fun normalizeCoordinate (bytes : ByteArray ): ByteArray {
352+ return when {
353+ bytes.size == 32 -> bytes
354+ bytes.size == 33 && bytes[0 ] == 0 .toByte() -> bytes.copyOfRange(1 , 33 )
355+ bytes.size > 33 -> {
356+ // Take the last 32 bytes
357+ bytes.copyOfRange(bytes.size - 32 , bytes.size)
358+ }
359+ bytes.size < 32 -> {
360+ val padded = ByteArray (32 )
361+ System .arraycopy(bytes, 0 , padded, 32 - bytes.size, bytes.size)
362+ padded
363+ }
364+ else -> bytes
365+ }
366+ }
367+
368+ /* *
369+ * Checks whether a given prefix is already tracked by any UID in [UserPrefixCacheManager].
370+ */
371+ private fun isAliasAlreadyMapped (prefix : String ): Boolean {
372+ return try {
373+ val userPrefixes = UserPrefixCacheManager .read() ? : emptyList()
374+ userPrefixes.any { it.prefix == prefix }
375+ } catch (e: Exception ) {
376+ false
377+ }
378+ }
41379}
0 commit comments