From 5c47a36dce099696e25b6e8f75bb939b60fc3f36 Mon Sep 17 00:00:00 2001 From: greenart7c3 Date: Fri, 25 Sep 2026 05:11:12 -0300 Subject: [PATCH] Bunker proxy: handle NIP-55 intents targeting a non-active proxy account --- .../greenart7c3/nostrsigner/MainViewModel.kt | 14 +- .../nostrsigner/service/IntentUtils.kt | 120 ++++++++++++- .../service/IntentUtilsProxyIntentTest.kt | 162 ++++++++++++++++++ 3 files changed, 284 insertions(+), 12 deletions(-) create mode 100644 app/src/test/java/com/greenart7c3/nostrsigner/service/IntentUtilsProxyIntentTest.kt diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/MainViewModel.kt b/app/src/main/java/com/greenart7c3/nostrsigner/MainViewModel.kt index 8b793625..1f6119f0 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/MainViewModel.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/MainViewModel.kt @@ -132,12 +132,14 @@ class MainViewModel(val context: Context) : ViewModel() { account?.let { acc -> val intentData = IntentUtils.getIntentData(context, intent, callingPackage, intent.getStringExtra("route"), acc) if (intentData != null) { - if (acc.isProxy) { - // Bunker proxy: forward the request silently. Never enqueue - // it for the approval UI. - val handled = IntentUtils.handleProxyIntent(context, acc, intentData, callingPackage) - if (handled) return@let - } + // Bunker proxy: forward the request silently. Never enqueue it + // for the approval UI. handleProxyIntent also resolves the case + // where the intent targets a proxy account that is not the + // currently active one — SignerActivity hides itself for those, + // so enqueueing would hang the caller on a UI that never + // renders. + val handled = IntentUtils.handleProxyIntent(context, acc, intentData, callingPackage) + if (handled) return@let IntentUtils.addAll(listOf(intentData)) addedIntentIds.add(intentData.id) // The calling app is visible to us now; capture its icon/name. diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/service/IntentUtils.kt b/app/src/main/java/com/greenart7c3/nostrsigner/service/IntentUtils.kt index 725b2c1a..34778437 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/service/IntentUtils.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/service/IntentUtils.kt @@ -157,7 +157,10 @@ object IntentUtils { * Bunker-proxy short-circuit for `nostrsigner://` intents. Forwards the request * to the remote bunker (or computes the result locally for trivial methods) and * delivers the result back to the caller via [Activity.setResult] or the - * provided callback URL. Per-app permissions and approval UI are skipped. + * provided callback URL. The approval UI is skipped; the caller is instead + * registered via [registerProxyApp] so it shows up on the main screen — + * without this a NIP-55 `get_public_key` connect would hang forever and the + * app would never be saved. * * Returns true if the intent was handled (so the caller should not enqueue it * for the approval UI). @@ -168,12 +171,28 @@ object IntentUtils { intentData: IntentData, packageName: String?, ): Boolean { - if (!account.isProxy) return false + if (!account.isProxy) { + // The caller may target a *different* account than the one that is + // active: SignerActivity hides itself whenever the intent's target is + // a proxy account, so letting this fall through to the approval queue + // would leave the caller waiting on a UI that never renders. + if (intentData.currentAccount.isNotBlank() && intentData.currentAccount != account.npub) { + val target = LocalPreferences.loadFromEncryptedStorageSync(context, intentData.currentAccount) + if (target?.isProxy == true) { + return handleProxyIntent(context, target, intentData, packageName) + } + } + return false + } - // GET_PUBLIC_KEY with more than one account: fall through to the existing - // approval UI so the user can pick which account to expose. + // A get_public_key targeting a *different* account is not ours to answer. + // (With several accounts the approval UI would normally let the user pick + // which account to expose — but that UI never renders on the proxy path, + // so falling through would just hang the caller forever.) if (intentData.type == SignerType.GET_PUBLIC_KEY && - LocalPreferences.allSavedAccounts(context).size > 1 + intentData.currentAccount.isNotBlank() && + intentData.currentAccount != account.npub && + intentData.currentAccount != account.hexKey ) { return false } @@ -220,6 +239,13 @@ object IntentUtils { else -> return false } + // The approval UI never renders on the proxy path, so the app must be + // registered here — a first-ever `get_public_key` connect would + // otherwise never be saved and never show up on the main screen. + if (packageName != null) { + registerProxyApp(context, account, packageName, intentData) + } + deliverProxyResult(context, packageName, account, intentData, event, value) return true } catch (e: Exception) { @@ -240,6 +266,77 @@ object IntentUtils { } } + /** + * Persists the caller of a handled proxy intent as a connected application + * on [account], mirroring what the normal approval flow's [sendResult] does: + * an [ApplicationEntity] keyed by the caller's package plus an accepted + * permission row for [intentData]'s type, so the app appears on the main + * screen. No local crypto is involved and the request itself is still + * forwarded to the remote bunker per-request. + */ + private suspend fun registerProxyApp( + context: Context, + account: Account, + packageName: String, + intentData: IntentData, + ) { + val dao = Amber.instance.dao(account.npub) + val application = + dao.getByKey(packageName) ?: run { + val localAppName = + try { + val info = context.packageManager.getApplicationInfo(packageName, 0) + context.packageManager.getApplicationLabel(info).toString() + } catch (_: Exception) { + null + } + ApplicationWithPermissions( + application = ApplicationEntity( + key = packageName, + name = localAppName ?: "", + relays = emptyList(), + url = "", + icon = "", + description = "", + pubKey = account.hexKey, + isConnected = true, + secret = "", + useSecret = false, + signPolicy = 2, + closeApplication = true, + deleteAfter = 0L, + lastUsed = TimeUtils.now(), + ), + permissions = mutableListOf(), + ) + } + application.application.isConnected = true + val permissionKind = + if (intentData.type == SignerType.NIP44_V3_ENCRYPT || intentData.type == SignerType.NIP44_V3_DECRYPT) { + intentData.nip44v3Kind + } else { + null + } + if (application.permissions.none { it.type == intentData.type.toString() && it.kind == permissionKind }) { + application.permissions.add( + ApplicationPermissionsEntity( + null, + packageName, + intentData.type.toString(), + permissionKind, + true, + RememberType.ALWAYS.screenCode, + Long.MAX_VALUE / 1000, + 0, + ), + ) + } + dao.insertApplicationWithPermissions(application) + // The caller is visible to us right now; capture its icon/name so a + // first-ever connection populates them immediately. + persistNativeAppMetadata(context, account, packageName) + } + private fun deliverProxyResult( context: Context, packageName: String?, @@ -688,6 +785,17 @@ object IntentUtils { npub = parsePubKey(npub) } + // A connect request may carry neither current_user nor pubkey (the + // caller is asking for the key, it does not know one yet). + val npubAccount = npub + ?: pubKey.takeIf { it.isNotBlank() }?.let { key -> + try { + Hex.decode(key).toNpub() + } catch (_: Exception) { + null + } + } + IntentData( data = data, name = name, @@ -698,7 +806,7 @@ object IntentUtils { compression = compressionType, returnType = returnType, permissions = permissions?.map { Permission(it.type.trim(), it.kind, it.checked) }, - currentAccount = npub ?: Hex.decode(pubKey).toNpub(), + currentAccount = npubAccount ?: "", route = route, event = null, encryptedData = null, diff --git a/app/src/test/java/com/greenart7c3/nostrsigner/service/IntentUtilsProxyIntentTest.kt b/app/src/test/java/com/greenart7c3/nostrsigner/service/IntentUtilsProxyIntentTest.kt new file mode 100644 index 00000000..d15e6652 --- /dev/null +++ b/app/src/test/java/com/greenart7c3/nostrsigner/service/IntentUtilsProxyIntentTest.kt @@ -0,0 +1,162 @@ +package com.greenart7c3.nostrsigner.service + +import android.content.Context +import com.greenart7c3.nostrsigner.Amber +import com.greenart7c3.nostrsigner.AmberLog +import com.greenart7c3.nostrsigner.LocalPreferences +import com.greenart7c3.nostrsigner.database.ApplicationDao +import com.greenart7c3.nostrsigner.database.ApplicationWithPermissions +import com.greenart7c3.nostrsigner.database.HistoryDao +import com.greenart7c3.nostrsigner.database.HistoryDatabase +import com.greenart7c3.nostrsigner.models.Account +import com.greenart7c3.nostrsigner.models.CompressionType +import com.greenart7c3.nostrsigner.models.IntentData +import com.greenart7c3.nostrsigner.models.ReturnType +import com.greenart7c3.nostrsigner.models.SignerType +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.slot +import io.mockk.unmockkAll +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +/** + * Regression tests for the bunker-proxy NIP-55 intent path. The approval UI never + * renders for proxy accounts (SignerActivity stays invisible), so a `get_public_key` + * connect must be answered AND the caller registered in the database — otherwise + * the calling app hangs forever and never shows up on Amber's main screen. + */ +class IntentUtilsProxyIntentTest { + private val context = mockk(relaxed = true) + private lateinit var amber: Amber + private lateinit var dao: ApplicationDao + private lateinit var proxyAccount: Account + private lateinit var normalAccount: Account + + companion object { + private val HEX = "ab".repeat(32) + private const val PROXY_NPUB = "npub1proxy" + private const val CALLER = "com.amethyst" + } + + @Before + fun setUp() { + mockkObject(AmberLog) + every { AmberLog.d(any(), any()) } returns Unit + every { AmberLog.d(any(), any(), any()) } returns Unit + every { AmberLog.e(any(), any()) } returns Unit + every { AmberLog.e(any(), any(), any()) } returns Unit + + amber = mockk() + every { amber.applicationIOScope } returns CoroutineScope(Dispatchers.Unconfined) + every { amber.getMainActivity() } returns null + every { amber.getLogDatabase(any()) } returns mockk(relaxed = true) + val historyDao = mockk(relaxed = true) + val historyDatabase = mockk() + every { historyDatabase.dao() } returns historyDao + every { amber.getHistoryDatabase(any()) } returns historyDatabase + + dao = mockk() + every { amber.dao(any()) } returns dao + installAmberInstance(amber) + + proxyAccount = mockk(relaxed = true) + every { proxyAccount.isProxy } returns true + every { proxyAccount.npub } returns PROXY_NPUB + every { proxyAccount.hexKey } returns HEX + every { proxyAccount.signPolicy } returns 1 + + normalAccount = mockk(relaxed = true) + every { normalAccount.isProxy } returns false + + // App not saved yet, so registerProxyApp takes the create path. + coEvery { dao.getByKey(any()) } returns null + } + + @After + fun tearDown() { + unmockkAll() + } + + private fun intentData( + type: SignerType = SignerType.GET_PUBLIC_KEY, + currentAccount: String = PROXY_NPUB, + ) = IntentData( + data = "", + name = "Amethyst", + type = type, + pubKey = HEX, + id = "id-1", + callBackUrl = null, + compression = CompressionType.NONE, + returnType = ReturnType.SIGNATURE, + permissions = null, + currentAccount = currentAccount, + route = null, + event = null, + encryptedData = null, + ) + + @Test + fun `proxy get_public_key intent is answered and saves the app`() = runBlocking { + val captured = slot() + coEvery { dao.insertApplicationWithPermissions(capture(captured)) } returns Unit + + val handled = IntentUtils.handleProxyIntent(context, proxyAccount, intentData(), CALLER) + + assertTrue(handled) + val saved = captured.captured + assertEquals(CALLER, saved.application.key) + assertEquals(HEX, saved.application.pubKey) + assertTrue(saved.application.isConnected) + assertTrue(saved.permissions.any { it.type == "GET_PUBLIC_KEY" && it.acceptable }) + coVerify(exactly = 1) { dao.insertApplicationWithPermissions(any()) } + } + + @Test + fun `proxy get_public_key targeting another account is not handled`() = runBlocking { + val handled = IntentUtils.handleProxyIntent( + context, + proxyAccount, + intentData(currentAccount = "npub1other"), + CALLER, + ) + + assertFalse(handled) + coVerify(exactly = 0) { dao.insertApplicationWithPermissions(any()) } + } + + @Test + fun `non-proxy account falls through to the approval flow`() = runBlocking { + val handled = IntentUtils.handleProxyIntent(context, normalAccount, intentData(), CALLER) + + assertFalse(handled) + coVerify(exactly = 0) { dao.insertApplicationWithPermissions(any()) } + } + + @Test + fun `get_public_key targeting a proxy account is handled even when another account is active`() = runBlocking { + mockkObject(LocalPreferences) + every { LocalPreferences.loadFromEncryptedStorageSync(any(), eq(PROXY_NPUB)) } returns proxyAccount + val captured = slot() + coEvery { dao.insertApplicationWithPermissions(capture(captured)) } returns Unit + + // Active account is normal, but the intent targets the proxy account. + val handled = IntentUtils.handleProxyIntent(context, normalAccount, intentData(), CALLER) + + assertTrue(handled) + assertEquals(CALLER, captured.captured.application.key) + assertTrue(captured.captured.permissions.any { it.type == "GET_PUBLIC_KEY" && it.acceptable }) + coVerify(exactly = 1) { dao.insertApplicationWithPermissions(any()) } + } +}