Skip to content

Commit a9bf55c

Browse files
committed
feat(services): recover orphaned Android Keystore keys via on-chain matching
1 parent 1cb8bf2 commit a9bf55c

3 files changed

Lines changed: 480 additions & 1 deletion

File tree

app/src/main/java/com/flowfoundation/wallet/manager/account/AccountManager.kt

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,21 @@ object AccountManager {
129129
}
130130

131131
logd(TAG, "AccountManager initialization completed successfully")
132+
133+
// Attempt to recover orphaned Android Keystore keys that were
134+
// generated during key rotation but never persisted to the
135+
// account cache or KeyStorageManager. If any are found, they
136+
// will be written to KeyStorageManager so that
137+
// buildLocalKeyAccounts() can surface them in the account switcher.
138+
try {
139+
val recovered = KeyStoreMigrationManager.recoverOrphanedKeystoreKeys()
140+
if (recovered > 0) {
141+
logd(TAG, "Recovered $recovered orphaned Keystore key(s)")
142+
}
143+
} catch (e: Exception) {
144+
loge(TAG, "Orphaned key recovery failed (non-fatal): ${e.message}")
145+
}
146+
132147
// Update Accounts info with WalletDataManager
133148
WalletDataManager.updateWalletData()
134149

app/src/main/java/com/flowfoundation/wallet/manager/account/KeyStoreMigrationManager.kt

Lines changed: 338 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,26 @@
11
package 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
316
import com.flowfoundation.wallet.utils.logd
17+
import com.flowfoundation.wallet.utils.loge
418
import 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
*/
1449
object 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

Comments
 (0)