Bunker proxy: handle NIP-55 intents targeting a non-active proxy account

This commit is contained in:
greenart7c3
2026-09-25 05:11:12 -03:00
parent 3bb54b4cc4
commit 5c47a36dce
3 changed files with 284 additions and 12 deletions
@@ -132,12 +132,14 @@ class MainViewModel(val context: Context) : ViewModel() {
account?.let { acc -> account?.let { acc ->
val intentData = IntentUtils.getIntentData(context, intent, callingPackage, intent.getStringExtra("route"), acc) val intentData = IntentUtils.getIntentData(context, intent, callingPackage, intent.getStringExtra("route"), acc)
if (intentData != null) { if (intentData != null) {
if (acc.isProxy) { // Bunker proxy: forward the request silently. Never enqueue it
// Bunker proxy: forward the request silently. Never enqueue // for the approval UI. handleProxyIntent also resolves the case
// it for the approval UI. // where the intent targets a proxy account that is not the
val handled = IntentUtils.handleProxyIntent(context, acc, intentData, callingPackage) // currently active one — SignerActivity hides itself for those,
if (handled) return@let // 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)) IntentUtils.addAll(listOf(intentData))
addedIntentIds.add(intentData.id) addedIntentIds.add(intentData.id)
// The calling app is visible to us now; capture its icon/name. // The calling app is visible to us now; capture its icon/name.
@@ -157,7 +157,10 @@ object IntentUtils {
* Bunker-proxy short-circuit for `nostrsigner://` intents. Forwards the request * Bunker-proxy short-circuit for `nostrsigner://` intents. Forwards the request
* to the remote bunker (or computes the result locally for trivial methods) and * 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 * 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 * Returns true if the intent was handled (so the caller should not enqueue it
* for the approval UI). * for the approval UI).
@@ -168,12 +171,28 @@ object IntentUtils {
intentData: IntentData, intentData: IntentData,
packageName: String?, packageName: String?,
): Boolean { ): 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 // A get_public_key targeting a *different* account is not ours to answer.
// approval UI so the user can pick which account to expose. // (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 && 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 return false
} }
@@ -220,6 +239,13 @@ object IntentUtils {
else -> return false 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) deliverProxyResult(context, packageName, account, intentData, event, value)
return true return true
} catch (e: Exception) { } 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( private fun deliverProxyResult(
context: Context, context: Context,
packageName: String?, packageName: String?,
@@ -688,6 +785,17 @@ object IntentUtils {
npub = parsePubKey(npub) 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( IntentData(
data = data, data = data,
name = name, name = name,
@@ -698,7 +806,7 @@ object IntentUtils {
compression = compressionType, compression = compressionType,
returnType = returnType, returnType = returnType,
permissions = permissions?.map { Permission(it.type.trim(), it.kind, it.checked) }, permissions = permissions?.map { Permission(it.type.trim(), it.kind, it.checked) },
currentAccount = npub ?: Hex.decode(pubKey).toNpub(), currentAccount = npubAccount ?: "",
route = route, route = route,
event = null, event = null,
encryptedData = null, encryptedData = null,
@@ -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<Context>(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<String>()) } returns Unit
every { AmberLog.d(any(), any(), any()) } returns Unit
every { AmberLog.e(any(), any<String>()) } 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<HistoryDao>(relaxed = true)
val historyDatabase = mockk<HistoryDatabase>()
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<ApplicationWithPermissions>()
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<ApplicationWithPermissions>()
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()) }
}
}