From ed99f243c16e54b405667cb6c2aa505d7760674e Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Mon, 27 Jul 2026 03:31:26 +0200 Subject: [PATCH] fix: harden board identity and sync compatibility --- .../com/bitchat/android/board/BoardManager.kt | 40 +++++--- .../com/bitchat/android/board/BoardPackets.kt | 17 ++++ .../android/board/BoardSigningIdentity.kt | 59 ++++++++++++ .../com/bitchat/android/board/BoardStore.kt | 24 ++--- .../bitchat/android/board/UnifiedNotices.kt | 2 +- .../android/mesh/BluetoothMeshService.kt | 14 ++- .../java/com/bitchat/android/mesh/MeshCore.kt | 14 ++- .../bitchat/android/sync/GossipSyncManager.kt | 55 +++++++++-- .../com/bitchat/android/ui/ChatViewModel.kt | 11 +++ .../bitchat/android/board/BoardManagerTest.kt | 41 +++++++- .../bitchat/android/board/BoardPacketsTest.kt | 35 +++++++ .../bitchat/android/board/BoardStoreTest.kt | 35 +++++-- .../android/board/UnifiedNoticesTest.kt | 17 +++- .../com/bitchat/android/sync/BoardSyncTest.kt | 94 +++++++++++++++++++ 14 files changed, 397 insertions(+), 61 deletions(-) create mode 100644 app/src/main/java/com/bitchat/android/board/BoardSigningIdentity.kt diff --git a/app/src/main/java/com/bitchat/android/board/BoardManager.kt b/app/src/main/java/com/bitchat/android/board/BoardManager.kt index 7b5f02eb..93288c32 100644 --- a/app/src/main/java/com/bitchat/android/board/BoardManager.kt +++ b/app/src/main/java/com/bitchat/android/board/BoardManager.kt @@ -19,6 +19,7 @@ class BoardManager( private val store: BoardStore, private val scope: CoroutineScope, private val meshProvider: () -> MeshService, + private val geoIdentityProvider: (String) -> BoardSigningIdentity? = { null }, private val notesManager: LocationNotesManager = LocationNotesManager.getInstance(), private val nowMs: () -> ULong = { System.currentTimeMillis().coerceAtLeast(0).toULong() }, private val random: SecureRandom = SecureRandom(), @@ -44,7 +45,9 @@ class BoardManager( fun posts(geohash: String): List = store.posts(geohash.lowercase()) fun isOwnPost(post: BoardPostPacket): Boolean = - meshProvider().getSigningPublicKey()?.contentEquals(post.authorSigningKey) == true + signingIdentityFor(post.geohash) + ?.publicKey + ?.contentEquals(post.authorSigningKey) == true fun createPost( content: String, @@ -64,9 +67,8 @@ class BoardManager( } val mesh = meshProvider() - val signingKey = mesh.getSigningPublicKey() - ?.takeIf { it.size == BoardWireConstants.SIGNING_KEY_LENGTH } - ?: return false + val identity = signingIdentityFor(normalizedGeohash) ?: return false + val signingKey = identity.publicKey.copyOf() val postID = ByteArray(BoardWireConstants.POST_ID_LENGTH).also(random::nextBytes) val createdAt = nowMs() val expiresAt = createdAt + expiryDays.toULong() * DAY_MS @@ -82,7 +84,7 @@ class BoardManager( expiresAt = expiresAt, flags = flags ) - val signature = mesh.signData(signingBytes) + val signature = identity.sign(signingBytes) ?.takeIf { it.size == BoardWireConstants.SIGNATURE_LENGTH } ?: return false val post = BoardPostPacket( @@ -107,7 +109,7 @@ class BoardManager( urgent = urgent ) { eventID -> synchronized(bridgedEventIDs) { - bridgedEventIDs[postID.toHex()] = eventID + bridgedEventIDs[post.identityKey()] = eventID } } } @@ -115,9 +117,11 @@ class BoardManager( } fun deletePost(post: BoardPostPacket): Boolean { - if (!isOwnPost(post)) return false + val identity = signingIdentityFor(post.geohash) + ?.takeIf { it.publicKey.contentEquals(post.authorSigningKey) } + ?: return false val deletedAt = nowMs() - val signature = meshProvider().signData( + val signature = identity.sign( BoardTombstonePacket.signingBytes(post.postID, deletedAt) )?.takeIf { it.size == BoardWireConstants.SIGNATURE_LENGTH } ?: return false val tombstone = BoardTombstonePacket( @@ -130,7 +134,7 @@ class BoardManager( if (post.geohash.isNotEmpty()) { val eventID = synchronized(bridgedEventIDs) { - bridgedEventIDs.remove(post.postID.toHex()) + bridgedEventIDs.remove(post.identityKey()) } if (eventID != null) notesManager.deleteEvent(eventID, post.geohash) } @@ -152,8 +156,7 @@ class BoardManager( } private fun handleArrival(post: BoardPostPacket) { - val postID = post.postID.toHex() - if (!handledPostIDs.add(postID) || isOwnPost(post)) return + if (!handledPostIDs.add(post.identityKey()) || isOwnPost(post)) return _unseenScopes.value = _unseenScopes.value + post.geohash val age = nowMs().toLong() - post.createdAt.coerceAtMost(Long.MAX_VALUE.toULong()).toLong() @@ -186,6 +189,21 @@ class BoardManager( private fun ByteArray.toHex(): String = joinToString("") { "%02x".format(it) } + private fun BoardPostPacket.identityKey(): String = + "${authorSigningKey.toHex()}:${postID.toHex()}" + + private fun signingIdentityFor(geohash: String): BoardSigningIdentity? { + if (geohash.isNotEmpty()) { + // Never fall back to the stable mesh identity for a location scope. + return runCatching { geoIdentityProvider(geohash) }.getOrNull() + } + val mesh = meshProvider() + val publicKey = mesh.getSigningPublicKey() + ?.takeIf { it.size == BoardWireConstants.SIGNING_KEY_LENGTH } + ?: return null + return BoardSigningIdentity(publicKey, mesh::signData) + } + private companion object { const val DAY_MS: ULong = 86_400_000uL const val URGENT_RECENCY_MS = 30 * 60 * 1_000L diff --git a/app/src/main/java/com/bitchat/android/board/BoardPackets.kt b/app/src/main/java/com/bitchat/android/board/BoardPackets.kt index 1a1f5146..62ba7464 100644 --- a/app/src/main/java/com/bitchat/android/board/BoardPackets.kt +++ b/app/src/main/java/com/bitchat/android/board/BoardPackets.kt @@ -6,11 +6,13 @@ import java.io.ByteArrayOutputStream import java.nio.ByteBuffer import java.nio.ByteOrder import java.nio.charset.CodingErrorAction +import java.security.MessageDigest object BoardWireConstants { const val POST_ID_LENGTH = 16 const val SIGNING_KEY_LENGTH = 32 const val SIGNATURE_LENGTH = 64 + const val TRANSPORT_SENDER_ID_LENGTH = 8 const val CONTENT_MAX_BYTES = 512 const val NICKNAME_MAX_BYTES = 64 const val GEOHASH_MAX_LENGTH = 12 @@ -145,6 +147,21 @@ sealed interface BoardWire { } } +/** + * Board payloads authenticate their embedded author key, so their outer mesh + * sender must not expose the device's stable peer ID. The pseudonym remains + * stable only for one board signing identity. + */ +fun BoardWire.transportSenderID(): ByteArray { + val authorKey = when (this) { + is BoardWire.Post -> packet.authorSigningKey + is BoardWire.Tombstone -> packet.authorSigningKey + } + return MessageDigest.getInstance("SHA-256") + .digest(authorKey) + .copyOf(BoardWireConstants.TRANSPORT_SENDER_ID_LENGTH) +} + object BoardWireCodec { private const val TLV_KIND = 0x01 private const val TLV_POST_ID = 0x02 diff --git a/app/src/main/java/com/bitchat/android/board/BoardSigningIdentity.kt b/app/src/main/java/com/bitchat/android/board/BoardSigningIdentity.kt new file mode 100644 index 00000000..a9ab35d1 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/board/BoardSigningIdentity.kt @@ -0,0 +1,59 @@ +package com.bitchat.android.board + +import org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters +import org.bouncycastle.crypto.signers.Ed25519Signer +import java.security.MessageDigest + +/** + * Signing identity used by board payloads. + * + * Location boards use a key derived from the already-unlinkable per-geohash + * Nostr secret. Domain separation keeps the board Ed25519 identity distinct + * from the secp256k1 identity used on relays. + */ +class BoardSigningIdentity( + publicKey: ByteArray, + private val signer: (ByteArray) -> ByteArray? +) { + val publicKey: ByteArray = publicKey.copyOf() + + init { + require(publicKey.size == BoardWireConstants.SIGNING_KEY_LENGTH) + } + + fun sign(message: ByteArray): ByteArray? = signer(message)?.copyOf() + + companion object { + private const val GEO_IDENTITY_CONTEXT = "bitchat-board-geo-identity-v1" + + fun fromNostrPrivateKeyHex(privateKeyHex: String): BoardSigningIdentity? = + runCatching { + val nostrSecret = privateKeyHex.hexToByteArray() + .takeIf { it.size == BoardWireConstants.SIGNING_KEY_LENGTH } + ?: return@runCatching null + val digest = MessageDigest.getInstance("SHA-256") + digest.update(GEO_IDENTITY_CONTEXT.toByteArray(Charsets.UTF_8)) + digest.update(nostrSecret) + fromEd25519Seed(digest.digest()) + }.getOrNull() + + fun fromEd25519Seed(seed: ByteArray): BoardSigningIdentity { + require(seed.size == BoardWireConstants.SIGNING_KEY_LENGTH) + val privateKey = Ed25519PrivateKeyParameters(seed.copyOf(), 0) + return BoardSigningIdentity(privateKey.generatePublicKey().encoded) { message -> + Ed25519Signer().run { + init(true, privateKey) + update(message, 0, message.size) + generateSignature() + } + } + } + + private fun String.hexToByteArray(): ByteArray { + require(length % 2 == 0) + return ByteArray(length / 2) { index -> + substring(index * 2, index * 2 + 2).toInt(16).toByte() + } + } + } +} diff --git a/app/src/main/java/com/bitchat/android/board/BoardStore.kt b/app/src/main/java/com/bitchat/android/board/BoardStore.kt index d47f2e9b..97aa95d1 100644 --- a/app/src/main/java/com/bitchat/android/board/BoardStore.kt +++ b/app/src/main/java/com/bitchat/android/board/BoardStore.kt @@ -189,7 +189,11 @@ class BoardStore( ) { return BoardIngestResult.REJECTED } - if (posts.any { it.post.postID.contentEquals(post.postID) }) { + if (posts.any { + it.post.postID.contentEquals(post.postID) && + it.post.authorSigningKey.contentEquals(post.authorSigningKey) + } + ) { return BoardIngestResult.DUPLICATE } @@ -219,14 +223,14 @@ class BoardStore( now.saturatedAdd(Limits.ORPHAN_TOMBSTONE_LIFETIME_MS) .saturatedAdd(Limits.CLOCK_SKEW_MS) ) - val matchingPostIndex = posts.indexOfFirst { it.post.postID.contentEquals(tombstone.postID) } + val matchingPostIndex = posts.indexOfFirst { + it.post.postID.contentEquals(tombstone.postID) && + it.post.authorSigningKey.contentEquals(tombstone.authorSigningKey) + } val retainUntil: ULong val isOrphan: Boolean if (matchingPostIndex >= 0) { val target = posts[matchingPostIndex].post - if (!target.authorSigningKey.contentEquals(tombstone.authorSigningKey)) { - return BoardIngestResult.REJECTED - } retainUntil = target.expiresAt isOrphan = false posts.removeAt(matchingPostIndex) @@ -258,12 +262,10 @@ class BoardStore( } private fun evictOldestPostsLocked(candidates: List, keep: Int) { - val victimIDs = candidates.sortedBy { it.post.createdAt } + val victims = candidates.sortedBy { it.post.createdAt } .take((candidates.size - keep).coerceAtLeast(0)) - .map { it.post.postID.toHex() } - .toSet() - if (victimIDs.isNotEmpty()) { - posts.removeAll { it.post.postID.toHex() in victimIDs } + if (victims.isNotEmpty()) { + posts.removeAll { stored -> victims.any { it === stored } } } } @@ -378,5 +380,3 @@ class BoardStore( private fun ULong.saturatedAdd(other: ULong): ULong = if (ULong.MAX_VALUE - this < other) ULong.MAX_VALUE else this + other - -private fun ByteArray.toHex(): String = joinToString("") { "%02x".format(it) } diff --git a/app/src/main/java/com/bitchat/android/board/UnifiedNotices.kt b/app/src/main/java/com/bitchat/android/board/UnifiedNotices.kt index 4b1c7b9e..c91b127b 100644 --- a/app/src/main/java/com/bitchat/android/board/UnifiedNotices.kt +++ b/app/src/main/java/com/bitchat/android/board/UnifiedNotices.kt @@ -37,7 +37,7 @@ object UnifiedNotices { val scopedPosts = boardPosts.filter { it.geohash == normalized } val boardNotices = scopedPosts.map { post -> UnifiedNotice( - id = "mesh:${post.postID.toHex()}", + id = "mesh:${post.authorSigningKey.toHex()}:${post.postID.toHex()}", content = post.content, nickname = post.authorNickname, createdAtMs = post.createdAt.coerceAtMost(Long.MAX_VALUE.toULong()).toLong(), diff --git a/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt b/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt index f928cfe0..c7c3323f 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt @@ -2,6 +2,7 @@ package com.bitchat.android.mesh import android.content.Context import android.util.Log +import com.bitchat.android.board.transportSenderID import com.bitchat.android.crypto.EncryptionService import com.bitchat.android.model.BitchatMessage import com.bitchat.android.model.AuthenticatedPeerState @@ -923,27 +924,24 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic val wire = com.bitchat.android.board.BoardWireCodec.decode(payload) ?: return if (!wire.verifySignature()) return serviceScope.launch { + // The inner board signature is authoritative. A stable outer + // sender/signature would re-link otherwise isolated location scopes. val packet = BitchatPacket( version = 1u, type = MessageType.BOARD_POST.value, - senderID = hexStringToByteArray(myPeerID), + senderID = wire.transportSenderID(), recipientID = null, timestamp = System.currentTimeMillis().coerceAtLeast(0).toULong(), payload = payload, signature = null, ttl = MAX_TTL ) - val signed = signPacketBeforeBroadcast(packet) - if (signed.signature?.size != com.bitchat.android.board.BoardWireConstants.SIGNATURE_LENGTH) { - Log.e(TAG, "Refusing to send board packet without an outer signature") - return@launch - } boardStore.ingest( wire, - signed, + packet, com.bitchat.android.board.BoardIngestSource.LOCAL ) - broadcastRoutedPacket(RoutedPacket(signed)) + broadcastRoutedPacket(RoutedPacket(packet)) } } diff --git a/app/src/main/java/com/bitchat/android/mesh/MeshCore.kt b/app/src/main/java/com/bitchat/android/mesh/MeshCore.kt index 632370cc..008904c4 100644 --- a/app/src/main/java/com/bitchat/android/mesh/MeshCore.kt +++ b/app/src/main/java/com/bitchat/android/mesh/MeshCore.kt @@ -2,6 +2,7 @@ package com.bitchat.android.mesh import android.content.Context import android.util.Log +import com.bitchat.android.board.transportSenderID import com.bitchat.android.crypto.EncryptionService import com.bitchat.android.model.BitchatMessage import com.bitchat.android.model.BitchatFilePacket @@ -543,27 +544,24 @@ class MeshCore( val wire = com.bitchat.android.board.BoardWireCodec.decode(payload) ?: return if (!wire.verifySignature()) return scope.launch { + // The inner board signature is authoritative. A stable outer + // sender/signature would re-link otherwise isolated location scopes. val packet = BitchatPacket( version = 1u, type = MessageType.BOARD_POST.value, - senderID = MeshPacketUtils.hexStringToByteArray(myPeerID), + senderID = wire.transportSenderID(), recipientID = null, timestamp = System.currentTimeMillis().coerceAtLeast(0).toULong(), payload = payload, signature = null, ttl = maxTtl ) - val signed = signPacketBeforeBroadcast(packet) - if (signed.signature?.size != com.bitchat.android.board.BoardWireConstants.SIGNATURE_LENGTH) { - Log.e("MeshCore", "Refusing to send board packet without an outer signature") - return@launch - } boardStore.ingest( wire, - signed, + packet, com.bitchat.android.board.BoardIngestSource.LOCAL ) - dispatchGlobal(RoutedPacket(signed)) + dispatchGlobal(RoutedPacket(packet)) } } diff --git a/app/src/main/java/com/bitchat/android/sync/GossipSyncManager.kt b/app/src/main/java/com/bitchat/android/sync/GossipSyncManager.kt index 68a145b2..b41642fa 100644 --- a/app/src/main/java/com/bitchat/android/sync/GossipSyncManager.kt +++ b/app/src/main/java/com/bitchat/android/sync/GossipSyncManager.kt @@ -1,7 +1,8 @@ package com.bitchat.android.sync import android.util.Log -import com.bitchat.android.mesh.BluetoothPacketBroadcaster +import com.bitchat.android.model.IdentityAnnouncement +import com.bitchat.android.model.PeerCapabilities import com.bitchat.android.model.RequestSyncPacket import com.bitchat.android.protocol.BitchatPacket import com.bitchat.android.protocol.MessageType @@ -53,6 +54,8 @@ class GossipSyncManager( // - announcements: only keep latest per sender peerID private val latestAnnouncementByPeer = ConcurrentHashMap>() private val responseTimesByPeer = ConcurrentHashMap>() + private val advertisedBoardPeers = ConcurrentHashMap.newKeySet() + private val observedBoardSyncPeers = ConcurrentHashMap.newKeySet() private var periodicJob: Job? = null private var boardPeriodicJob: Job? = null @@ -74,7 +77,9 @@ class GossipSyncManager( try { delay(BOARD_SYNC_INTERVAL_MS) if (boardPacketsProvider != null) { - sendRequestSync(SyncTypeFlags.BOARD) + boardSyncPeers().forEach { peerID -> + sendRequestSyncToPeer(peerID, SyncTypeFlags.BOARD) + } } } catch (e: CancellationException) { throw e @@ -106,19 +111,19 @@ class GossipSyncManager( fun scheduleInitialSync(delayMs: Long = 5_000L) { scope.launch(Dispatchers.IO) { delay(delayMs) - val types = if (boardPacketsProvider != null) { - SyncTypeFlags.PUBLIC_MESSAGES.union(SyncTypeFlags.BOARD) - } else { - SyncTypeFlags.PUBLIC_MESSAGES + sendRequestSync(SyncTypeFlags.PUBLIC_MESSAGES) + if (boardPacketsProvider != null) { + boardSyncPeers().forEach { peerID -> + sendRequestSyncToPeer(peerID, SyncTypeFlags.BOARD) + } } - sendRequestSync(types) } } fun scheduleInitialSyncToPeer(peerID: String, delayMs: Long = 5_000L) { scope.launch(Dispatchers.IO) { delay(delayMs) - val types = if (boardPacketsProvider != null) { + val types = if (boardPacketsProvider != null && peerSupportsBoard(peerID)) { SyncTypeFlags.PUBLIC_MESSAGES.union(SyncTypeFlags.BOARD) } else { SyncTypeFlags.PUBLIC_MESSAGES @@ -158,11 +163,26 @@ class GossipSyncManager( // senderID is fixed-size 8 bytes; map to hex string for key val sender = packet.senderID.joinToString("") { b -> "%02x".format(b) } latestAnnouncementByPeer[sender] = id to packet + val supportsBoard = IdentityAnnouncement.decode(packet.payload) + ?.capabilities + ?.contains(PeerCapabilities.BOARD) == true + if (supportsBoard) { + advertisedBoardPeers.add(sender) + } else { + advertisedBoardPeers.remove(sender) + } // Enforce capacity (remove oldest when exceeded) val cap = configProvider.seenCapacity().coerceAtLeast(1) while (latestAnnouncementByPeer.size > cap) { val it = latestAnnouncementByPeer.entries.iterator() - if (it.hasNext()) { it.next(); it.remove() } else break + if (it.hasNext()) { + val evictedPeer = it.next().key + it.remove() + advertisedBoardPeers.remove(evictedPeer) + observedBoardSyncPeers.remove(evictedPeer) + } else { + break + } } } } @@ -200,11 +220,16 @@ class GossipSyncManager( } fun handleRequestSync(fromPeerID: String, request: RequestSyncPacket) { + val requestedTypes = request.types ?: SyncTypeFlags.PUBLIC_MESSAGES + if (requestedTypes.contains(MessageType.BOARD_POST)) { + // This is the compatibility signal used by iOS builds that + // understand board sync but do not yet advertise the BOARD bit. + observedBoardSyncPeers.add(fromPeerID.lowercase()) + } if (!shouldRespondTo(fromPeerID)) { Log.w(TAG, "Rate-limited REQUEST_SYNC from ${fromPeerID.take(8)}") return } - val requestedTypes = request.types ?: SyncTypeFlags.PUBLIC_MESSAGES // Decode GCS into sorted set for membership checks val sorted = GCSFilter.decodeToSortedSet(request.p, request.m, request.data) fun mightContain(id: ByteArray): Boolean { @@ -268,6 +293,14 @@ class GossipSyncManager( } } + private fun peerSupportsBoard(peerID: String): Boolean { + val normalized = peerID.lowercase() + return normalized in advertisedBoardPeers || normalized in observedBoardSyncPeers + } + + private fun boardSyncPeers(): List = + latestAnnouncementByPeer.keys.filter(::peerSupportsBoard) + private fun hexStringToByteArray(hexString: String): ByteArray { val result = ByteArray(8) var tempID = hexString @@ -388,6 +421,8 @@ class GossipSyncManager( // Explicitly remove stored announcement for a given peer (hex ID) fun removeAnnouncementForPeer(peerID: String) { val key = peerID.lowercase() + advertisedBoardPeers.remove(key) + observedBoardSyncPeers.remove(key) if (latestAnnouncementByPeer.remove(key) != null) { Log.d(TAG, "Removed stored announcement for peer $peerID") } diff --git a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt index 3376338b..a9b2946c 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt @@ -32,6 +32,7 @@ import com.bitchat.android.services.ContactDirectory import com.bitchat.android.services.ContactIdentityResolver import com.bitchat.android.util.hexEncodedString import com.bitchat.android.board.BoardManager +import com.bitchat.android.board.BoardSigningIdentity import com.bitchat.android.board.BoardStore /** @@ -117,6 +118,16 @@ class ChatViewModel( store = BoardStore.getInstance(application.applicationContext), scope = viewModelScope, meshProvider = { mesh }, + geoIdentityProvider = { geohash -> + runCatching { + NostrIdentityBridge.deriveIdentity( + forGeohash = geohash, + context = application.applicationContext + ) + }.getOrNull()?.let { identity -> + BoardSigningIdentity.fromNostrPrivateKeyHex(identity.privateKeyHex) + } + }, onUrgentPosts = { geohash, posts -> val text = if (posts.size == 1) { val post = posts.single() diff --git a/app/src/test/java/com/bitchat/android/board/BoardManagerTest.kt b/app/src/test/java/com/bitchat/android/board/BoardManagerTest.kt index ad0e7c03..9de2fd1c 100644 --- a/app/src/test/java/com/bitchat/android/board/BoardManagerTest.kt +++ b/app/src/test/java/com/bitchat/android/board/BoardManagerTest.kt @@ -17,6 +17,7 @@ import org.junit.Test import org.mockito.kotlin.any import org.mockito.kotlin.argumentCaptor import org.mockito.kotlin.mock +import org.mockito.kotlin.never import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.mockito.kotlin.whenever @@ -25,6 +26,7 @@ import java.security.SecureRandom class BoardManagerTest { private val privateKey = Ed25519PrivateKeyParameters(ByteArray(32) { it.toByte() }, 0) private val publicKey = privateKey.generatePublicKey().encoded + private val geoIdentity = BoardSigningIdentity.fromEd25519Seed(ByteArray(32) { (it + 64).toByte() }) @Test fun `create and delete emit iOS-compatible signed wire payloads`() = runTest { @@ -38,6 +40,7 @@ class BoardManagerTest { store = BoardStore(nowMs = { NOW }), scope = backgroundScope, meshProvider = { mesh }, + geoIdentityProvider = { geoIdentity }, notesManager = notes, nowMs = { NOW }, random = SecureRandom(byteArrayOf(7)) @@ -58,9 +61,19 @@ class BoardManagerTest { assertEquals("water at gate two", post.content) assertEquals("u33dc", post.geohash) assertEquals(NOW + 3uL * DAY_MS, post.expiresAt) + assertTrue(geoIdentity.publicKey.contentEquals(post.authorSigningKey)) assertTrue(post.verifySignature()) + verify(mesh, never()).signData(any()) - assertTrue(manager.deletePost(post)) + val reloadedManager = BoardManager( + store = BoardStore(nowMs = { NOW }), + scope = backgroundScope, + meshProvider = { mesh }, + geoIdentityProvider = { geoIdentity }, + notesManager = notes, + nowMs = { NOW } + ) + assertTrue(reloadedManager.deletePost(post)) verify(mesh, times(2)).sendBoardPayload(payloads.capture()) val tombstone = (BoardWireCodec.decode(payloads.allValues.last()) as BoardWire.Tombstone).packet @@ -68,6 +81,32 @@ class BoardManagerTest { assertTrue(tombstone.verifySignature()) } + @Test + fun `mesh board keeps the established mesh signing identity`() = runTest { + val mesh = mock() + whenever(mesh.getSigningPublicKey()).thenReturn(publicKey) + whenever(mesh.signData(any())).thenAnswer { invocation -> + sign(invocation.getArgument(0)) + } + val manager = BoardManager( + store = BoardStore(nowMs = { NOW }), + scope = backgroundScope, + meshProvider = { mesh }, + geoIdentityProvider = { geoIdentity }, + notesManager = mock(), + nowMs = { NOW } + ) + + assertTrue(manager.createPost("mesh notice", "", "alice", false, 1)) + + val payload = argumentCaptor() + verify(mesh).sendBoardPayload(payload.capture()) + val post = (BoardWireCodec.decode(payload.firstValue) as BoardWire.Post).packet + assertTrue(publicKey.contentEquals(post.authorSigningKey)) + assertTrue(post.verifySignature()) + verify(mesh).signData(any()) + } + @Test @OptIn(ExperimentalCoroutinesApi::class) fun `remote urgent arrivals badge their scope and collapse into an alert`() = runTest { diff --git a/app/src/test/java/com/bitchat/android/board/BoardPacketsTest.kt b/app/src/test/java/com/bitchat/android/board/BoardPacketsTest.kt index c162510e..99762a5f 100644 --- a/app/src/test/java/com/bitchat/android/board/BoardPacketsTest.kt +++ b/app/src/test/java/com/bitchat/android/board/BoardPacketsTest.kt @@ -119,6 +119,41 @@ class BoardPacketsTest { assertTrue(post.verifySignature()) } + @Test + fun `transport sender is scoped to embedded author identity`() { + val wire = BoardWire.Post(signedPost()) + val sameAuthor = BoardWire.Post(signedPost(content = "another notice")) + val otherIdentity = BoardSigningIdentity.fromEd25519Seed(ByteArray(32) { (it + 7).toByte() }) + val otherPost = BoardPostPacket( + postID = ByteArray(16) { 9 }, + geohash = "u33dc1", + content = "other author", + authorSigningKey = otherIdentity.publicKey, + authorNickname = "bob", + createdAt = 1_700_000_000_000uL, + expiresAt = 1_700_086_400_000uL, + flags = 0u, + signature = ByteArray(64) + ) + + assertEquals(BoardWireConstants.TRANSPORT_SENDER_ID_LENGTH, wire.transportSenderID().size) + assertArrayEquals(wire.transportSenderID(), sameAuthor.transportSenderID()) + assertFalse(wire.transportSenderID().contentEquals(BoardWire.Post(otherPost).transportSenderID())) + } + + @Test + fun `geo board identity is deterministic and domain separated from Nostr key`() { + val nostrSecret = ByteArray(32) { (it + 11).toByte() } + val secretHex = nostrSecret.joinToString("") { "%02x".format(it) } + val first = BoardSigningIdentity.fromNostrPrivateKeyHex(secretHex)!! + val second = BoardSigningIdentity.fromNostrPrivateKeyHex(secretHex)!! + + assertArrayEquals(first.publicKey, second.publicKey) + assertFalse(first.publicKey.contentEquals(nostrSecret)) + val message = "ios-compatible-board-payload".toByteArray() + assertTrue(BoardWireCodec.verify(first.sign(message)!!, message, first.publicKey)) + } + private fun signedPost( postID: ByteArray = ByteArray(16) { (it + 1).toByte() }, geohash: String = "u33dc1", diff --git a/app/src/test/java/com/bitchat/android/board/BoardStoreTest.kt b/app/src/test/java/com/bitchat/android/board/BoardStoreTest.kt index 58e1441d..bda4aa41 100644 --- a/app/src/test/java/com/bitchat/android/board/BoardStoreTest.kt +++ b/app/src/test/java/com/bitchat/android/board/BoardStoreTest.kt @@ -56,19 +56,39 @@ class BoardStoreTest { } @Test - fun `only author tombstone deletes known post`() { + fun `foreign tombstone does not delete known post`() { val store = BoardStore(nowMs = { now }) val post = signedPost() assertEquals(BoardIngestResult.ACCEPTED, store.ingestPost(post)) val forged = signedTombstone(post, attacker) - assertEquals(BoardIngestResult.REJECTED, store.ingestTombstone(forged)) + assertEquals(BoardIngestResult.ACCEPTED, store.ingestTombstone(forged)) assertEquals(1, store.posts("").size) val valid = signedTombstone(post, author, deletedAt = now + 1uL) assertEquals(BoardIngestResult.ACCEPTED, store.ingestTombstone(valid)) assertTrue(store.posts("").isEmpty()) - assertEquals(1, store.syncCandidates().size) + assertEquals(2, store.syncCandidates().size) + } + + @Test + fun `post id collision cannot suppress another author's post or deletion`() { + val store = BoardStore(nowMs = { now }) + val genuine = signedPost() + val collision = signedPost(key = attacker) + + assertEquals(BoardIngestResult.ACCEPTED, store.ingestPost(collision)) + assertEquals(BoardIngestResult.ACCEPTED, store.ingestPost(genuine)) + assertEquals(2, store.posts("").size) + + assertEquals( + BoardIngestResult.ACCEPTED, + store.ingestTombstone(signedTombstone(genuine, author)) + ) + + val remaining = store.posts("") + assertEquals(1, remaining.size) + assertTrue(remaining.single().authorSigningKey.contentEquals(attacker.publicKey)) } @Test @@ -164,7 +184,8 @@ class BoardStoreTest { idByte: Byte = 1, geohash: String = "", createdAt: ULong = now, - expiresAt: ULong = now + 86_400_000uL + expiresAt: ULong = now + 86_400_000uL, + key: Key = author ): BoardPostPacket { val postID = ByteArray(16).also { it[0] = idByte } val content = "notice-$idByte" @@ -172,7 +193,7 @@ class BoardStoreTest { postID, geohash, content, - author.publicKey, + key.publicKey, "alice", createdAt, expiresAt, @@ -182,12 +203,12 @@ class BoardStoreTest { postID, geohash, content, - author.publicKey, + key.publicKey, "alice", createdAt, expiresAt, 0u, - author.sign(signingBytes) + key.sign(signingBytes) ) } diff --git a/app/src/test/java/com/bitchat/android/board/UnifiedNoticesTest.kt b/app/src/test/java/com/bitchat/android/board/UnifiedNoticesTest.kt index 8ccc8e81..9443a7de 100644 --- a/app/src/test/java/com/bitchat/android/board/UnifiedNoticesTest.kt +++ b/app/src/test/java/com/bitchat/android/board/UnifiedNoticesTest.kt @@ -72,16 +72,27 @@ class UnifiedNoticesTest { assertFalse(result.last().urgent) } + @Test + fun `colliding post ids from different authors retain unique row ids`() { + val first = post(content = "first") + val second = post(content = "second", authorKeyByte = 2) + + val result = UnifiedNotices.merge("u33dc", listOf(first, second), emptyList()) + + assertEquals(2, result.map { it.id }.distinct().size) + } + private fun post( content: String, nickname: String = "alice", createdAt: ULong = baseMs, - urgent: Boolean = false + urgent: Boolean = false, + authorKeyByte: Byte = 1 ) = BoardPostPacket( - postID = ByteArray(16) { content.hashCode().toByte() }, + postID = ByteArray(16) { 7 }, geohash = "u33dc", content = content, - authorSigningKey = ByteArray(32) { 1 }, + authorSigningKey = ByteArray(32) { authorKeyByte }, authorNickname = nickname, createdAt = createdAt, expiresAt = createdAt + 86_400_000uL, diff --git a/app/src/test/java/com/bitchat/android/sync/BoardSyncTest.kt b/app/src/test/java/com/bitchat/android/sync/BoardSyncTest.kt index c078c1b0..0f3a294d 100644 --- a/app/src/test/java/com/bitchat/android/sync/BoardSyncTest.kt +++ b/app/src/test/java/com/bitchat/android/sync/BoardSyncTest.kt @@ -1,8 +1,11 @@ package com.bitchat.android.sync +import com.bitchat.android.model.IdentityAnnouncement +import com.bitchat.android.model.PeerCapabilities import com.bitchat.android.model.RequestSyncPacket import com.bitchat.android.protocol.BitchatPacket import com.bitchat.android.protocol.MessageType +import kotlinx.coroutines.cancel import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -12,6 +15,8 @@ import org.junit.Assert.assertFalse import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Test +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit class BoardSyncTest { @Test @@ -94,6 +99,39 @@ class BoardSyncTest { assertEquals(listOf(MessageType.BOARD_POST.value), sent.map { it.type }) assertEquals(0u.toUByte(), sent.single().ttl) + scope.cancel() + } + + @Test + fun `initial sync omits board for a legacy peer`() { + val requestedTypes = initialSyncTypes( + announcementCapabilities = null, + observeBoardRequest = false + ) + + assertTrue(requestedTypes.contains(MessageType.ANNOUNCE)) + assertTrue(requestedTypes.contains(MessageType.MESSAGE)) + assertFalse(requestedTypes.contains(MessageType.BOARD_POST)) + } + + @Test + fun `initial sync includes board for an advertising peer`() { + val requestedTypes = initialSyncTypes( + announcementCapabilities = PeerCapabilities.BOARD, + observeBoardRequest = false + ) + + assertTrue(requestedTypes.contains(MessageType.BOARD_POST)) + } + + @Test + fun `a board request opts current iOS peers into future board sync`() { + val requestedTypes = initialSyncTypes( + announcementCapabilities = null, + observeBoardRequest = true + ) + + assertTrue(requestedTypes.contains(MessageType.BOARD_POST)) } private fun config() = object : GossipSyncManager.ConfigProvider { @@ -109,4 +147,60 @@ class BoardSyncTest { payload = byteArrayOf(1, 2, 3), ttl = 7u ) + + private fun initialSyncTypes( + announcementCapabilities: PeerCapabilities?, + observeBoardRequest: Boolean + ): SyncTypeFlags { + val peerID = "1111111111111111" + val scope = CoroutineScope(SupervisorJob() + Dispatchers.Unconfined) + val manager = GossipSyncManager( + myPeerID = "0102030405060708", + scope = scope, + configProvider = config() + ) + manager.boardPacketsProvider = { emptyList() } + manager.onPublicPacketSeen( + BitchatPacket( + type = MessageType.ANNOUNCE.value, + senderID = ByteArray(8) { 0x11 }, + timestamp = System.currentTimeMillis().toULong(), + payload = IdentityAnnouncement( + nickname = "peer", + noisePublicKey = ByteArray(32) { 1 }, + signingPublicKey = ByteArray(32) { 2 }, + capabilities = announcementCapabilities + ).encode()!!, + ttl = 7u + ) + ) + if (observeBoardRequest) { + manager.handleRequestSync( + fromPeerID = peerID, + request = RequestSyncPacket( + p = 7, + m = 1, + data = ByteArray(0), + types = SyncTypeFlags.BOARD + ) + ) + } + + val latch = CountDownLatch(1) + var sent: BitchatPacket? = null + manager.delegate = object : GossipSyncManager.Delegate { + override fun sendPacket(packet: BitchatPacket) = Unit + override fun sendPacketToPeer(peerID: String, packet: BitchatPacket) { + sent = packet + latch.countDown() + } + override fun signPacketForBroadcast(packet: BitchatPacket): BitchatPacket = packet + } + manager.scheduleInitialSyncToPeer(peerID, delayMs = 0) + + assertTrue("initial sync was not sent", latch.await(2, TimeUnit.SECONDS)) + val types = RequestSyncPacket.decode(requireNotNull(sent).payload)?.types + scope.cancel() + return requireNotNull(types) + } }