From 3bb54b4cc49efff8afa6ff0e07ab9a7f021942d8 Mon Sep 17 00:00:00 2001 From: greenart7c3 Date: Fri, 25 Sep 2026 05:11:03 -0300 Subject: [PATCH] Bunker proxy: cache proxy account resolution out of the response hot path --- .../service/ProxyResponseSubscription.kt | 60 +++++---- .../ProxyResponseSubscriptionTest.kt | 119 ++++++++++++++++++ 2 files changed, 155 insertions(+), 24 deletions(-) create mode 100644 app/src/test/java/com/greenart7c3/nostrsigner/ProxyResponseSubscriptionTest.kt diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/service/ProxyResponseSubscription.kt b/app/src/main/java/com/greenart7c3/nostrsigner/service/ProxyResponseSubscription.kt index 97ddbab4..f8186536 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/service/ProxyResponseSubscription.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/service/ProxyResponseSubscription.kt @@ -5,6 +5,7 @@ import android.util.Log import com.greenart7c3.nostrsigner.Amber import com.greenart7c3.nostrsigner.BuildFlavorChecker import com.greenart7c3.nostrsigner.LocalPreferences +import com.greenart7c3.nostrsigner.models.Account import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair @@ -20,7 +21,6 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal import com.vitorpamplona.quartz.nip01Core.tags.people.taggedUsers import com.vitorpamplona.quartz.nip04Dm.crypto.EncryptedInfo -import com.vitorpamplona.quartz.nip19Bech32.toNpub import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponse import com.vitorpamplona.quartz.nip46RemoteSigner.NostrConnectEvent import com.vitorpamplona.quartz.utils.TimeUtils @@ -49,10 +49,22 @@ class ProxyResponseSubscription( private val transientLogins = ConcurrentHashMap() private val pendingInitialConnect = ConcurrentHashMap>() + /** + * In-memory index of proxy accounts by their local proxy pubkey (hex), filled by + * [updateFilter]. Keeps the Keystore-backed account loading out of the per-event + * hot path; local proxy keys are freshly random per account, so the mapping is + * collision-free. + */ + private val proxyAccountsByLocalPub = ConcurrentHashMap() + + internal fun resolveForTaggedKey(localPubHex: String): Account? = proxyAccountsByLocalPub[localPubHex] + private data class TransientLogin( val localPrivKey: ByteArray, val remotePubkey: String, val secret: String, + val signer: NostrSignerInternal, + val localPubHex: String, ) init { @@ -72,27 +84,18 @@ class ProxyResponseSubscription( if (event.kind != NostrConnectEvent.KIND) return if (!event.verify()) return - val taggedKey = event.taggedUsers().firstOrNull() ?: return - val taggedNpub = taggedKey.toNPub() + val taggedKey = event.taggedUsers().firstOrNull()?.pubKey ?: return Amber.instance.applicationIOScope.launch { - transientLogins.values.firstOrNull { tl -> - NostrSignerInternal(KeyPair(privKey = tl.localPrivKey)).keyPair.pubKey.toNpub() == taggedNpub - }?.let { tl -> - handleViaSigner(event, NostrSignerInternal(KeyPair(privKey = tl.localPrivKey)), tl.remotePubkey, tl.secret, relayUrl) + transientLogins.values.firstOrNull { it.localPubHex == taggedKey }?.let { tl -> + handleViaSigner(event, tl.signer, tl.remotePubkey, tl.secret, relayUrl, tl.localPubHex) return@launch } - val accounts = LocalPreferences.allSavedAccounts(appContext) - for (info in accounts) { - val account = LocalPreferences.loadFromEncryptedStorage(appContext, info.npub) ?: continue - val proxy = account.proxy ?: continue - val localPub = account.signer.keyPair.pubKey.toNpub() - if (localPub != taggedNpub) continue - if (event.pubKey != proxy.remotePubkey) continue - handleViaSigner(event, account.signer, proxy.remotePubkey, "", relayUrl) - return@launch - } + val account = resolveForTaggedKey(taggedKey) ?: return@launch + val proxy = account.proxy ?: return@launch + if (event.pubKey != proxy.remotePubkey) return@launch + handleViaSigner(event, account.signer, proxy.remotePubkey, "", relayUrl, taggedKey) } } @@ -102,6 +105,7 @@ class ProxyResponseSubscription( remotePubkey: String, secret: String, relayUrl: String, + localPubHex: String, ) { val decrypted = try { val isNip04 = EncryptedInfo.isNIP04(event.content) @@ -123,12 +127,11 @@ class ProxyResponseSubscription( if (response != null) { RemoteBunkerClient.deliverResponse(response) - val localPub = signer.keyPair.pubKey.toHexKey() - pendingInitialConnect[localPub]?.let { deferred -> + pendingInitialConnect[localPubHex]?.let { deferred -> val result = response.result if (response.error.isNullOrBlank() && (secret.isEmpty() || result == secret || result == "ack")) { deferred.complete(event.pubKey) - pendingInitialConnect.remove(localPub) + pendingInitialConnect.remove(localPubHex) } } return @@ -139,10 +142,9 @@ class ProxyResponseSubscription( val node = JacksonMapper.mapper.readTree(decrypted) val method = node.get("method")?.asText() ?: return if (method == "connect") { - val localPub = signer.keyPair.pubKey.toHexKey() - pendingInitialConnect[localPub]?.let { deferred -> + pendingInitialConnect[localPubHex]?.let { deferred -> deferred.complete(event.pubKey) - pendingInitialConnect.remove(localPub) + pendingInitialConnect.remove(localPubHex) } } } catch (_: Exception) { @@ -177,12 +179,15 @@ class ProxyResponseSubscription( timeoutMs: Long, ): String? { val localPub = localKeyPair.pubKey.toHexKey() + val signer = NostrSignerInternal(localKeyPair) val deferred = CompletableDeferred() pendingInitialConnect[localPub] = deferred transientLogins[localPub] = TransientLogin( localPrivKey = localKeyPair.privKey!!, remotePubkey = "", secret = secret, + signer = signer, + localPubHex = localPub, ) subscribeForLocalKey(localPub, "", relays) return withTimeoutOrNull(timeoutMs) { deferred.await() } @@ -190,10 +195,13 @@ class ProxyResponseSubscription( fun registerTransientLogin(localPrivKey: ByteArray, remotePubkey: String) { val signer = NostrSignerInternal(KeyPair(privKey = localPrivKey)) - transientLogins[signer.keyPair.pubKey.toHexKey()] = TransientLogin( + val localPubHex = signer.keyPair.pubKey.toHexKey() + transientLogins[localPubHex] = TransientLogin( localPrivKey = localPrivKey, remotePubkey = remotePubkey, secret = "", + signer = signer, + localPubHex = localPubHex, ) } @@ -208,6 +216,7 @@ class ProxyResponseSubscription( if (BuildFlavorChecker.isOfflineFlavor()) return val activeSubKeys = mutableSetOf() activeSubKeys += subIds.keys.filter { it.startsWith("transient_") } + val activeLocalPubs = mutableSetOf() val since = TimeUtils.now() LocalPreferences.allAccounts(appContext).forEach { account -> @@ -215,6 +224,8 @@ class ProxyResponseSubscription( if (proxy.relays.isEmpty()) return@forEach val localPub = account.signer.keyPair.pubKey.toHexKey() + proxyAccountsByLocalPub[localPub] = account + activeLocalPubs.add(localPub) val subKey = "proxy_${account.npub}" activeSubKeys.add(subKey) if (!subIds.containsKey(subKey)) { @@ -239,5 +250,6 @@ class ProxyResponseSubscription( for (subKey in stale) { subIds.remove(subKey)?.let { client.unsubscribe(it) } } + proxyAccountsByLocalPub.keys.retainAll(activeLocalPubs) } } diff --git a/app/src/test/java/com/greenart7c3/nostrsigner/ProxyResponseSubscriptionTest.kt b/app/src/test/java/com/greenart7c3/nostrsigner/ProxyResponseSubscriptionTest.kt new file mode 100644 index 00000000..912653ff --- /dev/null +++ b/app/src/test/java/com/greenart7c3/nostrsigner/ProxyResponseSubscriptionTest.kt @@ -0,0 +1,119 @@ +package com.greenart7c3.nostrsigner + +import android.content.Context +import com.greenart7c3.nostrsigner.models.Account +import com.greenart7c3.nostrsigner.models.ProxyAccountMetadata +import com.greenart7c3.nostrsigner.service.ProxyResponseSubscription +import com.greenart7c3.nostrsigner.service.installAmberInstance +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.unmockkAll +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assume.assumeFalse +import org.junit.Before +import org.junit.Test + +/** + * Regression tests for the proxy-response hot path: resolution of an incoming + * kind-24133 response must come from the in-memory index built by + * [ProxyResponseSubscription.updateFilter], never from per-event Keystore-backed + * account loads. + */ +class ProxyResponseSubscriptionTest { + private val context = mockk(relaxed = true) + private val client = mockk(relaxed = true) + private lateinit var subscription: ProxyResponseSubscription + private lateinit var proxyAccount: Account + private lateinit var normalAccount: Account + + companion object { + private val PROXY_LOCAL_PUB = "cd".repeat(32) + private val NORMAL_LOCAL_PUB = "ef".repeat(32) + private val UNKNOWN_PUB = "01".repeat(32) + private val BUNKER_PUB = "ab".repeat(32) + } + + @Before + fun setUp() { + val amber = mockk() + every { amber.applicationIOScope } returns CoroutineScope(Dispatchers.Unconfined) + installAmberInstance(amber) + + subscription = ProxyResponseSubscription(client, context) + + proxyAccount = accountFor( + PROXY_LOCAL_PUB, + npub = "npub1proxy", + proxy = ProxyAccountMetadata( + remotePubkey = BUNKER_PUB, + relays = listOf(NormalizedRelayUrl("wss://relay.example.com")), + bunkerName = "bunker", + nostrConnectSecret = "", + ), + ) + normalAccount = accountFor(NORMAL_LOCAL_PUB, npub = "npub1normal", proxy = null) + + mockkObject(LocalPreferences) + coEvery { LocalPreferences.allAccounts(any()) } returns listOf(proxyAccount, normalAccount) + } + + @After + fun tearDown() { + unmockkAll() + } + + /** Read-only keypair (pubkey given, no crypto) so the local pubkey hex is deterministic. */ + private fun accountFor(localPubHex: String, npub: String, proxy: ProxyAccountMetadata?): Account = Account( + signer = NostrSignerInternal(KeyPair(pubKey = localPubHex.hexToByteArray())), + hexKey = BUNKER_PUB, + npub = npub, + name = MutableStateFlow(""), + picture = MutableStateFlow(""), + signPolicy = 1, + didBackup = true, + scope = CoroutineScope(Dispatchers.Unconfined), + proxy = proxy, + ) + + // updateFilter() is a no-op on the offline flavor by design (no network), + // so the proxy-account cache only exists in network flavors. + private fun skipOnOfflineFlavor() { + assumeFalse(BuildFlavorChecker.isOfflineFlavor()) + } + + @Test + fun `updateFilter caches proxy accounts and does not touch encrypted storage`() = runBlocking { + skipOnOfflineFlavor() + subscription.updateFilter() + + assertEquals(proxyAccount, subscription.resolveForTaggedKey(PROXY_LOCAL_PUB)) + assertNull(subscription.resolveForTaggedKey(NORMAL_LOCAL_PUB)) + assertNull(subscription.resolveForTaggedKey(UNKNOWN_PUB)) + coVerify(exactly = 0) { LocalPreferences.loadFromEncryptedStorage(any(), any()) } + } + + @Test + fun `updateFilter prunes cache entries of removed accounts`() = runBlocking { + skipOnOfflineFlavor() + subscription.updateFilter() + coEvery { LocalPreferences.allAccounts(any()) } returns emptyList() + + subscription.updateFilter() + + assertNull(subscription.resolveForTaggedKey(PROXY_LOCAL_PUB)) + } +}