Merge e07e3c91186877f3d59414afd8cf1baac6413e4a into 094657efa0aabbb6f71c9050149d1d01aee96400

This commit is contained in:
Harshit Agarwal 2026-08-04 03:46:40 -04:00 committed by GitHub
commit e5a14fe768
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 171 additions and 10 deletions

View File

@ -3,8 +3,11 @@ package com.bitchat.android.nostr
import android.app.Application import android.app.Application
import android.util.Log import android.util.Log
import com.bitchat.android.model.BitchatMessage import com.bitchat.android.model.BitchatMessage
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.util.Date import java.util.Date
/** /**
@ -18,7 +21,8 @@ class GeohashMessageHandler(
private val repo: GeohashRepository, private val repo: GeohashRepository,
private val scope: CoroutineScope, private val scope: CoroutineScope,
private val dataManager: com.bitchat.android.ui.DataManager, private val dataManager: com.bitchat.android.ui.DataManager,
private val addChannelMessage: (String, BitchatMessage) -> Unit private val addChannelMessage: (String, BitchatMessage) -> Unit,
private val signatureVerificationDispatcher: CoroutineDispatcher = Dispatchers.Default
) { ) {
companion object { private const val TAG = "GeohashMessageHandler" } companion object { private const val TAG = "GeohashMessageHandler" }
@ -44,6 +48,13 @@ class GeohashMessageHandler(
if (event.kind != NostrKind.EPHEMERAL_EVENT && event.kind != NostrKind.GEOHASH_PRESENCE) return@launch if (event.kind != NostrKind.EPHEMERAL_EVENT && event.kind != NostrKind.GEOHASH_PRESENCE) return@launch
val tagGeo = event.tags.firstOrNull { it.size >= 2 && it[0] == "g" }?.getOrNull(1) val tagGeo = event.tags.firstOrNull { it.size >= 2 && it[0] == "g" }?.getOrNull(1)
if (tagGeo == null || !tagGeo.equals(subscribedGeohash, true)) return@launch if (tagGeo == null || !tagGeo.equals(subscribedGeohash, true)) return@launch
val hasValidSignature = withContext(signatureVerificationDispatcher) {
event.isValidSignature()
}
if (!hasValidSignature) {
Log.w(TAG, "Rejecting geohash event ${event.id.take(8)}... with invalid signature")
return@launch
}
if (dedupe(event.id)) return@launch if (dedupe(event.id)) return@launch
// PoW validation (if enabled) - apply to chat messages primarily // PoW validation (if enabled) - apply to chat messages primarily

View File

@ -416,6 +416,11 @@ class LocationNotesManager private constructor() {
Log.v(TAG, "Ignoring non-text-note event: kind=${event.kind}") Log.v(TAG, "Ignoring non-text-note event: kind=${event.kind}")
return return
} }
if (!event.isValidSignature()) {
Log.w(TAG, "Rejecting note ${event.id.take(8)}... with invalid signature")
return
}
// Check for geohash tag // Check for geohash tag
val geohashTag = event.tags.firstOrNull { it.size >= 2 && it[0] == "g" } val geohashTag = event.tags.firstOrNull { it.size >= 2 && it[0] == "g" }

View File

@ -282,6 +282,11 @@ class NostrClient private constructor(private val context: Context) {
handler: (content: String, senderPubkey: String, nickname: String?, timestamp: Int) -> Unit handler: (content: String, senderPubkey: String, nickname: String?, timestamp: Int) -> Unit
) { ) {
try { try {
if (!event.isValidSignature()) {
Log.w(TAG, "🚫 Rejecting geohash event ${event.id.take(8)}... with invalid signature")
return
}
// Check Proof of Work validation for incoming geohash events // Check Proof of Work validation for incoming geohash events
val powSettings = PoWPreferenceManager.getCurrentSettings() val powSettings = PoWPreferenceManager.getCurrentSettings()
if (powSettings.enabled && powSettings.difficulty > 0) { if (powSettings.enabled && powSettings.difficulty > 0) {

View File

@ -0,0 +1,136 @@
package com.bitchat.android.nostr
import android.app.Application
import androidx.test.core.app.ApplicationProvider
import com.bitchat.android.ui.ChatState
import com.bitchat.android.ui.DataManager
import com.bitchat.android.ui.MessageManager
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
/**
* Regression coverage for GH-733: geohash events (kind 20000/20001) were accepted from the
* relay without Schnorr signature verification, allowing full pubkey impersonation.
*/
@RunWith(RobolectricTestRunner::class)
class GeohashMessageHandlerSignatureTest {
private val application: Application = ApplicationProvider.getApplicationContext()
@OptIn(ExperimentalCoroutinesApi::class)
private val testDispatcher = UnconfinedTestDispatcher()
private val testScope = TestScope(testDispatcher)
private lateinit var chatState: ChatState
private lateinit var dataManager: DataManager
private lateinit var messageManager: MessageManager
private lateinit var repo: GeohashRepository
private lateinit var handler: GeohashMessageHandler
private val geohash = "u4pruy"
@Before
fun setup() {
chatState = ChatState(scope = testScope)
dataManager = DataManager(context = application)
messageManager = MessageManager(state = chatState)
repo = GeohashRepository(application, chatState, dataManager)
handler = GeohashMessageHandler(
application = application,
repo = repo,
scope = testScope,
dataManager = dataManager,
addChannelMessage = messageManager::addChannelMessage,
signatureVerificationDispatcher = testDispatcher
)
}
private fun buildSignedEvent(identity: NostrIdentity, content: String): NostrEvent {
val unsigned = NostrEvent(
pubkey = identity.publicKeyHex,
createdAt = (System.currentTimeMillis() / 1000).toInt(),
kind = NostrKind.EPHEMERAL_EVENT,
tags = listOf(listOf("g", geohash)),
content = content
)
return unsigned.sign(identity.privateKeyHex)
}
@Test
fun onEvent_acceptsGenuinelySignedEvent() {
val victim = NostrIdentity.generate()
val genuine = buildSignedEvent(victim, "hello from the real victim")
handler.onEvent(genuine, geohash)
val stored = chatState.getChannelMessagesValue()["geo:$geohash"]
assertEquals(1, stored?.size)
assertEquals("hello from the real victim", stored?.first()?.content)
}
@Test
fun onEvent_rejectsEventWithForgedSignature() {
val victim = NostrIdentity.generate()
val attacker = NostrIdentity.generate()
// Attacker claims the victim's pubkey but signs with their own key - a forged event.
val forged = buildSignedEvent(victim, "impersonated message").copy(
sig = NostrEvent(
pubkey = attacker.publicKeyHex,
createdAt = (System.currentTimeMillis() / 1000).toInt(),
kind = NostrKind.EPHEMERAL_EVENT,
tags = listOf(listOf("g", geohash)),
content = "impersonated message"
).sign(attacker.privateKeyHex).sig
)
// Sanity check: this event is indeed invalid before we even touch the handler.
assertEquals(false, forged.isValidSignature())
handler.onEvent(forged, geohash)
val stored = chatState.getChannelMessagesValue()["geo:$geohash"]
assertNull("Forged event must not be rendered as a legitimate message", stored)
}
@Test
fun onEvent_stillAcceptsGenuineEventAfterForgedCopyWithSameIdWasRejected() {
val victim = NostrIdentity.generate()
val attacker = NostrIdentity.generate()
val genuine = buildSignedEvent(victim, "hello from the real victim")
// A forged event carrying the SAME id as the genuine one (ids are a content hash,
// independent of the signature), but signed by an attacker - relays can deliver this
// before the genuine copy arrives from another relay.
val forgedWithSameId = genuine.copy(
sig = NostrEvent(
pubkey = attacker.publicKeyHex,
createdAt = genuine.createdAt,
kind = NostrKind.EPHEMERAL_EVENT,
tags = listOf(listOf("g", geohash)),
content = "hello from the real victim"
).sign(attacker.privateKeyHex).sig
)
assertEquals(genuine.id, forgedWithSameId.id)
assertEquals(false, forgedWithSameId.isValidSignature())
// Forged copy arrives first and must be rejected without poisoning the dedup cache.
handler.onEvent(forgedWithSameId, geohash)
assertNull(chatState.getChannelMessagesValue()["geo:$geohash"])
// The genuine copy (same id, valid signature) arrives afterwards from another relay -
// it must still be rendered, not silently dropped as a "duplicate".
handler.onEvent(genuine, geohash)
val stored = chatState.getChannelMessagesValue()["geo:$geohash"]
assertEquals(1, stored?.size)
assertEquals("hello from the real victim", stored?.first()?.content)
}
}

View File

@ -36,17 +36,21 @@ class NostrBackgroundEventProcessorTest {
fun `cold start processes more events than the removed handoff queue capacity`() = runBlocking { fun `cold start processes more events than the removed handoff queue capacity`() = runBlocking {
val application = ApplicationProvider.getApplicationContext<Application>() val application = ApplicationProvider.getApplicationContext<Application>()
val processor = NostrBackgroundEventProcessor(application, scope) val processor = NostrBackgroundEventProcessor(application, scope)
val identity = NostrIdentity.generate()
val expectedIds = mutableSetOf<String>()
repeat(300) { index -> repeat(300) { index ->
val event = NostrEvent(
pubkey = identity.publicKeyHex,
createdAt = index + 1,
kind = NostrKind.EPHEMERAL_EVENT,
tags = listOf(listOf("g", "u4pruy")),
content = "message-$index"
).sign(identity.privateKeyHex)
expectedIds += event.id
processor.onGeohashMessage( processor.onGeohashMessage(
event = NostrEvent( event = event,
id = "cold-start-$index",
pubkey = index.toString(16).padStart(64, '0'),
createdAt = 1,
kind = NostrKind.EPHEMERAL_EVENT,
tags = listOf(listOf("g", "u4pruy")),
content = "message-$index"
),
geohash = "u4pruy" geohash = "u4pruy"
) )
} }
@ -58,7 +62,7 @@ class NostrBackgroundEventProcessorTest {
} }
assertEquals( assertEquals(
(0 until 300).map { "cold-start-$it" }.toSet(), expectedIds,
AppStateStore.channelMessages.value["geo:u4pruy"].orEmpty().map { it.id }.toSet() AppStateStore.channelMessages.value["geo:u4pruy"].orEmpty().map { it.id }.toSet()
) )
} }