mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-08-29 07:16:08 +00:00
fix: harden board identity and sync compatibility
This commit is contained in:
parent
112f434f5c
commit
ed99f243c1
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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) }
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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")
|
||||
}
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user