diff --git a/app/src/main/java/com/bitchat/android/nostr/NdrNostrService.kt b/app/src/main/java/com/bitchat/android/nostr/NdrNostrService.kt index f777687c..2b4c30a6 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NdrNostrService.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NdrNostrService.kt @@ -691,19 +691,22 @@ class NdrNostrService( } return when (event.kind) { "subscribe" -> { - val subid = event.subid ?: return true - val filterJson = event.filterJson ?: return true + val subid = event.subid?.takeIf(String::isNotBlank) ?: return false + val filterJson = event.filterJson ?: return false if (hasRecipientFilter(filterJson)) { Log.w(TAG, "Rejecting recipient-bearing NDR relay filter") - return true + return false } val filter = try { parseFilterJson(filterJson) } catch (_: Throwable) { Log.w(TAG, "Ignoring malformed NDR relay filter") - return true + return false + } + if (!isPairwiseMessageSubscription(filter)) { + Log.w(TAG, "Rejecting non-pairwise NDR relay filter") + return false } - if (!isPairwiseMessageSubscription(filter)) return true if (!activeSubIds.add(subid)) { return true } diff --git a/app/src/test/kotlin/com/bitchat/android/nostr/NdrNostrServiceTest.kt b/app/src/test/kotlin/com/bitchat/android/nostr/NdrNostrServiceTest.kt index d969d185..293e2aea 100644 --- a/app/src/test/kotlin/com/bitchat/android/nostr/NdrNostrServiceTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/nostr/NdrNostrServiceTest.kt @@ -138,7 +138,7 @@ class NdrNostrServiceTest { ) assertEquals(listOf("messages"), relay.subscriptions.map { it.id }) assertEquals( - setOf("message-sub", "appkeys-sub", "invite-sub", "recipient-sub"), + setOf("message-sub"), runtime.ackedActionIds.toSet() ) assertEquals( diff --git a/app/src/test/kotlin/com/bitchat/android/nostr/NdrSubscriptionAdmissionTest.kt b/app/src/test/kotlin/com/bitchat/android/nostr/NdrSubscriptionAdmissionTest.kt new file mode 100644 index 00000000..888d7907 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/nostr/NdrSubscriptionAdmissionTest.kt @@ -0,0 +1,202 @@ +package com.bitchat.android.nostr + +import com.bitchat.android.model.NdrFeatureGate +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder + +class NdrSubscriptionAdmissionTest { + @get:Rule + val temporaryFolder = TemporaryFolder() + + @After + fun resetFeatureGate() { + NdrFeatureGate.setEnabledForTests(false) + } + + @Test + fun `rejected native subscription remains pending instead of being acknowledged`() { + NdrFeatureGate.setEnabledForTests(true) + val runtime = SubscriptionRuntime() + val relay = RecordingRelayManager() + val storageDirectory = temporaryFolder.newFolder("ndr").absolutePath + val service = NdrNostrService( + relayManager = relay, + runtimeFactory = object : NdrPairwiseRuntimeFactory { + override fun newWithStoragePath( + ourPubkeyHex: String, + ourIdentityPrivkeyHex: String, + storagePath: String + ): NdrPairwiseRuntime = runtime + }, + storageDirectoryProvider = { storageDirectory } + ) + + assertTrue(service.configureIfNeeded(testIdentity())) + assertTrue(relay.subscriptions.isEmpty()) + assertTrue(runtime.ackedActionIds.isEmpty()) + assertEquals( + listOf(SUBSCRIPTION_ACTION_ID), + runtime.pendingActions(0u).map { it.actionId } + ) + } + + @Test + fun `reset-blocked pairwise subscription installs and acknowledges once after reset`() { + NdrFeatureGate.setEnabledForTests(true) + val scope = CoroutineScope(Dispatchers.Unconfined + SupervisorJob()) + val relayManager = NostrRelayManager( + scope = scope, + eventDeduplicator = NostrEventDeduplicator(maxCapacity = 8) + ) + val runtime = SubscriptionRuntime( + NdrPubSubEvent( + kind = "subscribe", + actionId = SUBSCRIPTION_ACTION_ID, + subid = "messages", + filterJson = + """{"authors":["${"aa".repeat(32)}"],"kinds":[1060]}""" + ) + ) + val storageDirectory = temporaryFolder.newFolder("reset-ndr").absolutePath + val service = NdrNostrService( + relayManager = BitchatNdrRelayAdapter( + relayManager, + accountRelayUrls = emptyList() + ), + runtimeFactory = object : NdrPairwiseRuntimeFactory { + override fun newWithStoragePath( + ourPubkeyHex: String, + ourIdentityPrivkeyHex: String, + storagePath: String + ): NdrPairwiseRuntime = runtime + }, + storageDirectoryProvider = { storageDirectory } + ) + val identity = testIdentity() + val resetToken = relayManager.beginAccountReset() + + try { + assertTrue(service.configureIfNeeded(identity)) + assertTrue(relayManager.getActiveSubscriptions().isEmpty()) + assertTrue(runtime.ackedActionIds.isEmpty()) + + assertTrue(relayManager.discardForAccountReset(resetToken)) + assertTrue(relayManager.completeAccountReset(resetToken)) + assertTrue(service.configureIfNeeded(identity)) + assertEquals( + setOf("messages"), + relayManager.getActiveSubscriptions().keys + ) + assertEquals(listOf(SUBSCRIPTION_ACTION_ID), runtime.ackedActionIds) + + assertTrue(service.configureIfNeeded(identity)) + assertEquals( + setOf("messages"), + relayManager.getActiveSubscriptions().keys + ) + assertEquals(listOf(SUBSCRIPTION_ACTION_ID), runtime.ackedActionIds) + } finally { + val cleanupReset = relayManager.discardForAccountReset() + relayManager.completeAccountReset(cleanupReset) + scope.cancel() + } + } + + private fun testIdentity(): NostrIdentity = + NostrIdentity.fromPrivateKey("0".repeat(63) + "1") + + private class RecordingRelayManager : NdrRelayManager { + val subscriptions = mutableListOf() + + override fun subscribe( + filter: NostrFilter, + id: String, + handler: (NostrEvent) -> Boolean + ) { + subscriptions += id + } + + override fun unsubscribe(id: String) = Unit + + override fun sendEventConfirmed( + event: NostrEvent, + completion: (accepted: Boolean) -> Unit + ) = completion(true) + + override fun cancelConfirmedEvent(eventId: String) = Unit + + override fun setOnConnectionAvailable(handler: () -> Unit) = Unit + } + + private class SubscriptionRuntime( + private val action: NdrPubSubEvent = NdrPubSubEvent( + kind = "subscribe", + actionId = SUBSCRIPTION_ACTION_ID, + subid = "messages", + filterJson = """ + { + "kinds": [1060], + "authors": ["${"aa".repeat(32)}"], + "#p": ["${"bb".repeat(32)}"] + } + """.trimIndent() + ) + ) : NdrPairwiseRuntime { + val ackedActionIds = mutableListOf() + + override fun currentInviteEventJson(): String? = null + override fun currentInviteUrl(root: String): String? = null + + override fun acceptInviteFromEventJson( + eventJson: String, + expectedPeerPubkeyHex: String + ): NdrAcceptInviteResult = unsupported() + + override fun acceptInviteFromUrl( + inviteUrl: String, + expectedPeerPubkeyHex: String + ): NdrAcceptInviteResult = unsupported() + + override fun processEvent(eventJson: String) = Unit + + override fun processOutOfBandResponse( + eventJson: String, + expectedPeerPubkeyHex: String + ) = Unit + + override fun pendingActions(nowSeconds: ULong): List = + if (SUBSCRIPTION_ACTION_ID in ackedActionIds) emptyList() else listOf(action) + + override fun ackActions(actionIds: List) { + ackedActionIds += actionIds + } + + override fun sessionInfo(peerPubkeyHex: String): NdrPairwiseSessionInfo? = null + override fun knownPeerPubkeys(): List = emptyList() + override fun retirePeer(peerPubkeyHex: String): Boolean = false + + override fun sendText( + recipientPubkeyHex: String, + text: String, + expiresAtSeconds: ULong? + ): NdrPairwiseSendResult = unsupported() + + override fun getOurPubkeyHex(): String = "cc".repeat(32) + override fun getTotalSessions(): ULong = 0u + override fun destroy() = Unit + + private fun unsupported(): T = error("not used") + } + + companion object { + private const val SUBSCRIPTION_ACTION_ID = "subscription-action" + } +}