Merge upstream/master and resolve database version conflict by bumping to 20

This commit is contained in:
greenart7c3
2026-08-14 06:50:48 -03:00
10 changed files with 768 additions and 31 deletions
@@ -2,7 +2,7 @@
"formatVersion": 1, "formatVersion": 1,
"database": { "database": {
"version": 19, "version": 19,
"identityHash": "4d6bba48f6829e611cf02157dcc20cf5", "identityHash": "a56de62f406b4670ae2cbaf4225fdc6e",
"entities": [ "entities": [
{ {
"tableName": "application", "tableName": "application",
@@ -225,11 +225,61 @@
] ]
} }
] ]
},
{
"tableName": "bunker_event",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `eventId` TEXT NOT NULL, `time` INTEGER NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "eventId",
"columnName": "eventId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "time",
"columnName": "time",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_bunker_event_id",
"unique": true,
"columnNames": [
"eventId"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_bunker_event_id` ON `${TABLE_NAME}` (`eventId`)"
},
{
"name": "index_bunker_event_time",
"unique": false,
"columnNames": [
"time"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_bunker_event_time` ON `${TABLE_NAME}` (`time`)"
}
]
} }
], ],
"setupQueries": [ "setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '4d6bba48f6829e611cf02157dcc20cf5')" "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'a56de62f406b4670ae2cbaf4225fdc6e')"
] ]
} }
} }
@@ -0,0 +1,285 @@
{
"formatVersion": 1,
"database": {
"version": 20,
"identityHash": "a56de62f406b4670ae2cbaf4225fdc6e",
"entities": [
{
"tableName": "application",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `name` TEXT NOT NULL, `relays` TEXT NOT NULL, `url` TEXT NOT NULL, `icon` TEXT NOT NULL, `description` TEXT NOT NULL, `pubKey` TEXT NOT NULL, `isConnected` INTEGER NOT NULL, `secret` TEXT NOT NULL, `useSecret` INTEGER NOT NULL, `signPolicy` INTEGER NOT NULL, `closeApplication` INTEGER NOT NULL, `deleteAfter` INTEGER NOT NULL, `lastUsed` INTEGER NOT NULL, `localKey` TEXT NOT NULL, PRIMARY KEY(`key`))",
"fields": [
{
"fieldPath": "key",
"columnName": "key",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "relays",
"columnName": "relays",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "icon",
"columnName": "icon",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "description",
"columnName": "description",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "pubKey",
"columnName": "pubKey",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "isConnected",
"columnName": "isConnected",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "secret",
"columnName": "secret",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "useSecret",
"columnName": "useSecret",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "signPolicy",
"columnName": "signPolicy",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "closeApplication",
"columnName": "closeApplication",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "deleteAfter",
"columnName": "deleteAfter",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastUsed",
"columnName": "lastUsed",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "localKey",
"columnName": "localKey",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"key"
]
},
"indices": [
{
"name": "index_key",
"unique": true,
"columnNames": [
"key"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_key` ON `${TABLE_NAME}` (`key`)"
},
{
"name": "index_name",
"unique": false,
"columnNames": [
"name"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_name` ON `${TABLE_NAME}` (`name`)"
}
]
},
{
"tableName": "applicationPermission",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER, `pkKey` TEXT NOT NULL, `type` TEXT NOT NULL, `kind` INTEGER, `acceptable` INTEGER NOT NULL, `rememberType` INTEGER NOT NULL, `acceptUntil` INTEGER NOT NULL, `rejectUntil` INTEGER NOT NULL, `relay` TEXT NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`pkKey`) REFERENCES `application`(`key`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER"
},
{
"fieldPath": "pkKey",
"columnName": "pkKey",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "type",
"columnName": "type",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "kind",
"columnName": "kind",
"affinity": "INTEGER"
},
{
"fieldPath": "acceptable",
"columnName": "acceptable",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "rememberType",
"columnName": "rememberType",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "acceptUntil",
"columnName": "acceptUntil",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "rejectUntil",
"columnName": "rejectUntil",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "relay",
"columnName": "relay",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "permissions_by_pk_key",
"unique": false,
"columnNames": [
"pkKey"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `permissions_by_pk_key` ON `${TABLE_NAME}` (`pkKey`)"
},
{
"name": "permissions_unique",
"unique": true,
"columnNames": [
"pkKey",
"type",
"kind",
"relay"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `permissions_unique` ON `${TABLE_NAME}` (`pkKey`, `type`, `kind`, `relay`)"
}
],
"foreignKeys": [
{
"table": "application",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"pkKey"
],
"referencedColumns": [
"key"
]
}
]
},
{
"tableName": "bunker_event",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `eventId` TEXT NOT NULL, `time` INTEGER NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "eventId",
"columnName": "eventId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "time",
"columnName": "time",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_bunker_event_id",
"unique": true,
"columnNames": [
"eventId"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_bunker_event_id` ON `${TABLE_NAME}` (`eventId`)"
},
{
"name": "index_bunker_event_time",
"unique": false,
"columnNames": [
"time"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_bunker_event_time` ON `${TABLE_NAME}` (`time`)"
}
]
}
],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'a56de62f406b4670ae2cbaf4225fdc6e')"
]
}
}
@@ -108,19 +108,22 @@ object SignerProviderQuery {
val historyDatabase = Amber.instance.getHistoryDatabase(account.npub) val historyDatabase = Amber.instance.getHistoryDatabase(account.npub)
val permDao = Amber.instance.dao(account.npub) val permDao = Amber.instance.dao(account.npub)
// For kind 22242 (NIP-42 relay auth), extract relay host once for both whitelist and permission checks // For kind 22242 (NIP-42 relay auth), extract relay host once for the
// whitelist gate and the requester-scoped permission checks below.
val relayHost = if (event.kind == 22242) { val relayHost = if (event.kind == 22242) {
RelayUrlUtils.extractHostAndPort(AmberEvent.relay(event)) RelayUrlUtils.extractHostAndPort(AmberEvent.relay(event))
} else { } else {
"" ""
} }
val whitelistAutoAccept = if (event.kind == 22242) { // The auth whitelist is only a relay constraint: when non-empty, relays
// outside the list are auto-rejected. Membership never authorizes signing
// by itself — a requester-scoped permission (or user prompt) is still
// required, otherwise any app/NIP-46 client could silently obtain
// relay-auth signatures (GHSA-vx4h-56qj-wcp7).
if (event.kind == 22242) {
val authWhitelist = Amber.instance.settings.authWhitelist val authWhitelist = Amber.instance.settings.authWhitelist
when { if (authWhitelist.isNotEmpty() && relayHost !in authWhitelist) {
authWhitelist.isEmpty() -> false
relayHost in authWhitelist -> true
else -> {
scope.launch { scope.launch {
historyDatabase.dao().addHistory( historyDatabase.dao().addHistory(
listOf( listOf(
@@ -137,12 +140,9 @@ object SignerProviderQuery {
account.npub, account.npub,
) )
} }
return MatrixCursor(arrayOf("rejected")).also { it.addRow(arrayOf("true")) } return rejectedCursor()
} }
} }
} else {
false
}
var permission = if (event.kind == 22242) { var permission = if (event.kind == 22242) {
// Kind 22242 = relay client auth (NIP-42): check relay-specific permission first // Kind 22242 = relay client auth (NIP-42): check relay-specific permission first
@@ -172,7 +172,7 @@ object SignerProviderQuery {
} }
} }
val signPolicy = permDao.getSignPolicy(requesterId) val signPolicy = permDao.getSignPolicy(requesterId)
val isRemembered = whitelistAutoAccept || IntentUtils.isRemembered(signPolicy, permission) ?: return null val isRemembered = IntentUtils.isRemembered(signPolicy, permission) ?: return null
if (!isRemembered) { if (!isRemembered) {
scope.launch { scope.launch {
historyDatabase.dao().addHistory( historyDatabase.dao().addHistory(
@@ -166,6 +166,14 @@ val MIGRATION_17_18 = object : Migration(17, 18) {
} }
} }
val MIGRATION_18_19 = object : Migration(18, 19) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("CREATE TABLE IF NOT EXISTS `bunker_event` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `eventId` TEXT NOT NULL, `time` INTEGER NOT NULL)")
db.execSQL("CREATE UNIQUE INDEX IF NOT EXISTS `index_bunker_event_id` ON `bunker_event` (`eventId`)")
db.execSQL("CREATE INDEX IF NOT EXISTS `index_bunker_event_time` ON `bunker_event` (`time`)")
}
}
/** /**
* GHSA-5fjp-ghh8-wch8 (CWE-312): envelope-encrypt the two NIP-46 secret columns * GHSA-5fjp-ghh8-wch8 (CWE-312): envelope-encrypt the two NIP-46 secret columns
* of the `application` table — `secret` (bunker shared secret) and `localKey` * of the `application` table — `secret` (bunker shared secret) and `localKey`
@@ -180,9 +188,9 @@ val MIGRATION_17_18 = object : Migration(17, 18) {
* *
* Idempotency: Room migrations run exactly once per version bump. The * Idempotency: Room migrations run exactly once per version bump. The
* migration is not safe to re-run on already-encrypted data (it would * migration is not safe to re-run on already-encrypted data (it would
* double-encrypt), so it must only run between schema 18 and 19. * double-encrypt), so it must only run between schema 19 and 20.
*/ */
val MIGRATION_18_19 = object : Migration(18, 19) { val MIGRATION_19_20 = object : Migration(19, 20) {
override fun migrate(db: SupportSQLiteDatabase) { override fun migrate(db: SupportSQLiteDatabase) {
db.beginTransaction() db.beginTransaction()
try { try {
@@ -207,7 +215,7 @@ val MIGRATION_18_19 = object : Migration(18, 19) {
} }
db.setTransactionSuccessful() db.setTransactionSuccessful()
} catch (e: Exception) { } catch (e: Exception) {
AmberLog.e(Amber.TAG, "MIGRATION_18_19: failed to envelope-encrypt application secrets", e) AmberLog.e(Amber.TAG, "MIGRATION_19_20: failed to envelope-encrypt application secrets", e)
throw e throw e
} finally { } finally {
db.endTransaction() db.endTransaction()
@@ -219,13 +227,16 @@ val MIGRATION_18_19 = object : Migration(18, 19) {
entities = [ entities = [
ApplicationEntity::class, ApplicationEntity::class,
ApplicationPermissionsEntity::class, ApplicationPermissionsEntity::class,
BunkerEventEntity::class,
], ],
version = 19, version = 20,
) )
@TypeConverters(Converters::class) @TypeConverters(Converters::class)
abstract class AppDatabase : RoomDatabase() { abstract class AppDatabase : RoomDatabase() {
abstract fun dao(): ApplicationDao abstract fun dao(): ApplicationDao
abstract fun bunkerEventDao(): BunkerEventDao
companion object { companion object {
fun getDatabase( fun getDatabase(
context: Context, context: Context,
@@ -260,6 +271,7 @@ abstract class AppDatabase : RoomDatabase() {
.addMigrations(MIGRATION_16_17) .addMigrations(MIGRATION_16_17)
.addMigrations(MIGRATION_17_18) .addMigrations(MIGRATION_17_18)
.addMigrations(MIGRATION_18_19) .addMigrations(MIGRATION_18_19)
.addMigrations(MIGRATION_19_20)
.build() .build()
instance instance
@@ -0,0 +1,21 @@
package com.greenart7c3.nostrsigner.database
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Transaction
@Dao
interface BunkerEventDao {
@Insert(onConflict = OnConflictStrategy.IGNORE)
@Transaction
suspend fun insert(bunkerEvent: BunkerEventEntity): Long?
@Query("SELECT EXISTS(SELECT 1 FROM bunker_event WHERE eventId = :eventId)")
suspend fun exists(eventId: String): Boolean
@Query("DELETE FROM bunker_event WHERE time < :time")
@Transaction
suspend fun deleteOld(time: Long): Int
}
@@ -0,0 +1,26 @@
package com.greenart7c3.nostrsigner.database
import androidx.room.Entity
import androidx.room.Index
import androidx.room.PrimaryKey
@Entity(
tableName = "bunker_event",
indices = [
Index(
value = ["eventId"],
name = "index_bunker_event_id",
unique = true,
),
Index(
value = ["time"],
name = "index_bunker_event_time",
),
],
)
data class BunkerEventEntity(
@PrimaryKey(autoGenerate = true)
val id: Int,
val eventId: String,
val time: Long,
)
@@ -47,6 +47,12 @@ class ClearLogsWorker(appContext: Context, workerParams: WorkerParameters) : Cor
AmberLog.d(Amber.TAG, "Trimmed $excessLogs excess log entries (cap: $MAX_LOG_ENTRIES)") AmberLog.d(Amber.TAG, "Trimmed $excessLogs excess log entries (cap: $MAX_LOG_ENTRIES)")
} }
val bunkerEventDao = Amber.instance.getDatabase(it.npub).bunkerEventDao()
val deletedBunkerEvents = bunkerEventDao.deleteOld(threeDaysAgo / 1000)
if (deletedBunkerEvents > 0) {
AmberLog.d(Amber.TAG, "Deleted $deletedBunkerEvents old bunker event entries")
}
val dao = Amber.instance.dao(it.npub) val dao = Amber.instance.dao(it.npub)
dao.updateExpiredPermissions(TimeUtils.now()) dao.updateExpiredPermissions(TimeUtils.now())
val deleted = dao.deleteOldApplications(now / 1000) val deleted = dao.deleteOldApplications(now / 1000)
@@ -33,6 +33,7 @@ import com.greenart7c3.nostrsigner.LocalPreferences
import com.greenart7c3.nostrsigner.R import com.greenart7c3.nostrsigner.R
import com.greenart7c3.nostrsigner.SignerProviderQuery import com.greenart7c3.nostrsigner.SignerProviderQuery
import com.greenart7c3.nostrsigner.database.ApplicationWithPermissions import com.greenart7c3.nostrsigner.database.ApplicationWithPermissions
import com.greenart7c3.nostrsigner.database.BunkerEventEntity
import com.greenart7c3.nostrsigner.database.HistoryEntity import com.greenart7c3.nostrsigner.database.HistoryEntity
import com.greenart7c3.nostrsigner.database.LogEntity import com.greenart7c3.nostrsigner.database.LogEntity
import com.greenart7c3.nostrsigner.models.Account import com.greenart7c3.nostrsigner.models.Account
@@ -56,6 +57,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import com.vitorpamplona.quartz.nip01Core.tags.people.taggedUsers import com.vitorpamplona.quartz.nip01Core.tags.people.taggedUsers
import com.vitorpamplona.quartz.nip04Dm.crypto.EncryptedInfo import com.vitorpamplona.quartz.nip04Dm.crypto.EncryptedInfo
import com.vitorpamplona.quartz.nip40Expiration.expiration
import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequest import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequest
import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestConnect import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestConnect
import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestNip04Decrypt import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestNip04Decrypt
@@ -130,6 +132,22 @@ class EventNotificationConsumer(private val applicationContext: Context) {
return return
} }
val now = TimeUtils.now()
if (event.createdAt < now - TimeUtils.FIVE_MINUTES) {
saveLog("Event ${event.id} is too old: ${now - event.createdAt}s ago", relay.url)
return
}
if (event.createdAt > now + TimeUtils.FIVE_MINUTES) {
saveLog("Event ${event.id} is in the future: ${event.createdAt - now}s ahead", relay.url)
return
}
val expiration = event.expiration()
if (expiration != null && expiration < now) {
saveLog("Event ${event.id} has expired", relay.url)
return
}
NotificationUtils.getOrCreateBunkerChannel(applicationContext) NotificationUtils.getOrCreateBunkerChannel(applicationContext)
NotificationUtils.getOrCreateErrorsChannel(applicationContext) NotificationUtils.getOrCreateErrorsChannel(applicationContext)
@@ -167,6 +185,14 @@ class EventNotificationConsumer(private val applicationContext: Context) {
saveLog("Tagged account ${taggedKey.toNPub()} not logged in", relay.url) saveLog("Tagged account ${taggedKey.toNPub()} not logged in", relay.url)
return return
} }
val bunkerEventDao = Amber.instance.getDatabase(acc.npub).bunkerEventDao()
if (bunkerEventDao.exists(event.id)) {
saveLog("Event ${event.id} already processed (persistent cache)", relay.url)
return
}
bunkerEventDao.insert(BunkerEventEntity(0, event.id, event.createdAt))
notify(event, acc, relay, connectionPrivKey) notify(event, acc, relay, connectionPrivKey)
} }
@@ -0,0 +1,182 @@
package com.greenart7c3.nostrsigner
import android.content.Context
import android.net.Uri
import com.greenart7c3.nostrsigner.database.ApplicationDao
import com.greenart7c3.nostrsigner.database.ApplicationPermissionsEntity
import com.greenart7c3.nostrsigner.database.HistoryDao
import com.greenart7c3.nostrsigner.database.HistoryDatabase
import com.greenart7c3.nostrsigner.models.Account
import com.greenart7c3.nostrsigner.models.AmberSettings
import com.greenart7c3.nostrsigner.service.installAmberInstance
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.utils.TimeUtils
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkObject
import io.mockk.unmockkObject
import io.mockk.verify
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import org.junit.After
import org.junit.Assert.assertNull
import org.junit.Before
import org.junit.Test
/**
* Regression tests for GHSA-vx4h-56qj-wcp7: the NIP-42 relay-auth whitelist
* must act only as a relay constraint (non-whitelisted relays auto-reject),
* never as an authorization grant. Signing a kind-22242 event always requires
* a requester-scoped permission (or a user prompt).
*
* MatrixCursor is an android.jar stub in local unit tests, so cursor contents
* cannot be inspected here; instead these tests verify whether signing was
* reached at all ([Account.signSync]) and which permission lookups ran.
*/
class SignerProviderQueryTest {
private val context = mockk<Context>(relaxed = true)
private lateinit var amber: Amber
private lateinit var dao: ApplicationDao
private lateinit var account: Account
companion object {
private const val REQUESTER = "com.attacker.app"
private const val NPUB = "npub1test000000000000000000000000000000000000000000000000000000"
private const val WHITELISTED_HOST = "relay.example.com"
private const val WHITELISTED_URL = "wss://relay.example.com"
private const val OTHER_URL = "wss://evil.example.com"
private val HEX = "ab".repeat(32)
private fun relayAuthJson(relayUrl: String) = """{"kind":22242,"tags":[["relay","$relayUrl"]],"content":""}"""
}
@Before
fun setUp() {
mockkObject(AmberLog)
every { AmberLog.d(any(), any<String>()) } returns Unit
every { AmberLog.d(any(), any(), any()) } returns Unit
val historyDao = mockk<HistoryDao>(relaxed = true)
val historyDatabase = mockk<HistoryDatabase>()
every { historyDatabase.dao() } returns historyDao
dao = mockk()
amber = mockk()
every { amber.applicationIOScope } returns CoroutineScope(Dispatchers.Unconfined)
every { amber.getHistoryDatabase(any()) } returns historyDatabase
every { amber.dao(any()) } returns dao
installAmberInstance(amber)
account = mockk()
every { account.npub } returns NPUB
every { account.hexKey } returns HEX
mockkObject(LocalPreferences)
every { LocalPreferences.loadFromEncryptedStorageSync(any(), any()) } returns account
every { LocalPreferences.allSavedAccounts(any()) } returns emptyList()
}
@After
fun tearDown() {
unmockkObject(LocalPreferences)
unmockkObject(AmberLog)
}
private fun signEventUri(): Uri {
val uri = mockk<Uri>()
every { uri.toString() } returns "content://${BuildConfig.APPLICATION_ID}.SIGN_EVENT"
return uri
}
private fun queryRelayAuth(relayUrl: String) = SignerProviderQuery.query(
context = context,
requesterId = REQUESTER,
callerPackageName = null,
operationUri = signEventUri(),
arguments = arrayOf(relayAuthJson(relayUrl), "", NPUB),
)
private fun acceptedPermission(relay: String) = ApplicationPermissionsEntity(
id = 1,
pkKey = REQUESTER,
type = "SIGN_EVENT",
kind = 22242,
acceptable = true,
rememberType = 0,
acceptUntil = TimeUtils.now() + 3600,
rejectUntil = 0,
relay = relay,
)
private fun stubNoGrants() {
every { dao.getPermissionForRelay(REQUESTER, "SIGN_EVENT", 22242, WHITELISTED_HOST) } returns null
every { dao.getWildcardRelayPermission(REQUESTER, "SIGN_EVENT", 22242) } returns null
every { dao.getSignPolicy(REQUESTER) } returns null
}
private fun stubSigning() {
val signed = Event("cd".repeat(32), HEX, TimeUtils.now(), 22242, arrayOf(arrayOf("relay", WHITELISTED_URL)), "", "ef".repeat(64))
every { account.signSync<Event>(any(), 22242, any(), any()) } returns signed
}
@Test
fun `whitelisted relay does not auto-accept a requester with no permission`() {
every { amber.settings } returns AmberSettings(authWhitelist = listOf(WHITELISTED_HOST))
stubNoGrants()
val cursor = queryRelayAuth(WHITELISTED_URL)
// null = no silent signing; the caller must surface a user prompt.
assertNull(cursor)
verify { dao.getPermissionForRelay(REQUESTER, "SIGN_EVENT", 22242, WHITELISTED_HOST) }
verify(exactly = 0) { account.signSync<Event>(any(), any(), any(), any()) }
}
@Test
fun `whitelisted relay signs silently when the requester holds an accepted relay grant`() {
every { amber.settings } returns AmberSettings(authWhitelist = listOf(WHITELISTED_HOST))
every { dao.getPermissionForRelay(REQUESTER, "SIGN_EVENT", 22242, WHITELISTED_HOST) } returns acceptedPermission(WHITELISTED_HOST)
every { dao.getSignPolicy(REQUESTER) } returns null
stubSigning()
queryRelayAuth(WHITELISTED_URL)
verify { account.signSync<Event>(any(), 22242, any(), any()) }
}
@Test
fun `whitelisted relay signs silently when the requester holds a wildcard relay grant`() {
every { amber.settings } returns AmberSettings(authWhitelist = listOf(WHITELISTED_HOST))
every { dao.getPermissionForRelay(REQUESTER, "SIGN_EVENT", 22242, WHITELISTED_HOST) } returns null
every { dao.getWildcardRelayPermission(REQUESTER, "SIGN_EVENT", 22242) } returns acceptedPermission("*")
every { dao.getSignPolicy(REQUESTER) } returns null
stubSigning()
queryRelayAuth(WHITELISTED_URL)
verify { account.signSync<Event>(any(), 22242, any(), any()) }
}
@Test
fun `non-whitelisted relay is auto-rejected before any permission lookup`() {
every { amber.settings } returns AmberSettings(authWhitelist = listOf(WHITELISTED_HOST))
queryRelayAuth(OTHER_URL)
verify(exactly = 0) { dao.getPermissionForRelay(any(), any(), any(), any()) }
verify(exactly = 0) { dao.getWildcardRelayPermission(any(), any(), any()) }
verify(exactly = 0) { account.signSync<Event>(any(), any(), any(), any()) }
}
@Test
fun `empty whitelist keeps the normal permission flow`() {
every { amber.settings } returns AmberSettings(authWhitelist = emptyList())
stubNoGrants()
val cursor = queryRelayAuth(WHITELISTED_URL)
assertNull(cursor)
verify { dao.getPermissionForRelay(REQUESTER, "SIGN_EVENT", 22242, WHITELISTED_HOST) }
verify(exactly = 0) { account.signSync<Event>(any(), any(), any(), any()) }
}
}
@@ -0,0 +1,129 @@
package com.greenart7c3.nostrsigner.service
import com.greenart7c3.nostrsigner.Amber
import com.greenart7c3.nostrsigner.LocalPreferences
import com.greenart7c3.nostrsigner.database.AppDatabase
import com.greenart7c3.nostrsigner.database.ApplicationDao
import com.greenart7c3.nostrsigner.database.BunkerEventDao
import com.greenart7c3.nostrsigner.models.Account
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.crypto.verify
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.tags.people.taggedUsers
import com.vitorpamplona.quartz.nip40Expiration.ExpirationTag
import com.vitorpamplona.quartz.nip46RemoteSigner.NostrConnectEvent
import com.vitorpamplona.quartz.utils.TimeUtils
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkObject
import io.mockk.mockkStatic
import io.mockk.spyk
import io.mockk.unmockkObject
import io.mockk.unmockkStatic
import io.mockk.verify
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.runBlocking
import org.junit.After
import org.junit.Before
import org.junit.Test
class EventNotificationConsumerTest {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private lateinit var consumer: EventNotificationConsumer
private lateinit var bunkerEventDao: BunkerEventDao
private lateinit var amber: Amber
private lateinit var account: Account
private lateinit var dao: ApplicationDao
@Before
fun setUp() {
mockkStatic("com.vitorpamplona.quartz.nip01Core.crypto.EventKt")
mockkStatic("com.vitorpamplona.quartz.nip01Core.tags.people.EventExtKt")
mockkStatic("com.vitorpamplona.quartz.nip40Expiration.EventExtKt")
bunkerEventDao = mockk(relaxed = true)
dao = mockk(relaxed = true)
val database = mockk<AppDatabase>(relaxed = true)
every { database.bunkerEventDao() } returns bunkerEventDao
every { database.dao() } returns dao
amber = mockk(relaxed = true)
every { amber.getDatabase(any()) } returns database
every { amber.dao(any()) } returns dao
installAmberInstance(amber)
mockkObject(LocalPreferences)
account = newTestAccount(scope)
consumer = spyk(EventNotificationConsumer(mockk(relaxed = true)))
every { consumer["notificationManager"]() } returns mockk(relaxed = true)
}
@After
fun tearDown() {
unmockkObject(LocalPreferences)
unmockkStatic("com.vitorpamplona.quartz.nip01Core.crypto.EventKt")
unmockkStatic("com.vitorpamplona.quartz.nip01Core.tags.people.EventExtKt")
unmockkStatic("com.vitorpamplona.quartz.nip40Expiration.EventExtKt")
}
@Test
fun `consume rejects old events`() = runBlocking {
val event = mockk<Event>()
every { event.verify() } returns true
every { event.kind } returns NostrConnectEvent.KIND
every { event.createdAt } returns (TimeUtils.now() - TimeUtils.FIVE_MINUTES - 1)
consumer.consume(event, NormalizedRelayUrl("wss://relay.com"))
verify(exactly = 0) { event.tags }
}
@Test
fun `consume rejects future events`() = runBlocking {
val event = mockk<Event>()
every { event.verify() } returns true
every { event.kind } returns NostrConnectEvent.KIND
every { event.createdAt } returns (TimeUtils.now() + TimeUtils.FIVE_MINUTES + 1)
consumer.consume(event, NormalizedRelayUrl("wss://relay.com"))
verify(exactly = 0) { event.tags }
}
@Test
fun `consume rejects expired events`() = runBlocking {
val event = mockk<Event>()
every { event.verify() } returns true
every { event.kind } returns NostrConnectEvent.KIND
every { event.createdAt } returns TimeUtils.now()
every { event.tags } returns arrayOf(arrayOf(ExpirationTag.TAG_NAME, (TimeUtils.now() - 10).toString()))
consumer.consume(event, NormalizedRelayUrl("wss://relay.com"))
// If it passed expiration, it would call taggedUsers()
verify(exactly = 0) { event.taggedUsers() }
}
@Test
fun `consume rejects duplicate events persistently`() = runBlocking {
val event = mockk<Event>()
every { event.id } returns "event1"
every { event.verify() } returns true
every { event.kind } returns NostrConnectEvent.KIND
every { event.createdAt } returns TimeUtils.now()
every { event.tags } returns arrayOf(arrayOf("p", account.hexKey))
coEvery { LocalPreferences.loadFromEncryptedStorageSync(any(), any()) } returns account
coEvery { bunkerEventDao.exists("event1") } returns true
consumer.consume(event, NormalizedRelayUrl("wss://relay.com"))
coVerify(exactly = 1) { bunkerEventDao.exists("event1") }
coVerify(exactly = 0) { bunkerEventDao.insert(any()) }
}
}