mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-09-19 04:59:59 +00:00
Merge e07e3c91186877f3d59414afd8cf1baac6413e4a into 094657efa0aabbb6f71c9050149d1d01aee96400
This commit is contained in:
commit
e5a14fe768
@ -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
|
||||||
|
|||||||
@ -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" }
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user