fix: harden board identity and sync compatibility

This commit is contained in:
callebtc 2026-07-27 03:31:26 +02:00
parent 112f434f5c
commit ed99f243c1
14 changed files with 397 additions and 61 deletions

View File

@ -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<BoardPostPacket> = 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

View File

@ -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

View File

@ -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()
}
}
}
}

View File

@ -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<StoredPost>, 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) }

View File

@ -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(),

View File

@ -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))
}
}

View File

@ -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))
}
}

View File

@ -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<String, Pair<String, BitchatPacket>>()
private val responseTimesByPeer = ConcurrentHashMap<String, ArrayDeque<Long>>()
private val advertisedBoardPeers = ConcurrentHashMap.newKeySet<String>()
private val observedBoardSyncPeers = ConcurrentHashMap.newKeySet<String>()
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<String> =
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")
}

View File

@ -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()

View File

@ -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<MeshService>()
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<ByteArray>()
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 {

View File

@ -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",

View File

@ -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)
)
}

View File

@ -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,

View File

@ -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)
}
}