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.util.Log
import com.bitchat.android.model.BitchatMessage
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.util.Date
/**
@ -18,7 +21,8 @@ class GeohashMessageHandler(
private val repo: GeohashRepository,
private val scope: CoroutineScope,
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" }
@ -44,6 +48,13 @@ class GeohashMessageHandler(
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)
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
// 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}")
return
}
if (!event.isValidSignature()) {
Log.w(TAG, "Rejecting note ${event.id.take(8)}... with invalid signature")
return
}
// Check for geohash tag
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
) {
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
val powSettings = PoWPreferenceManager.getCurrentSettings()
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 {
val application = ApplicationProvider.getApplicationContext<Application>()
val processor = NostrBackgroundEventProcessor(application, scope)
val identity = NostrIdentity.generate()
val expectedIds = mutableSetOf<String>()
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(
event = NostrEvent(
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"
),
event = event,
geohash = "u4pruy"
)
}
@ -58,7 +62,7 @@ class NostrBackgroundEventProcessorTest {
}
assertEquals(
(0 until 300).map { "cold-start-$it" }.toSet(),
expectedIds,
AppStateStore.channelMessages.value["geo:u4pruy"].orEmpty().map { it.id }.toSet()
)
}