From 1e44e94ed4c027e43f0179450c090f62388fe49a Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Mon, 27 Jul 2026 03:21:16 +0200 Subject: [PATCH] fix: harden bridge courier privacy and retries --- .../android/geohash/LocationChannelManager.kt | 95 ++++-- .../android/mesh/BluetoothMeshService.kt | 14 +- .../bitchat/android/mesh/BridgeMeshPort.kt | 10 +- .../java/com/bitchat/android/mesh/MeshCore.kt | 12 +- .../bitchat/android/mesh/PacketProcessor.kt | 10 +- .../bitchat/android/services/AppStateStore.kt | 6 +- .../bitchat/android/services/MessageRouter.kt | 302 +++++++++++++++--- .../android/services/bridge/BridgeModels.kt | 1 + .../bridge/BridgePacketSignatureVerifier.kt | 40 +++ .../services/bridge/CourierCoordinator.kt | 130 ++++++-- .../services/bridge/CourierMessagePort.kt | 26 ++ .../services/bridge/MeshBridgeService.kt | 118 ++++++- .../services/bridge/PrekeyCoordinator.kt | 14 +- .../com/bitchat/android/ui/ChatViewModel.kt | 22 +- .../com/bitchat/android/ui/DataManager.kt | 20 +- .../bitchat/android/ui/MeshDelegateHandler.kt | 2 + .../android/services/AppStateStoreTest.kt | 16 + .../bridge/CourierDepositAuthenticatorTest.kt | 76 +++++ 18 files changed, 771 insertions(+), 143 deletions(-) create mode 100644 app/src/main/java/com/bitchat/android/services/bridge/BridgePacketSignatureVerifier.kt create mode 100644 app/src/main/java/com/bitchat/android/services/bridge/CourierMessagePort.kt create mode 100644 app/src/test/kotlin/com/bitchat/android/services/bridge/CourierDepositAuthenticatorTest.kt diff --git a/app/src/main/java/com/bitchat/android/geohash/LocationChannelManager.kt b/app/src/main/java/com/bitchat/android/geohash/LocationChannelManager.kt index ea595697..4812e170 100644 --- a/app/src/main/java/com/bitchat/android/geohash/LocationChannelManager.kt +++ b/app/src/main/java/com/bitchat/android/geohash/LocationChannelManager.kt @@ -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() @@ -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 /** diff --git a/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt b/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt index d63ea952..74a71712 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt @@ -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)) } } diff --git a/app/src/main/java/com/bitchat/android/mesh/BridgeMeshPort.kt b/app/src/main/java/com/bitchat/android/mesh/BridgeMeshPort.kt index 6f4eada1..81218a5a 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BridgeMeshPort.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BridgeMeshPort.kt @@ -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) } } diff --git a/app/src/main/java/com/bitchat/android/mesh/MeshCore.kt b/app/src/main/java/com/bitchat/android/mesh/MeshCore.kt index 0d9806f8..230f796c 100644 --- a/app/src/main/java/com/bitchat/android/mesh/MeshCore.kt +++ b/app/src/main/java/com/bitchat/android/mesh/MeshCore.kt @@ -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)) } } diff --git a/app/src/main/java/com/bitchat/android/mesh/PacketProcessor.kt b/app/src/main/java/com/bitchat/android/mesh/PacketProcessor.kt index 92cffe81..95dc199f 100644 --- a/app/src/main/java/com/bitchat/android/mesh/PacketProcessor.kt +++ b/app/src/main/java/com/bitchat/android/mesh/PacketProcessor.kt @@ -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 diff --git a/app/src/main/java/com/bitchat/android/services/AppStateStore.kt b/app/src/main/java/com/bitchat/android/services/AppStateStore.kt index b3859e0e..fc1aabe4 100644 --- a/app/src/main/java/com/bitchat/android/services/AppStateStore.kt +++ b/app/src/main/java/com/bitchat/android/services/AppStateStore.kt @@ -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 diff --git a/app/src/main/java/com/bitchat/android/services/MessageRouter.kt b/app/src/main/java/com/bitchat/android/services/MessageRouter.kt index ebfb381f..1bc92a64 100644 --- a/app/src/main/java/com/bitchat/android/services/MessageRouter.kt +++ b/app/src/main/java/com/bitchat/android/services/MessageRouter.kt @@ -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>() + private val courierAttemptTimes = mutableMapOf() + private val courierAttemptsInFlight = mutableSetOf() + + 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 + } diff --git a/app/src/main/java/com/bitchat/android/services/bridge/BridgeModels.kt b/app/src/main/java/com/bitchat/android/services/bridge/BridgeModels.kt index 27cde257..60323cc5 100644 --- a/app/src/main/java/com/bitchat/android/services/bridge/BridgeModels.kt +++ b/app/src/main/java/com/bitchat/android/services/bridge/BridgeModels.kt @@ -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 diff --git a/app/src/main/java/com/bitchat/android/services/bridge/BridgePacketSignatureVerifier.kt b/app/src/main/java/com/bitchat/android/services/bridge/BridgePacketSignatureVerifier.kt new file mode 100644 index 00000000..dcc1f668 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/services/bridge/BridgePacketSignatureVerifier.kt @@ -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, + 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) } +} diff --git a/app/src/main/java/com/bitchat/android/services/bridge/CourierCoordinator.kt b/app/src/main/java/com/bitchat/android/services/bridge/CourierCoordinator.kt index 153ebaea..774f20c1 100644 --- a/app/src/main/java/com/bitchat/android/services/bridge/CourierCoordinator.kt +++ b/app/src/main/java/com/bitchat/android/services/bridge/CourierCoordinator.kt @@ -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, - 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 = mutableSetOf() ) private val appContext = context.applicationContext private val scope = CoroutineScope(SupervisorJob() + dispatcher) private val pendingDrops = mutableListOf() private val signatureAttemptTimes = mutableListOf() + private val depositTimesByPeer = mutableMapOf>() private var subscribedTags: Set = 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 = 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 } } diff --git a/app/src/main/java/com/bitchat/android/services/bridge/CourierMessagePort.kt b/app/src/main/java/com/bitchat/android/services/bridge/CourierMessagePort.kt new file mode 100644 index 00000000..9d79d48b --- /dev/null +++ b/app/src/main/java/com/bitchat/android/services/bridge/CourierMessagePort.kt @@ -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( + extraBufferCapacity = 16, + onBufferOverflow = BufferOverflow.DROP_OLDEST + ) + val messages: SharedFlow = _messages.asSharedFlow() + + internal fun deliver(message: BitchatMessage) { + _messages.tryEmit(message) + } +} diff --git a/app/src/main/java/com/bitchat/android/services/bridge/MeshBridgeService.kt b/app/src/main/java/com/bitchat/android/services/bridge/MeshBridgeService.kt index 283c0d42..2defb965 100644 --- a/app/src/main/java/com/bitchat/android/services/bridge/MeshBridgeService.kt +++ b/app/src/main/java/com/bitchat/android/services/bridge/MeshBridgeService.kt @@ -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() 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 } diff --git a/app/src/main/java/com/bitchat/android/services/bridge/PrekeyCoordinator.kt b/app/src/main/java/com/bitchat/android/services/bridge/PrekeyCoordinator.kt index 0fd48dda..8ea0ed15 100644 --- a/app/src/main/java/com/bitchat/android/services/bridge/PrekeyCoordinator.kt +++ b/app/src/main/java/com/bitchat/android/services/bridge/PrekeyCoordinator.kt @@ -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 { diff --git a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt index f859a5db..9ba109b9 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt @@ -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() diff --git a/app/src/main/java/com/bitchat/android/ui/DataManager.kt b/app/src/main/java/com/bitchat/android/ui/DataManager.kt index b338c864..44b766a0 100644 --- a/app/src/main/java/com/bitchat/android/ui/DataManager.kt +++ b/app/src/main/java/com/bitchat/android/ui/DataManager.kt @@ -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) { diff --git a/app/src/main/java/com/bitchat/android/ui/MeshDelegateHandler.kt b/app/src/main/java/com/bitchat/android/ui/MeshDelegateHandler.kt index 8d18563a..deebfab5 100644 --- a/app/src/main/java/com/bitchat/android/ui/MeshDelegateHandler.kt +++ b/app/src/main/java/com/bitchat/android/ui/MeshDelegateHandler.kt @@ -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())) } } diff --git a/app/src/test/kotlin/com/bitchat/android/services/AppStateStoreTest.kt b/app/src/test/kotlin/com/bitchat/android/services/AppStateStoreTest.kt index 75e2d50c..9d7acfda 100644 --- a/app/src/test/kotlin/com/bitchat/android/services/AppStateStoreTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/services/AppStateStoreTest.kt @@ -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) diff --git a/app/src/test/kotlin/com/bitchat/android/services/bridge/CourierDepositAuthenticatorTest.kt b/app/src/test/kotlin/com/bitchat/android/services/bridge/CourierDepositAuthenticatorTest.kt new file mode 100644 index 00000000..3ed899e2 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/services/bridge/CourierDepositAuthenticatorTest.kt @@ -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) + } +}