fix: verify Schnorr signatures on incoming geohash Nostr events

Geohash chat (kind 20000) and presence (kind 20001) events, and location
notes (kind 1), were rendered/processed without ever calling
NostrEvent.isValidSignature(). A relay-side attacker (or anyone able to
publish to a connected relay) could forge an event claiming any victim's
pubkey and have it accepted as genuine in GeohashMessageHandler,
NostrClient.handleGeohashMessage, and LocationNotesManager.handleEvent.

Add a signature check at all three ingestion points, dropping any event
whose signature doesn't verify against its claimed pubkey before further
processing (dedup, PoW check, participant tracking, or UI rendering).

Adds GeohashMessageHandlerSignatureTest, which fails against the old
code (forged event accepted and rendered) and passes against the fix,
while confirming genuinely-signed events are unaffected.

Fixes #733

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
aharshit123456 2026-07-26 14:08:37 +05:30
parent b7f0b33d3a
commit 1d80e64c69
4 changed files with 109 additions and 0 deletions

View File

@ -48,6 +48,10 @@ class GeohashMessageHandler(
val tagGeo = event.tags.firstOrNull { it.size >= 2 && it[0] == "g" }?.getOrNull(1)
if (tagGeo == null || !tagGeo.equals(subscribedGeohash, true)) return@launch
if (dedupe(event.id)) return@launch
if (!event.isValidSignature()) {
Log.w(TAG, "Rejecting geohash event ${event.id.take(8)}... with invalid signature")
return@launch
}
// PoW validation (if enabled) - apply to chat messages primarily
if (event.kind == NostrKind.EPHEMERAL_EVENT) {

View File

@ -361,6 +361,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,95 @@
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, chatState, messageManager, repo, testScope, dataManager)
}
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)
}
}