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 ->
|
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()) }
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user