fix: harden bridge courier privacy and retries

This commit is contained in:
callebtc 2026-07-27 03:21:16 +02:00
parent a9f471faea
commit 1e44e94ed4
18 changed files with 771 additions and 143 deletions

View File

@ -52,6 +52,10 @@ class LocationChannelManager private constructor(private val context: Context) {
private val geocoderProvider: GeocoderProvider = GeocoderFactory.get(context)
private var lastLocation: Location? = null
private var geocodingJob: Job? = null
@Volatile
private var acceptLocationUpdates = true
@Volatile
private var locationRequestGeneration = 0L
private val gson = Gson()
private var dataManager: com.bitchat.android.ui.DataManager? = null
@ -75,7 +79,7 @@ class LocationChannelManager private constructor(private val context: Context) {
}
private val locationUpdateCallback: (Location) -> Unit = { location ->
onLocationUpdated(location)
if (acceptLocationUpdates) onLocationUpdated(location)
}
// Published state for UI bindings (matching iOS @Published properties)
@ -166,9 +170,12 @@ class LocationChannelManager private constructor(private val context: Context) {
/**
* Refresh available channels from current location
*/
fun refreshChannels() {
fun refreshChannels(
forceFresh: Boolean = false,
updatePlaceNames: Boolean = true
) {
if (_permissionState.value == PermissionState.AUTHORIZED && isLocationServicesEnabled()) {
requestOneShotLocation()
requestOneShotLocation(forceFresh, updatePlaceNames)
}
}
@ -189,6 +196,7 @@ class LocationChannelManager private constructor(private val context: Context) {
return
}
acceptLocationUpdates = true
// Register for continuous updates from available provider
locationProvider.requestLocationUpdates(
intervalMs = interval,
@ -212,7 +220,7 @@ class LocationChannelManager private constructor(private val context: Context) {
* Select a channel
*/
fun select(channel: ChannelID) {
Log.d(TAG, "Selected channel: ${channel.displayName}")
Log.d(TAG, "Location channel selection updated")
// Use synchronous set to avoid race with background recomputation
_selectedChannel.value = channel
saveChannelSelection(channel)
@ -231,7 +239,7 @@ class LocationChannelManager private constructor(private val context: Context) {
)
val isTeleportedNow = currentGeohash != channel.channel.geohash
_teleported.value = isTeleportedNow
Log.d(TAG, "Teleported (immediate recompute): $isTeleportedNow (current: $currentGeohash, selected: ${channel.channel.geohash})")
Log.d(TAG, "Teleported status updated: $isTeleportedNow")
}
}
}
@ -292,7 +300,10 @@ class LocationChannelManager private constructor(private val context: Context) {
// MARK: - Location Operations
private fun requestOneShotLocation() {
private fun requestOneShotLocation(
forceFresh: Boolean = false,
updatePlaceNames: Boolean = true
) {
if (!checkAndSyncPermission()) {
Log.w(TAG, "No location permission for one-shot request")
return
@ -301,33 +312,53 @@ class LocationChannelManager private constructor(private val context: Context) {
Log.d(TAG, "Requesting one-shot location")
// Set loading state initially
_isLoadingLocation.value = true
acceptLocationUpdates = true
val generation = locationRequestGeneration
if (forceFresh) {
requestFreshLocation(updatePlaceNames, generation)
return
}
locationProvider.getLastKnownLocation { cached ->
if (generation != locationRequestGeneration || !acceptLocationUpdates) {
return@getLastKnownLocation
}
// If we have a cached location and it's reasonably recent (e.g. < 5 mins), use it
// For now, we just use it if it exists, similar to previous logic
if (cached != null) {
Log.d(TAG, "Using last known location: ${cached.latitude}, ${cached.longitude}")
onLocationUpdated(cached)
Log.d(TAG, "Using recent location fix")
onLocationUpdated(cached, updatePlaceNames)
} else {
Log.d(TAG, "No last known location available, requesting fresh...")
locationProvider.requestFreshLocation { fresh ->
if (fresh != null) {
Log.d(TAG, "Fresh location received: ${fresh.latitude}, ${fresh.longitude}")
onLocationUpdated(fresh)
} else {
Log.w(TAG, "Failed to get fresh location")
_isLoadingLocation.value = false
}
}
requestFreshLocation(updatePlaceNames, generation)
}
}
}
private fun onLocationUpdated(location: Location) {
private fun requestFreshLocation(
updatePlaceNames: Boolean = true,
generation: Long = locationRequestGeneration
) {
locationProvider.requestFreshLocation { fresh ->
if (generation != locationRequestGeneration || !acceptLocationUpdates) {
return@requestFreshLocation
}
if (fresh != null) {
Log.d(TAG, "Fresh location fix received")
onLocationUpdated(fresh, updatePlaceNames)
} else {
Log.w(TAG, "Failed to get fresh location")
_isLoadingLocation.value = false
}
}
}
private fun onLocationUpdated(location: Location, updatePlaceNames: Boolean = true) {
lastLocation = location
_isLoadingLocation.value = false
computeChannels(location)
reverseGeocodeIfNeeded(location)
if (updatePlaceNames) reverseGeocodeIfNeeded(location)
}
@ -356,7 +387,7 @@ class LocationChannelManager private constructor(private val context: Context) {
}
private fun computeChannels(location: Location) {
Log.d(TAG, "Computing channels for location: ${location.latitude}, ${location.longitude}")
Log.d(TAG, "Computing location channels")
val levels = GeohashChannelLevel.allCases()
val result = mutableListOf<GeohashChannel>()
@ -369,7 +400,7 @@ class LocationChannelManager private constructor(private val context: Context) {
)
result.add(GeohashChannel(level = level, geohash = geohash))
Log.v(TAG, "Generated ${level.displayName}: $geohash")
Log.v(TAG, "Generated ${level.displayName} location channel")
}
_availableChannels.value = result
@ -388,7 +419,7 @@ class LocationChannelManager private constructor(private val context: Context) {
)
val isTeleported = currentGeohash != selectedChannelValue.channel.geohash
_teleported.value = isTeleported
Log.d(TAG, "Teleported status: $isTeleported (current: $currentGeohash, selected: ${selectedChannelValue.channel.geohash})")
Log.d(TAG, "Teleported status updated: $isTeleported")
}
}
}
@ -408,7 +439,7 @@ class LocationChannelManager private constructor(private val context: Context) {
if (addresses.isNotEmpty()) {
val address = addresses[0]
val names = namesByLevel(address)
Log.d(TAG, "Reverse geocoding result: $names")
Log.d(TAG, "Reverse geocoding completed")
_locationNames.value = names
} else {
Log.w(TAG, "No reverse geocoding results")
@ -482,7 +513,7 @@ class LocationChannelManager private constructor(private val context: Context) {
)
}
dataManager?.saveLastGeohashChannel(channelData)
Log.d(TAG, "Saved channel selection: ${channel.displayName}")
Log.d(TAG, "Saved location channel selection")
} catch (e: Exception) {
Log.e(TAG, "Failed to save channel selection: ${e.message}")
}
@ -499,7 +530,7 @@ class LocationChannelManager private constructor(private val context: Context) {
val channel = persisted?.toChannel()
if (channel != null) {
_selectedChannel.value = channel
Log.d(TAG, "Restored persisted channel: ${channel.displayName}")
Log.d(TAG, "Restored persisted location channel")
} else {
Log.d(TAG, "Could not restore persisted channel, defaulting to Mesh")
_selectedChannel.value = ChannelID.Mesh
@ -543,6 +574,20 @@ class LocationChannelManager private constructor(private val context: Context) {
Log.d(TAG, "Cleared persisted channel selection")
}
/** Remove exact/transient location state without destroying the singleton. */
fun panicReset() {
acceptLocationUpdates = false
locationRequestGeneration += 1
locationProvider.cancel()
geocodingJob?.cancel()
geocodingJob = null
lastLocation = null
_availableChannels.value = emptyList()
_locationNames.value = emptyMap()
_isLoadingLocation.value = false
clearPersistedChannel()
}
// MARK: - Location Services State Persistence
/**

View File

@ -413,7 +413,7 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
override fun getBroadcastRecipient(): ByteArray {
return SpecialRecipients.BROADCAST
}
// Cryptographic operations
override fun verifySignature(packet: BitchatPacket, peerID: String): Boolean {
return securityManager.verifySignature(packet, peerID)
@ -570,6 +570,9 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
override fun getBroadcastRecipient(): ByteArray {
return SpecialRecipients.BROADCAST
}
override fun isPeerDirectlyConnected(peerID: String): Boolean =
peerManager.getPeerInfo(peerID)?.isDirectConnection == true
override fun handleNoiseHandshake(routed: RoutedPacket): Boolean {
return runBlocking { securityManager.handleNoiseHandshake(routed) }
@ -925,7 +928,7 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
}
fun sendCourierEnvelope(payload: ByteArray, recipientPeerID: String) {
sendRawProtocolPacket(MessageType.COURIER_ENVELOPE, payload, recipientPeerID, sign = false)
sendRawProtocolPacket(MessageType.COURIER_ENVELOPE, payload, recipientPeerID, sign = true)
}
fun sendPrekeyBundle(payload: ByteArray) {
@ -948,7 +951,12 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
ttl = MAX_TTL
) ?: return@launch
val outgoing = if (sign) signPacketBeforeBroadcast(packet) else packet
if (sign && outgoing.signature?.size != 64) return@launch
if (sign &&
type != MessageType.COURIER_ENVELOPE &&
outgoing.signature?.size != 64
) {
return@launch
}
broadcastRoutedPacket(RoutedPacket(outgoing))
}
}

View File

@ -51,7 +51,7 @@ interface BridgeMeshDelegate {
fun handleVerifiedAnnouncement(peerId: String, announcement: IdentityAnnouncement)
fun handlePrekeyPacket(packet: BitchatPacket)
fun handleCarrier(payload: ByteArray, fromPeerId: String, directedToUs: Boolean)
fun handleCourierEnvelope(payload: ByteArray)
fun handleCourierEnvelope(packet: BitchatPacket, fromPeerId: String, directIngress: Boolean)
}
object BridgeMeshPort : BridgeMeshDelegate {
@ -96,7 +96,11 @@ object BridgeMeshPort : BridgeMeshDelegate {
delegate?.handleCarrier(payload, fromPeerId, directedToUs)
}
override fun handleCourierEnvelope(payload: ByteArray) {
delegate?.handleCourierEnvelope(payload)
override fun handleCourierEnvelope(
packet: BitchatPacket,
fromPeerId: String,
directIngress: Boolean
) {
delegate?.handleCourierEnvelope(packet, fromPeerId, directIngress)
}
}

View File

@ -442,6 +442,9 @@ class MeshCore(
return SpecialRecipients.BROADCAST
}
override fun isPeerDirectlyConnected(peerID: String): Boolean =
peerManager.getPeerInfo(peerID)?.isDirectConnection == true
override fun handleNoiseHandshake(routed: RoutedPacket): Boolean {
return runBlocking { securityManager.handleNoiseHandshake(routed) }
}
@ -554,7 +557,7 @@ class MeshCore(
type = MessageType.COURIER_ENVELOPE,
payload = payload,
recipientPeerID = recipientPeerID,
sign = false
sign = true
)
}
@ -583,7 +586,12 @@ class MeshCore(
ttl = maxTtl
) ?: return@launch
val outgoing = if (sign) signPacketBeforeBroadcast(packet) else packet
if (sign && outgoing.signature?.size != 64) return@launch
if (sign &&
type != MessageType.COURIER_ENVELOPE &&
outgoing.signature?.size != 64
) {
return@launch
}
dispatchGlobal(RoutedPacket(outgoing))
}
}

View File

@ -171,7 +171,14 @@ class PacketProcessor(private val myPeerID: String) {
MessageType.NOISE_HANDSHAKE -> validPacket = handleNoiseHandshake(routed)
MessageType.NOISE_ENCRYPTED -> handleNoiseEncrypted(routed)
MessageType.COURIER_ENVELOPE -> {
BridgeMeshPort.handleCourierEnvelope(packet.payload)
val directIngress =
packet.ttl == com.bitchat.android.util.AppConstants.MESSAGE_TTL_HOPS &&
delegate?.isPeerDirectlyConnected(peerID) == true
BridgeMeshPort.handleCourierEnvelope(
packet = packet,
fromPeerId = peerID,
directIngress = directIngress
)
}
MessageType.FILE_TRANSFER -> handleMessage(routed)
else -> {
@ -335,6 +342,7 @@ interface PacketProcessorDelegate {
// Network information
fun getNetworkSize(): Int
fun getBroadcastRecipient(): ByteArray
fun isPeerDirectlyConnected(peerID: String): Boolean = false
// Message type handlers
fun handleNoiseHandshake(routed: RoutedPacket): Boolean

View File

@ -102,17 +102,17 @@ object AppStateStore {
_publicMessages.value.any { !it.isBridged && it.id == messageId }
}
fun addPrivateMessage(peerID: String, msg: BitchatMessage) {
fun addPrivateMessage(peerID: String, msg: BitchatMessage): Boolean =
synchronized(this) {
if (seenMessageIds.contains(msg.id)) return
if (seenMessageIds.contains(msg.id)) return@synchronized false
seenMessageIds.add(msg.id)
val conversationID = ContactDirectory.canonicalConversationId(peerID)
val map = _privateMessages.value.toMutableMap()
val list = (map[conversationID] ?: emptyList()) + msg
map[conversationID] = list
_privateMessages.value = ContactDirectory.canonicalizePrivateChats(map)
true
}
}
private fun statusPriority(status: DeliveryStatus?): Int = when (status) {
null -> 0

View File

@ -5,12 +5,15 @@ import android.util.Log
import com.bitchat.android.favorites.FavoriteControlMessage
import com.bitchat.android.mesh.MeshService
import com.bitchat.android.model.ReadReceipt
import com.bitchat.android.nostr.NostrRelayManager
import com.bitchat.android.nostr.NostrTransport
import com.bitchat.android.services.bridge.CourierDepositResult
import com.bitchat.android.services.bridge.MeshBridgeService
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
/**
@ -19,12 +22,21 @@ import kotlinx.coroutines.launch
class MessageRouter private constructor(
private var mesh: MeshService,
private val nostr: NostrTransport,
private val relayManager: NostrRelayManager,
private val currentNostrIdentity: () -> com.bitchat.android.nostr.NostrIdentity?
) {
private data class OutboxMessage(
val content: String,
val recipientNickname: String,
val messageId: String
val messageId: String,
val createdAtMs: Long = System.currentTimeMillis(),
var lastMeshAttemptMs: Long = 0,
var lastNostrAttemptMs: Long = 0
)
private data class OutboxKey(
val conversationID: String,
val messageID: String
)
enum class RouteResult {
@ -36,6 +48,10 @@ class MessageRouter private constructor(
companion object {
private const val TAG = "MessageRouter"
private const val RETRY_INTERVAL_MS = 60_000L
private const val TRANSPORT_RETRY_INTERVAL_MS = 5L * 60 * 1000
private const val OUTBOX_TTL_MS = 48L * 60 * 60 * 1000
private const val MAX_OUTBOX_PER_CONVERSATION = 50
@Volatile private var INSTANCE: MessageRouter? = null
fun tryGetInstance(): MessageRouter? = INSTANCE
fun getInstance(context: Context, mesh: MeshService): MessageRouter {
@ -46,6 +62,7 @@ class MessageRouter private constructor(
MessageRouter(
mesh = mesh,
nostr = nostr,
relayManager = NostrRelayManager.getInstance(application),
currentNostrIdentity = {
com.bitchat.android.nostr.NostrIdentityBridge
.getCurrentNostrIdentity(application)
@ -67,7 +84,31 @@ class MessageRouter private constructor(
}
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val outboxLock = Any()
private val outbox = mutableMapOf<String, MutableList<OutboxMessage>>()
private val courierAttemptTimes = mutableMapOf<OutboxKey, Long>()
private val courierAttemptsInFlight = mutableSetOf<OutboxKey>()
init {
scope.launch {
MeshBridgeService.isEnabled.collect { enabled ->
if (enabled) retryCourierDeposits(force = true)
}
}
scope.launch {
relayManager.isConnected.collect { connected ->
if (connected) flushAllOutbox()
}
}
scope.launch {
while (isActive) {
delay(RETRY_INTERVAL_MS)
pruneExpiredOutbox()
retryCourierDeposits(force = false)
if (relayManager.isConnected.value) flushAllOutbox()
}
}
}
// Listener for favorites changes to flush outbox when npub mapping appears/changes
private val favoriteListener = object: com.bitchat.android.favorites.FavoritesChangeListener {
@ -102,33 +143,26 @@ class MessageRouter private constructor(
Log.d(TAG, "Routing PM via mesh to ${meshTarget} msg_id=${messageID.take(8)}")
mesh.sendPrivateMessage(content, meshTarget, recipientNickname, messageID)
return RouteResult.MESH
} else if (canSendViaNostr(nostrTarget)) {
} else if (canSendViaNostr(nostrTarget) && relayManager.isConnected.value) {
Log.d(TAG, "Routing PM via Nostr to ${conversationID.take(32)}… msg_id=${messageID.take(8)}")
nostr.sendPrivateMessage(content, nostrTarget, recipientNickname, messageID)
return RouteResult.NOSTR
} else {
Log.d(TAG, "Queued PM for ${conversationID} (no mesh, no Nostr mapping) msg_id=${messageID.take(8)}")
val q = outbox.getOrPut(conversationID) { mutableListOf() }
q.add(OutboxMessage(content, recipientNickname, messageID))
Log.d(TAG, "Queued PM for ${conversationID.take(16)}… (no ready transport) msg_id=${messageID.take(8)}")
enqueueOutbox(
conversationID,
OutboxMessage(content, recipientNickname, messageID)
)
resolution.noisePublicKey?.let { recipientNoiseKey ->
scope.launch {
val result = runCatching {
MeshBridgeService.depositCourierDrop(
content = content,
messageId = messageID,
recipientNoiseKey = recipientNoiseKey
)
}.getOrElse { error ->
Log.w(TAG, "Courier deposit failed: ${error.message}")
return@launch
}
if (result is CourierDepositResult.Rejected) {
Log.d(TAG, "Courier deposit rejected: ${result.reason}")
}
}
attemptCourierDeposit(
conversationID,
OutboxMessage(content, recipientNickname, messageID),
recipientNoiseKey,
force = true
)
}
Log.d(TAG, "Initiating noise handshake after queueing PM for ${conversationID.take(16)}")
if (hasMesh) meshTarget?.let { mesh.initiateNoiseHandshake(it) }
if (hasMesh) mesh.initiateNoiseHandshake(meshTarget)
return RouteResult.QUEUED
}
}
@ -191,42 +225,85 @@ class MessageRouter private constructor(
// Flush any queued messages for a specific peerID
fun flushOutboxFor(peerID: String) {
val conversationID = ContactDirectory.canonicalConversationId(peerID)
val queued = outbox[conversationID] ?: outbox[peerID] ?: return
if (queued.isEmpty()) return
Log.d(TAG, "Flushing outbox for ${conversationID.take(16)}… count=${queued.size}")
val iterator = queued.iterator()
while (iterator.hasNext()) {
val queuedMessage = iterator.next()
val resolution = ContactDirectory.resolve(conversationID)
val meshTarget = resolution.meshPeerID
val nostrTarget = resolution.noiseKeyHex ?: conversationID
if (meshTarget != null && isReady(mesh, meshTarget)) {
mesh.sendPrivateMessage(
queuedMessage.content,
meshTarget,
queuedMessage.recipientNickname,
queuedMessage.messageId
)
iterator.remove()
} else if (canSendViaNostr(nostrTarget)) {
nostr.sendPrivateMessage(
queuedMessage.content,
nostrTarget,
queuedMessage.recipientNickname,
queuedMessage.messageId
)
iterator.remove()
}
val matchingQueues = synchronized(outboxLock) {
outbox.entries
.filter { (key, _) ->
key == peerID ||
ContactDirectory.canonicalConversationId(key) == conversationID
}
.map { it.key to it.value.toList() }
}
if (queued.isEmpty()) {
outbox.remove(conversationID)
outbox.remove(peerID)
if (matchingQueues.isEmpty()) return
Log.d(
TAG,
"Flushing outbox for ${conversationID.take(16)}… count=${matchingQueues.sumOf { it.second.size }}"
)
matchingQueues.forEach { (queuedConversationID, queued) ->
queued.forEach messageLoop@{ queuedMessage ->
val resolution = ContactDirectory.resolve(queuedConversationID)
val meshTarget = resolution.meshPeerID
val nostrTarget = resolution.noiseKeyHex ?: queuedConversationID
if (meshTarget != null && isReady(mesh, meshTarget)) {
if (!shouldAttemptTransport(queuedMessage, mesh = true)) return@messageLoop
mesh.sendPrivateMessage(
queuedMessage.content,
meshTarget,
queuedMessage.recipientNickname,
queuedMessage.messageId
)
recordTransportAttempt(
queuedConversationID,
queuedMessage.messageId,
mesh = true
)
} else if (canSendViaNostr(nostrTarget) && relayManager.isConnected.value) {
if (!shouldAttemptTransport(queuedMessage, mesh = false)) return@messageLoop
nostr.sendPrivateMessage(
queuedMessage.content,
nostrTarget,
queuedMessage.recipientNickname,
queuedMessage.messageId
)
recordTransportAttempt(
queuedConversationID,
queuedMessage.messageId,
mesh = false
)
}
}
}
}
// Flush everything (rarely used)
fun flushAllOutbox() {
outbox.keys.toList().forEach { flushOutboxFor(it) }
synchronized(outboxLock) { outbox.keys.toList() }.forEach { flushOutboxFor(it) }
}
/** Stop retrying a retained message once any transport confirms delivery. */
fun acknowledge(messageID: String, fromPeerID: String) {
val acknowledgedConversation = ContactDirectory.canonicalConversationId(fromPeerID)
synchronized(outboxLock) {
outbox.entries.forEach { (conversationID, messages) ->
if (ContactDirectory.canonicalConversationId(conversationID) ==
acknowledgedConversation
) {
messages.removeAll { it.messageId == messageID }
val key = OutboxKey(conversationID, messageID)
courierAttemptTimes.remove(key)
courierAttemptsInFlight.remove(key)
}
}
outbox.entries.removeAll { it.value.isEmpty() }
}
}
/** Panic-mode hook: retained plaintext and retry metadata must not survive. */
fun wipeOutbox() {
synchronized(outboxLock) {
outbox.clear()
courierAttemptTimes.clear()
courierAttemptsInFlight.clear()
}
}
private fun canSendViaNostr(peerID: String): Boolean {
@ -273,6 +350,7 @@ class MessageRouter private constructor(
} catch (_: Exception) { null }
noiseHex?.let { flushOutboxFor(it) }
}
retryCourierDeposits(force = true)
}
// Called when a Noise session becomes established; flush both the mesh peerID and its noiseHex alias
@ -283,4 +361,126 @@ class MessageRouter private constructor(
} catch (_: Exception) { null }
noiseHex?.let { flushOutboxFor(it) }
}
private fun enqueueOutbox(conversationID: String, message: OutboxMessage) {
synchronized(outboxLock) {
val queue = outbox.getOrPut(conversationID) { mutableListOf() }
if (queue.any { it.messageId == message.messageId }) return
queue += message
while (queue.size > MAX_OUTBOX_PER_CONVERSATION) {
val removed = queue.removeAt(0)
val key = OutboxKey(conversationID, removed.messageId)
courierAttemptTimes.remove(key)
courierAttemptsInFlight.remove(key)
AppStateStore.updatePrivateMessageStatus(
removed.messageId,
com.bitchat.android.model.DeliveryStatus.Failed("delivery queue full")
)
}
}
}
private fun retryCourierDeposits(force: Boolean) {
val queued = synchronized(outboxLock) {
outbox.flatMap { (conversationID, messages) ->
messages.filter { !isExpired(it) }.map { conversationID to it }
}
}
queued.forEach { (conversationID, message) ->
val recipientNoiseKey =
ContactDirectory.resolve(conversationID).noisePublicKey
if (recipientNoiseKey != null) {
attemptCourierDeposit(conversationID, message, recipientNoiseKey, force)
}
}
}
private fun attemptCourierDeposit(
conversationID: String,
message: OutboxMessage,
recipientNoiseKey: ByteArray,
force: Boolean
) {
val now = System.currentTimeMillis()
val key = OutboxKey(conversationID, message.messageId)
synchronized(outboxLock) {
if (key in courierAttemptsInFlight) return
val lastAttempt = courierAttemptTimes[key] ?: 0L
if (!force && now - lastAttempt < RETRY_INTERVAL_MS) return
courierAttemptTimes[key] = now
courierAttemptsInFlight += key
}
scope.launch {
val result = runCatching {
MeshBridgeService.depositCourierDrop(
content = message.content,
messageId = message.messageId,
recipientNoiseKey = recipientNoiseKey
)
}.getOrElse { error ->
Log.w(TAG, "Courier deposit failed: ${error.message}")
null
}
synchronized(outboxLock) {
courierAttemptsInFlight.remove(key)
}
if (result is CourierDepositResult.Rejected) {
Log.d(TAG, "Courier deposit rejected: ${result.reason}")
} else if (
result == CourierDepositResult.Published ||
result == CourierDepositResult.ForwardedToGateway ||
result == CourierDepositResult.AlreadyPublished
) {
AppStateStore.updatePrivateMessageStatus(
message.messageId,
com.bitchat.android.model.DeliveryStatus.Sent
)
}
}
}
private fun pruneExpiredOutbox() {
synchronized(outboxLock) {
outbox.forEach { (conversationID, messages) ->
val expiredIds = messages.filter(::isExpired).map { it.messageId }.toSet()
messages.removeAll { it.messageId in expiredIds }
expiredIds.forEach {
val key = OutboxKey(conversationID, it)
courierAttemptTimes.remove(key)
courierAttemptsInFlight.remove(key)
AppStateStore.updatePrivateMessageStatus(
it,
com.bitchat.android.model.DeliveryStatus.Failed("delivery expired")
)
}
}
outbox.entries.removeAll { it.value.isEmpty() }
}
}
private fun shouldAttemptTransport(message: OutboxMessage, mesh: Boolean): Boolean {
val lastAttempt = synchronized(outboxLock) {
if (mesh) message.lastMeshAttemptMs else message.lastNostrAttemptMs
}
return System.currentTimeMillis() - lastAttempt >= TRANSPORT_RETRY_INTERVAL_MS
}
private fun recordTransportAttempt(
conversationID: String,
messageID: String,
mesh: Boolean
) {
val now = System.currentTimeMillis()
synchronized(outboxLock) {
outbox[conversationID].orEmpty()
.filter { it.messageId == messageID }
.forEach { message ->
if (mesh) message.lastMeshAttemptMs = now else message.lastNostrAttemptMs = now
}
}
}
private fun isExpired(message: OutboxMessage): Boolean =
System.currentTimeMillis() - message.createdAtMs >= OUTBOX_TTL_MS
}

View File

@ -37,6 +37,7 @@ internal data class VerifiedBridgePeer(
val nickname: String,
val noiseKey: ByteArray,
val signingKey: ByteArray,
val isVerifiedNickname: Boolean,
val capabilities: PeerCapabilities?,
val bridgeCell: String?,
val lastSeenMs: Long

View File

@ -0,0 +1,40 @@
package com.bitchat.android.services.bridge
import com.bitchat.android.protocol.BitchatPacket
import org.bouncycastle.crypto.params.Ed25519PublicKeyParameters
import org.bouncycastle.crypto.signers.Ed25519Signer
/**
* Verifies bridge protocol packets against the signing key bound by the
* sender's authenticated announcement.
*/
internal object BridgePacketSignatureVerifier {
fun verify(packet: BitchatPacket, publicKey: ByteArray): Boolean {
val signature = packet.signature?.takeIf { it.size == 64 } ?: return false
val signingData = packet.toBinaryDataForSigning() ?: return false
if (publicKey.size != 32) return false
return runCatching {
Ed25519Signer().apply {
init(false, Ed25519PublicKeyParameters(publicKey, 0))
update(signingData, 0, signingData.size)
}.verifySignature(signature)
}.getOrDefault(false)
}
}
internal object CourierDepositAuthenticator {
fun authenticate(
packet: BitchatPacket,
fromPeerId: String,
directIngress: Boolean,
peers: List<VerifiedBridgePeer>,
isTrusted: (VerifiedBridgePeer) -> Boolean
): VerifiedBridgePeer? {
if (!directIngress || packet.senderID.toHex() != fromPeerId) return null
val peer = peers.firstOrNull { it.peerId == fromPeerId } ?: return null
if (!BridgePacketSignatureVerifier.verify(packet, peer.signingKey)) return null
return peer.takeIf(isTrusted)
}
private fun ByteArray.toHex(): String = joinToString("") { "%02x".format(it) }
}

View File

@ -16,9 +16,11 @@ import com.bitchat.android.nostr.NostrKind
import com.bitchat.android.nostr.NostrProtocol
import com.bitchat.android.nostr.NostrPublishResult
import com.bitchat.android.nostr.NostrRelayManager
import com.bitchat.android.protocol.BitchatPacket
import com.bitchat.android.services.AppStateStore
import com.bitchat.android.services.ContactDirectory
import com.bitchat.android.services.ContactIdentityResolver
import com.bitchat.android.services.MessageRouter
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@ -42,20 +44,25 @@ internal class CourierCoordinator(
private val prekeys: PrekeyManager,
private val meshProvider: () -> MeshService?,
private val peersProvider: () -> List<VerifiedBridgePeer>,
private val onPrekeyConsumed: () -> Unit,
private val isTrustedDepositor: (VerifiedBridgePeer) -> Boolean,
private val isSenderBlocked: (ByteArray) -> Boolean,
private val onPrekeyConsumed: suspend () -> Unit,
private val clock: () -> Long = System::currentTimeMillis,
private val dispatcher: CoroutineDispatcher = Dispatchers.Default.limitedParallelism(1)
) {
private data class PendingDrop(
val envelope: CourierEnvelope,
val dedupKey: String?,
val queueKey: String
val queueKey: String,
val depositorKey: String? = null,
val attemptedGatewayPeerIds: MutableSet<String> = mutableSetOf()
)
private val appContext = context.applicationContext
private val scope = CoroutineScope(SupervisorJob() + dispatcher)
private val pendingDrops = mutableListOf<PendingDrop>()
private val signatureAttemptTimes = mutableListOf<Long>()
private val depositTimesByPeer = mutableMapOf<String, MutableList<Long>>()
private var subscribedTags: Set<String> = emptySet()
private val courierSubscription = RelaySubscriptionSlot("mesh-bridge-courier")
@Volatile
@ -82,7 +89,9 @@ internal class CourierCoordinator(
fun peerStateChanged() {
scope.launch {
if (enabled) refreshSubscription()
if (!enabled) return@launch
refreshSubscription()
retryPendingGatewayHandoffs()
}
}
@ -94,15 +103,25 @@ internal class CourierCoordinator(
}
}
fun handleEnvelope(payload: ByteArray) {
fun handleEnvelope(packet: BitchatPacket, fromPeerId: String, directIngress: Boolean) {
scope.launch {
val envelope = CourierEnvelope.decode(payload) ?: return@launch
val envelope = CourierEnvelope.decode(packet.payload) ?: return@launch
if (!validLifetime(envelope)) return@launch
if (isMyTag(envelope.recipientTag)) {
openEnvelope(envelope)
} else if (enabled) {
publishOrQueue(envelope, dedupKey = null)
return@launch
}
if (!enabled) return@launch
if (!allowSignatureAttempt()) return@launch
val depositor = authenticatedDepositor(packet, fromPeerId, directIngress)
?: return@launch
if (!allowDeposit(depositor.peerId)) return@launch
publishOrQueue(
envelope = envelope,
dedupKey = null,
depositorKey = depositor.peerId
)
}
}
@ -177,6 +196,16 @@ internal class CourierCoordinator(
val gateway = availableGateway()
if (gateway != null) {
meshProvider()?.sendCourierEnvelope(encoded, gateway.peerId)
// Keep a local copy until a relay accepts it. If the gateway is
// lost before publishing, router retries can choose another path.
enqueue(
PendingDrop(
envelope,
dedupKey,
dedupKey,
attemptedGatewayPeerIds = mutableSetOf(gateway.peerId)
)
)
return@withContext CourierDepositResult.ForwardedToGateway
}
enqueue(PendingDrop(envelope, dedupKey, dedupKey))
@ -187,6 +216,7 @@ internal class CourierCoordinator(
closeSubscription()
pendingDrops.clear()
signatureAttemptTimes.clear()
depositTimesByPeer.clear()
publishedDropKeys.clear()
seenDropEventIds.clear()
openedMessageIds.clear()
@ -232,7 +262,7 @@ internal class CourierCoordinator(
}
}
private fun handleDropEvent(event: NostrEvent) {
private suspend fun handleDropEvent(event: NostrEvent) {
if (!enabled ||
event.kind != NostrKind.COURIER_DROP ||
seenDropEventIds.contains(event.id, clock()) ||
@ -268,14 +298,19 @@ internal class CourierCoordinator(
}
}
private fun openEnvelope(envelope: CourierEnvelope): Boolean {
private suspend fun openEnvelope(envelope: CourierEnvelope): Boolean {
val opened = runCatching {
prekeys.open(envelope.ciphertext, envelope.prekeyId, clock())
}.getOrNull() ?: return false
// Consumption happened during open. Announce the reduced/replenished
// bundle before any later payload parser can return early.
if (opened.consumedPrekey) onPrekeyConsumed()
val payload = NoisePayload.decode(opened.payload) ?: return true
if (payload.type != NoisePayloadType.PRIVATE_MESSAGE) return true
val privateMessage = PrivateMessagePacket.decode(payload.data) ?: return true
if (openedMessageIds.contains(privateMessage.messageID, clock())) return true
openedMessageIds.add(privateMessage.messageID, DROP_DEDUP_MS, clock())
if (isSenderBlocked(opened.senderStaticKey)) return true
val senderPeerId = ContactIdentityResolver.peerIdForNoiseKey(opened.senderStaticKey)
val senderResolution = ContactDirectory.resolve(opened.senderStaticKey.toHex())
val message = BitchatMessage(
@ -289,18 +324,23 @@ internal class CourierCoordinator(
recipientNickname = meshProvider()?.myPeerID,
senderPeerID = senderPeerId
)
AppStateStore.addPrivateMessage(
val delivered = AppStateStore.addPrivateMessage(
ContactIdentityResolver.contactConversationIdForNoiseKey(opened.senderStaticKey),
message
)
openedMessageIds.add(privateMessage.messageID, DROP_DEDUP_MS, clock())
if (opened.consumedPrekey) onPrekeyConsumed()
if (!delivered) return true
CourierMessagePort.deliver(message)
MessageRouter.tryGetInstance()?.sendDeliveryAck(
privateMessage.messageID,
opened.senderStaticKey.toHex()
)
return true
}
private suspend fun publishOrQueue(
envelope: CourierEnvelope,
dedupKey: String?
dedupKey: String?,
depositorKey: String? = null
): CourierDepositResult {
if (!validLifetime(envelope)) {
return CourierDepositResult.Rejected(CourierDepositResult.Reason.INVALID_MESSAGE)
@ -310,7 +350,8 @@ internal class CourierCoordinator(
PendingDrop(
envelope,
dedupKey,
dedupKey ?: envelopeQueueKey(envelope)
dedupKey ?: envelopeQueueKey(envelope),
depositorKey
)
)
return CourierDepositResult.QueuedLocally
@ -352,7 +393,8 @@ internal class CourierCoordinator(
PendingDrop(
envelope,
dedupKey,
dedupKey ?: envelopeQueueKey(envelope)
dedupKey ?: envelopeQueueKey(envelope),
depositorKey
)
)
CourierDepositResult.QueuedLocally
@ -361,16 +403,38 @@ internal class CourierCoordinator(
}
private fun enqueue(drop: PendingDrop) {
if (pendingDrops.any { it.queueKey == drop.queueKey }) return
pendingDrops.firstOrNull { it.queueKey == drop.queueKey }?.let { existing ->
existing.attemptedGatewayPeerIds += drop.attemptedGatewayPeerIds
return
}
if (drop.depositorKey != null &&
pendingDrops.count { it.depositorKey == drop.depositorKey } >=
MAX_PENDING_DROPS_PER_DEPOSITOR
) {
return
}
pendingDrops += drop
while (pendingDrops.size > MAX_PENDING_DROPS) pendingDrops.removeAt(0)
}
private fun retryPendingGatewayHandoffs() {
if (relayManager.isConnected.value) return
pendingDrops.forEach { drop ->
if (drop.depositorKey != null) return@forEach
if (drop.attemptedGatewayPeerIds.size >= MAX_GATEWAYS_PER_DROP) return@forEach
val gateway = availableGateway(excluding = drop.attemptedGatewayPeerIds)
?: return@forEach
val encoded = drop.envelope.encode() ?: return@forEach
meshProvider()?.sendCourierEnvelope(encoded, gateway.peerId)
drop.attemptedGatewayPeerIds += gateway.peerId
}
}
private suspend fun flushPendingDrops() {
if (!enabled || !relayManager.isConnected.value) return
val queued = pendingDrops.toList()
pendingDrops.clear()
queued.forEach { publishOrQueue(it.envelope, it.dedupKey) }
queued.forEach { publishOrQueue(it.envelope, it.dedupKey, it.depositorKey) }
}
private fun closeSubscription() {
@ -378,11 +442,15 @@ internal class CourierCoordinator(
subscribedTags = emptySet()
}
private fun availableGateway(): VerifiedBridgePeer? =
private fun availableGateway(excluding: Set<String> = emptySet()): VerifiedBridgePeer? =
peersProvider().firstOrNull { peer ->
peer.peerId !in excluding &&
peer.capabilities?.contains(com.bitchat.android.model.PeerCapabilities.BRIDGE) == true &&
peer.bridgeCell != null &&
meshProvider()?.getPeerInfo(peer.peerId)?.isConnected == true
meshProvider()?.getPeerInfo(peer.peerId)?.let { info ->
info.isConnected && info.isDirectConnection
} == true &&
isTrustedDepositor(peer)
}
private fun isMyTag(tag: ByteArray): Boolean {
@ -390,6 +458,27 @@ internal class CourierCoordinator(
return CourierEnvelope.candidateTags(ownKey, clock()).any { it.contentEquals(tag) }
}
private fun authenticatedDepositor(
packet: BitchatPacket,
fromPeerId: String,
directIngress: Boolean
): VerifiedBridgePeer? = CourierDepositAuthenticator.authenticate(
packet = packet,
fromPeerId = fromPeerId,
directIngress = directIngress,
peers = peersProvider(),
isTrusted = isTrustedDepositor
)
private fun allowDeposit(peerId: String): Boolean {
val now = clock()
val attempts = depositTimesByPeer.getOrPut(peerId) { mutableListOf() }
attempts.removeAll { now - it >= RATE_WINDOW_MS }
if (attempts.size >= DEPOSITS_PER_MINUTE_PER_PEER) return false
attempts += now
return true
}
private fun validLifetime(envelope: CourierEnvelope): Boolean {
val now = clock()
return !envelope.isExpired(now) &&
@ -423,10 +512,13 @@ internal class CourierCoordinator(
const val MAX_TRACKED_IDS = 512
const val MAX_WATCHED_PEERS = 16
const val MAX_PENDING_DROPS = 20
const val MAX_PENDING_DROPS_PER_DEPOSITOR = 5
const val MAX_GATEWAYS_PER_DROP = 2
const val MAX_DROP_BYTES = 20 * 1024
const val MAX_PRIVATE_MESSAGE_BYTES = 255
const val DROP_DEDUP_MS = 24L * 60 * 60 * 1000
const val RATE_WINDOW_MS = 60_000L
const val SIGNATURE_ATTEMPTS_PER_MINUTE = 720
const val DEPOSITS_PER_MINUTE_PER_PEER = 10
}
}

View File

@ -0,0 +1,26 @@
package com.bitchat.android.services.bridge
import com.bitchat.android.model.BitchatMessage
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow
/**
* Process-local delivery signal for courier-opened private messages.
*
* AppStateStore remains the durable in-process timeline source. This signal
* lets the active UI run the same unread, haptic, and notification path used
* by live mesh private messages without retaining an Activity or ViewModel.
*/
object CourierMessagePort {
private val _messages = MutableSharedFlow<BitchatMessage>(
extraBufferCapacity = 16,
onBufferOverflow = BufferOverflow.DROP_OLDEST
)
val messages: SharedFlow<BitchatMessage> = _messages.asSharedFlow()
internal fun deliver(message: BitchatMessage) {
_messages.tryEmit(message)
}
}

View File

@ -1,8 +1,10 @@
package com.bitchat.android.services.bridge
import android.annotation.SuppressLint
import android.content.Context
import android.util.Log
import androidx.core.content.edit
import com.bitchat.android.favorites.FavoritesPersistenceService
import com.bitchat.android.geohash.Geohash
import com.bitchat.android.geohash.GeohashChannelLevel
import com.bitchat.android.geohash.LocationChannelManager
@ -22,6 +24,8 @@ import com.bitchat.android.nostr.NostrProtocol
import com.bitchat.android.nostr.NostrRelayManager
import com.bitchat.android.protocol.BitchatPacket
import com.bitchat.android.services.AppStateStore
import com.bitchat.android.services.ContactIdentityResolver
import com.bitchat.android.ui.DataManager
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@ -118,6 +122,10 @@ object MeshBridgeService : BridgeMeshDelegate {
private val signatureAttemptTimes = mutableListOf<Long>()
private var downlinkJob: Job? = null
private var presenceJob: Job? = null
@Volatile
private var suppressPrekeyBroadcasts = false
@Volatile
private var panicQuiesced = false
fun initialize(
context: Context,
@ -138,7 +146,11 @@ object MeshBridgeService : BridgeMeshDelegate {
relayManager = NostrRelayManager.getInstance(application)
val prekeyManager = PrekeyManager.getInstance(application)
prefs = application.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
_isEnabled.value = loadEnabledWithMigration(prefs!!)
val storedEnabled = loadEnabledWithMigration(prefs!!)
_isEnabled.value = storedEnabled && !panicQuiesced
if (panicQuiesced && storedEnabled) {
prefs?.edit { putBoolean(KEY_ENABLED, false) }
}
outboundPolicy.set(
BridgeOutboundPolicy.capture(
enabled = _isEnabled.value,
@ -159,8 +171,23 @@ object MeshBridgeService : BridgeMeshDelegate {
prekeys = prekeyManager,
meshProvider = ::currentMesh,
peersProvider = verifiedPeerSnapshot::get,
isTrustedDepositor = { peer ->
runCatching {
FavoritesPersistenceService.shared
.getFavoriteStatus(peer.noiseKey)
?.isMutual == true
}.getOrDefault(false) || peer.isVerifiedNickname
},
isSenderBlocked = { noiseKey ->
DataManager.isFingerprintBlocked(
application,
ContactIdentityResolver.fingerprintHex(noiseKey)
)
},
onPrekeyConsumed = {
scope.launch { prekeyCoordinator?.broadcast(force = true) }
kotlinx.coroutines.withContext(dispatcher) {
broadcastPrekeys(force = true)
}
},
clock = clock
)
@ -170,6 +197,10 @@ object MeshBridgeService : BridgeMeshDelegate {
val location = LocationChannelManager.getInstance(context)
scope.launch {
location.availableChannels.collect { channels ->
if (!_isEnabled.value) {
localLocationCell = null
return@collect
}
localLocationCell = channels
.firstOrNull { it.level == GeohashChannelLevel.NEIGHBORHOOD }
?.geohash
@ -203,18 +234,25 @@ object MeshBridgeService : BridgeMeshDelegate {
scope.launch {
if (_isEnabled.value) {
relayManager?.connect()
location.refreshChannels()
location.refreshChannels(
forceFresh = true,
updatePlaceNames = false
)
refreshRendezvous(forceSubscriptions = true)
courierCoordinator?.peerStateChanged()
}
startPresenceLoop()
if (_isEnabled.value) startPresenceLoop()
delay(2_000)
prekeyCoordinator?.broadcast(force = true)
broadcastPrekeys(force = true)
}
}
fun setEnabled(enabled: Boolean) {
if (outboundPolicy.get().enabled == enabled) return
if (enabled) {
panicQuiesced = false
suppressPrekeyBroadcasts = false
}
outboundPolicy.set(BridgeOutboundPolicy.capture(enabled, nearbyOnly = false))
_isEnabled.value = enabled
prefs?.edit { putBoolean(KEY_ENABLED, enabled) }
@ -223,18 +261,25 @@ object MeshBridgeService : BridgeMeshDelegate {
courierCoordinator?.setEnabled(enabled)
scope.launch {
if (!enabled) {
stopPresenceLoop()
closeSubscriptions()
queuedUplinks.clear()
pendingDownlinks.clear()
participants.clear()
publishParticipants()
localLocationCell = null
_activeCell.value = null
} else {
startPresenceLoop()
relayManager?.connect()
LocationChannelManager.getInstance(requireContext()).refreshChannels()
LocationChannelManager.getInstance(requireContext())
.refreshChannels(
forceFresh = true,
updatePlaceNames = false
)
refreshRendezvous(forceSubscriptions = true)
courierCoordinator?.peerStateChanged()
prekeyCoordinator?.broadcast(force = true)
broadcastPrekeys(force = true)
}
currentMesh()?.sendBroadcastAnnounce()
}
@ -290,7 +335,7 @@ object MeshBridgeService : BridgeMeshDelegate {
/** Called only after a public radio packet's Ed25519 signature was accepted. */
override fun handleAuthenticatedRadioMessage(messageId: String) {
if (messageId.isBlank()) return
if (panicQuiesced || messageId.isBlank()) return
scope.launch {
radioMessageIds.add(messageId)
pendingDownlinks.removeAll { pending ->
@ -300,12 +345,15 @@ object MeshBridgeService : BridgeMeshDelegate {
}
override fun handleVerifiedAnnouncement(peerId: String, announcement: IdentityAnnouncement) {
if (panicQuiesced) return
scope.launch {
val peer = VerifiedBridgePeer(
peerId = peerId,
nickname = announcement.nickname,
noiseKey = announcement.noisePublicKey.copyOf(),
signingKey = announcement.signingPublicKey.copyOf(),
isVerifiedNickname =
currentMesh()?.getPeerInfo(peerId)?.isVerifiedNickname == true,
capabilities = announcement.capabilities,
bridgeCell = announcement.bridgeGeohash?.takeIf(::isValidGeohash),
lastSeenMs = clock()
@ -318,15 +366,17 @@ object MeshBridgeService : BridgeMeshDelegate {
refreshRendezvous()
courierCoordinator?.peerStateChanged()
}
prekeyCoordinator?.broadcast()
broadcastPrekeys()
}
}
override fun handlePrekeyPacket(packet: BitchatPacket) {
if (panicQuiesced) return
scope.launch { prekeyCoordinator?.handlePacket(packet) }
}
override fun handleCarrier(payload: ByteArray, fromPeerId: String, directedToUs: Boolean) {
if (panicQuiesced) return
scope.launch {
val carrier = NostrCarrierPacket.decode(payload) ?: return@launch
when (carrier.direction) {
@ -342,8 +392,13 @@ object MeshBridgeService : BridgeMeshDelegate {
}
}
override fun handleCourierEnvelope(payload: ByteArray) {
courierCoordinator?.handleEnvelope(payload)
override fun handleCourierEnvelope(
packet: BitchatPacket,
fromPeerId: String,
directIngress: Boolean
) {
if (panicQuiesced) return
courierCoordinator?.handleEnvelope(packet, fromPeerId, directIngress)
}
suspend fun depositCourierDrop(
@ -354,12 +409,27 @@ object MeshBridgeService : BridgeMeshDelegate {
courierCoordinator?.deposit(content, messageId, recipientNoiseKey)
?: CourierDepositResult.Rejected(CourierDepositResult.Reason.BRIDGE_DISABLED)
@SuppressLint("ApplySharedPref", "UseKtx")
suspend fun wipe() {
courierCoordinator?.wipe()
// Revoke policy synchronously so no already-queued bridge work can be
// authorized by the pre-panic setting.
outboundPolicy.set(BridgeOutboundPolicy.Denied)
panicQuiesced = true
suppressPrekeyBroadcasts = true
_isEnabled.value = false
_nearbyOnly.value = false
PeerCapabilities.setBridgeEnabled(false)
courierCoordinator?.setEnabled(false)
kotlinx.coroutines.withContext(dispatcher) {
prefs?.edit()?.putBoolean(KEY_ENABLED, false)?.commit()
stopPresenceLoop()
downlinkJob?.cancel()
downlinkJob = null
closeSubscriptions()
queuedUplinks.clear()
pendingDownlinks.clear()
localLocationCell = null
_activeCell.value = null
verifiedPeers.clear()
publishVerifiedPeerSnapshot()
participants.clear()
@ -369,10 +439,15 @@ object MeshBridgeService : BridgeMeshDelegate {
rebroadcastEventIds.clear()
injectedEventIds.clear()
radioMessageIds.clear()
uplinkTimes.clear()
inboundTimes.clear()
inboundTimesBySigner.clear()
downlinkTimes.clear()
signatureAttemptTimes.clear()
prekeyCoordinator?.wipe()
_nearbyOnly.value = false
publishParticipants()
}
courierCoordinator?.wipe()
}
private fun publishVerifiedPeerSnapshot() {
@ -622,15 +697,30 @@ object MeshBridgeService : BridgeMeshDelegate {
delay(PRESENCE_INTERVAL_MS)
pruneParticipants()
if (_isEnabled.value) {
LocationChannelManager.getInstance(requireContext())
.refreshChannels(
forceFresh = true,
updatePlaceNames = false
)
refreshRendezvous()
courierCoordinator?.peerStateChanged()
publishPresence()
prekeyCoordinator?.broadcast()
broadcastPrekeys()
}
}
}
}
private fun stopPresenceLoop() {
presenceJob?.cancel()
presenceJob = null
}
private fun broadcastPrekeys(force: Boolean = false) {
if (suppressPrekeyBroadcasts) return
prekeyCoordinator?.broadcast(force)
}
private fun recordParticipant(pubkey: String, nickname: String?) {
val now = clock()
participants.entries.removeAll { now - it.value.lastSeenMs > PARTICIPANT_FRESH_MS }

View File

@ -4,8 +4,6 @@ import com.bitchat.android.mesh.MeshService
import com.bitchat.android.model.PrekeyBundle
import com.bitchat.android.protocol.BitchatPacket
import com.bitchat.android.services.ContactIdentityResolver
import org.bouncycastle.crypto.params.Ed25519PublicKeyParameters
import org.bouncycastle.crypto.signers.Ed25519Signer
/**
* Coordinates authenticated prekey packets without owning cryptographic
@ -58,20 +56,10 @@ internal class PrekeyCoordinator(
bundle: PrekeyBundle,
peer: VerifiedBridgePeer
) {
val signature = packet.signature ?: return
val signingData = packet.toBinaryDataForSigning() ?: return
if (!verifyEd25519(signature, signingData, peer.signingKey)) return
if (!BridgePacketSignatureVerifier.verify(packet, peer.signingKey)) return
manager.verifyAndIngest(bundle, peer.noiseKey, peer.signingKey, clock())
}
private fun verifyEd25519(signature: ByteArray, data: ByteArray, key: ByteArray): Boolean =
runCatching {
Ed25519Signer().apply {
init(false, Ed25519PublicKeyParameters(key, 0))
update(data, 0, data.size)
}.verifySignature(signature)
}.getOrDefault(false)
private fun ByteArray.toHex(): String = joinToString("") { "%02x".format(it) }
private companion object {

View File

@ -269,6 +269,15 @@ class ChatViewModel(
} catch (_: Exception) { }
} } catch (_: Exception) { }
}
viewModelScope.launch {
com.bitchat.android.services.bridge.CourierMessagePort.messages.collect { message ->
// The coordinator already inserted the message into AppStateStore
// after authenticating and block-checking its full Noise key.
// Reuse normal mesh ingress for unread tracking, haptics, and
// notification suppression while the conversation is focused.
meshDelegateHandler.didReceiveMessage(message)
}
}
viewModelScope.launch {
try { com.bitchat.android.services.AppStateStore.channelMessages.collect { byChannel ->
// Replace with store snapshot
@ -992,6 +1001,13 @@ class ChatViewModel(
// A pending one-shot downgrade confirmation must not survive panic or
// become actionable against the fresh post-wipe identity.
mediaSendingManager.clearPendingPrivateMediaConsent()
com.bitchat.android.services.MessageRouter.tryGetInstance()?.wipeOutbox()
// Revoke all bridge publication/courier authority before clearing
// UI, transport, or identity state.
try {
com.bitchat.android.services.bridge.MeshBridgeService.wipe()
} catch (_: Exception) { }
// Clear all UI managers
messageManager.clearAllMessages()
@ -1007,10 +1023,6 @@ class ChatViewModel(
// Clear all mesh service data
clearAllMeshServiceData()
try {
com.bitchat.android.services.bridge.MeshBridgeService.wipe()
} catch (_: Exception) { }
// Clear all cryptographic data
clearAllCryptographicData()
@ -1030,7 +1042,7 @@ class ChatViewModel(
try {
val locationManager = com.bitchat.android.geohash.LocationChannelManager.getInstance(getApplication())
locationManager.clearPersistedChannel()
locationManager.panicReset()
} catch (_: Exception) { }
geohashViewModel.panicReset()

View File

@ -13,9 +13,21 @@ class DataManager(private val context: Context) {
companion object {
private const val TAG = "DataManager"
private const val PREFS_NAME = "bitchat_prefs"
private const val BLOCKED_USERS_KEY = "blocked_users"
/**
* Reads the current persisted block list for ingress paths that have a
* full Noise key but no live mesh peer/session.
*/
fun isFingerprintBlocked(context: Context, fingerprint: String): Boolean =
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
.getStringSet(BLOCKED_USERS_KEY, emptySet())
?.contains(fingerprint) == true
}
private val prefs: SharedPreferences = context.getSharedPreferences("bitchat_prefs", Context.MODE_PRIVATE)
private val prefs: SharedPreferences =
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
private val gson = Gson()
// Channel-related maps that need to persist state
@ -198,12 +210,12 @@ class DataManager(private val context: Context) {
// MARK: - Blocked Users Management
fun loadBlockedUsers() {
val savedBlockedUsers = prefs.getStringSet("blocked_users", emptySet()) ?: emptySet()
val savedBlockedUsers = prefs.getStringSet(BLOCKED_USERS_KEY, emptySet()) ?: emptySet()
_blockedUsers.addAll(savedBlockedUsers)
}
fun saveBlockedUsers() {
prefs.edit().putStringSet("blocked_users", _blockedUsers).apply()
prefs.edit().putStringSet(BLOCKED_USERS_KEY, _blockedUsers).apply()
}
fun addBlockedUser(fingerprint: String) {

View File

@ -155,6 +155,8 @@ class MeshDelegateHandler(
override fun didReceiveDeliveryAck(messageID: String, recipientPeerID: String) {
coroutineScope.launch {
com.bitchat.android.services.MessageRouter.tryGetInstance()
?.acknowledge(messageID, recipientPeerID)
messageManager.updateMessageDeliveryStatus(messageID, DeliveryStatus.Delivered(recipientPeerID, Date()))
}
}

View File

@ -3,6 +3,8 @@ package com.bitchat.android.services
import com.bitchat.android.model.BitchatMessage
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import java.util.Date
@ -174,6 +176,20 @@ class AppStateStoreTest {
assertEquals(listOf(message), AppStateStore.privateMessages.value[contactID])
}
@Test
fun `private message insertion reports duplicates before ingress side effects`() {
val message = BitchatMessage(
id = "same-message",
sender = "alice",
content = "hello",
timestamp = Date(1)
)
assertTrue(AppStateStore.addPrivateMessage("peer", message))
assertFalse(AppStateStore.addPrivateMessage("peer", message))
assertEquals(listOf(message), AppStateStore.privateMessages.value["peer"])
}
@Test
fun `canonicalized private chat history is chronological after alias merge`() {
val noiseKeyHex = "01".repeat(32)

View File

@ -0,0 +1,76 @@
package com.bitchat.android.services.bridge
import com.bitchat.android.protocol.BitchatPacket
import com.bitchat.android.protocol.MessageType
import org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters
import org.bouncycastle.crypto.signers.Ed25519Signer
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Test
class CourierDepositAuthenticatorTest {
private val privateKey = Ed25519PrivateKeyParameters(ByteArray(32) { (it + 1).toByte() }, 0)
private val peerId = "0011223344556677"
private val peer = VerifiedBridgePeer(
peerId = peerId,
nickname = "peer",
noiseKey = ByteArray(32) { 0x22 },
signingKey = privateKey.generatePublicKey().encoded,
isVerifiedNickname = true,
capabilities = null,
bridgeCell = "u4pruy",
lastSeenMs = 1
)
@Test
fun `direct trusted announce-bound depositor is accepted`() {
assertNotNull(authenticate(signedPacket()))
}
@Test
fun `relayed or claimed-sender mismatch is rejected`() {
assertNull(authenticate(signedPacket(), directIngress = false))
assertNull(authenticate(signedPacket(), fromPeerId = "8899aabbccddeeff"))
}
@Test
fun `untrusted or tampered depositor is rejected`() {
assertNull(authenticate(signedPacket(), trusted = false))
assertNull(authenticate(signedPacket().copy(payload = "tampered".toByteArray())))
}
@Test
fun `ttl relay mutation does not invalidate canonical iOS-compatible signature`() {
assertNotNull(authenticate(signedPacket().copy(ttl = 6u)))
}
private fun authenticate(
packet: BitchatPacket,
fromPeerId: String = peerId,
directIngress: Boolean = true,
trusted: Boolean = true
): VerifiedBridgePeer? = CourierDepositAuthenticator.authenticate(
packet = packet,
fromPeerId = fromPeerId,
directIngress = directIngress,
peers = listOf(peer),
isTrusted = { trusted }
)
private fun signedPacket(): BitchatPacket {
val unsigned = BitchatPacket(
type = MessageType.COURIER_ENVELOPE.value,
senderID = peerId.chunked(2).map { it.toInt(16).toByte() }.toByteArray(),
recipientID = ByteArray(8) { 0x44 },
timestamp = 1_750_000_000_000u,
payload = "courier-envelope".toByteArray(),
ttl = 7u
)
val data = requireNotNull(unsigned.toBinaryDataForSigning())
val signature = Ed25519Signer().apply {
init(true, privateKey)
update(data, 0, data.size)
}.generateSignature()
return unsigned.copy(signature = signature)
}
}