mirror of
https://github.com/greenart7c3/Amber.git
synced 2026-10-05 19:08:23 +00:00
Bunker proxy: handle NIP-55 intents targeting a non-active proxy account
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()) }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user