From 5334c08fac9868577b2fd16cce258ecfa68d3790 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Mon, 7 Sep 2026 23:30:42 +0300 Subject: [PATCH] Add durable private delivery and courier transport --- .../android/testhook/TestHookDriver.kt | 172 ++++++- .../android/crypto/EncryptionService.kt | 6 + .../android/mesh/BLEPacketPaddingPolicy.kt | 2 +- .../mesh/BluetoothConnectionManager.kt | 20 + .../android/mesh/BluetoothMeshService.kt | 258 +++++++++- .../mesh/BluetoothPacketBroadcaster.kt | 95 +++- .../com/bitchat/android/mesh/CourierStore.kt | 241 +++++++++ .../mesh/DirectCourierDepositPolicy.kt | 20 + .../android/mesh/FragmentingPacketSender.kt | 17 + .../java/com/bitchat/android/mesh/MeshCore.kt | 4 +- .../com/bitchat/android/mesh/MeshService.kt | 13 + .../bitchat/android/mesh/MessageHandler.kt | 32 ++ .../bitchat/android/mesh/PacketProcessor.kt | 18 + .../bitchat/android/mesh/SecurityManager.kt | 1 + .../android/mesh/UnifiedMeshService.kt | 16 + .../bitchat/android/model/BitchatMessage.kt | 5 +- .../bitchat/android/model/CourierEnvelope.kt | 115 +++++ .../android/model/RequestSyncPacket.kt | 29 +- .../android/noise/NoiseEncryptionService.kt | 46 +- .../android/nostr/BridgeCourierService.kt | 234 +++++++++ .../nostr/NostrDirectMessageHandler.kt | 2 + .../com/bitchat/android/nostr/NostrEvent.kt | 1 + .../android/nostr/NostrRelayManager.kt | 34 +- .../bitchat/android/nostr/NostrTransport.kt | 5 + .../android/protocol/BinaryProtocol.kt | 1 + .../android/service/TransportBridgeService.kt | 16 + .../bitchat/android/services/AppStateStore.kt | 9 +- .../android/services/ContactDirectory.kt | 15 + .../services/ConversationRepository.kt | 11 +- .../android/services/MessageOutboxStore.kt | 73 +++ .../bitchat/android/services/MessageRouter.kt | 174 ++++++- .../com/bitchat/android/sync/GCSFilter.kt | 13 +- .../bitchat/android/sync/GossipSyncManager.kt | 256 +++++++--- .../com/bitchat/android/sync/SyncTypeFlags.kt | 68 +++ .../com/bitchat/android/ui/ChatViewModel.kt | 7 +- .../com/bitchat/android/ui/MessageManager.kt | 9 +- .../com/bitchat/android/util/AppConstants.kt | 1 + .../wifi-aware/WifiAwareMeshService.kt | 3 + .../android/crypto/NoiseCourierTest.kt | 37 ++ .../mesh/FragmentingPacketSenderTest.kt | 19 + .../ClientRewriteWireContractTest.kt | 12 +- ...luetoothPacketBroadcasterCompletionTest.kt | 110 +++++ .../bitchat/android/mesh/CourierStoreTest.kt | 148 ++++++ .../mesh/DirectCourierDepositPolicyTest.kt | 32 ++ .../android/mesh/MessageHandlerTest.kt | 45 ++ .../PacketProcessorAnnounceSideEffectTest.kt | 38 +- .../android/model/CourierEnvelopeTest.kt | 111 +++++ .../android/nostr/BridgeCourierServiceTest.kt | 240 +++++++++ .../android/services/ContactDirectoryTest.kt | 13 + .../services/MessageOutboxStoreTest.kt | 54 ++ .../android/services/MessageRouterTest.kt | 166 ++++++- .../com/bitchat/android/sync/GCSFilterTest.kt | 3 +- .../android/sync/GossipSyncManagerTest.kt | 223 +++++++++ .../bitchat/android/sync/SyncTypeFlagsTest.kt | 36 ++ docs/client-rewrite-contracts.md | 7 +- docs/release-gate-runbook.md | 19 +- docs/sync.md | 32 +- tools/release_gate/mesh_lab.py | 460 +++++++++++++++++- 58 files changed, 3645 insertions(+), 202 deletions(-) create mode 100644 app/src/main/java/com/bitchat/android/mesh/CourierStore.kt create mode 100644 app/src/main/java/com/bitchat/android/mesh/DirectCourierDepositPolicy.kt create mode 100644 app/src/main/java/com/bitchat/android/model/CourierEnvelope.kt create mode 100644 app/src/main/java/com/bitchat/android/nostr/BridgeCourierService.kt create mode 100644 app/src/main/java/com/bitchat/android/services/MessageOutboxStore.kt create mode 100644 app/src/main/java/com/bitchat/android/sync/SyncTypeFlags.kt create mode 100644 app/src/test/java/com/bitchat/android/crypto/NoiseCourierTest.kt create mode 100644 app/src/test/kotlin/com/bitchat/android/mesh/BluetoothPacketBroadcasterCompletionTest.kt create mode 100644 app/src/test/kotlin/com/bitchat/android/mesh/CourierStoreTest.kt create mode 100644 app/src/test/kotlin/com/bitchat/android/mesh/DirectCourierDepositPolicyTest.kt create mode 100644 app/src/test/kotlin/com/bitchat/android/model/CourierEnvelopeTest.kt create mode 100644 app/src/test/kotlin/com/bitchat/android/nostr/BridgeCourierServiceTest.kt create mode 100644 app/src/test/kotlin/com/bitchat/android/services/MessageOutboxStoreTest.kt create mode 100644 app/src/test/kotlin/com/bitchat/android/sync/GossipSyncManagerTest.kt create mode 100644 app/src/test/kotlin/com/bitchat/android/sync/SyncTypeFlagsTest.kt diff --git a/app/src/debug/java/com/bitchat/android/testhook/TestHookDriver.kt b/app/src/debug/java/com/bitchat/android/testhook/TestHookDriver.kt index 7379b5ef..4bdc2701 100644 --- a/app/src/debug/java/com/bitchat/android/testhook/TestHookDriver.kt +++ b/app/src/debug/java/com/bitchat/android/testhook/TestHookDriver.kt @@ -1,6 +1,7 @@ package com.bitchat.android.testhook import android.content.Context +import android.content.ContextWrapper import android.content.Intent import android.util.Log import com.bitchat.android.favorites.FavoritesPersistenceService @@ -15,7 +16,10 @@ import com.bitchat.android.identity.SecureIdentityStateManager import com.bitchat.android.mesh.MeshService import com.bitchat.android.mesh.PrivateMediaPreparation import com.bitchat.android.mesh.TransferProgressManager +import com.bitchat.android.mesh.CourierDepositTier +import com.bitchat.android.mesh.CourierStore import com.bitchat.android.model.BitchatFilePacket +import com.bitchat.android.model.CourierEnvelope import com.bitchat.android.model.RoutedPacket import com.bitchat.android.noise.NoiseSession import com.bitchat.android.protocol.BitchatPacket @@ -23,8 +27,11 @@ import com.bitchat.android.service.MeshForegroundService import com.bitchat.android.service.MeshServiceHolder import com.bitchat.android.service.TransportBridgeService import com.bitchat.android.services.AppStateStore +import com.bitchat.android.services.ConversationStorageCipher +import com.bitchat.android.services.MessageRouter import com.bitchat.android.ui.DataManager import com.bitchat.android.ui.PrivateMediaRecipientResolver +import com.bitchat.android.ui.debug.DebugSettingsManager import com.bitchat.android.util.AppConstants import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async @@ -69,6 +76,13 @@ object TestHookDriver { "announce" -> announce(context) "broadcast_msg" -> broadcastMsg(context, intent.requiredString("content"), intent.getStringExtra("channel")) "dm_send" -> dmSend(context, intent.requiredString("peer"), intent.requiredString("content"), intent.getStringExtra("msg_id")) + "router_private_send" -> routerPrivateSend( + context, + intent.requiredString("peer"), + intent.requiredString("content"), + intent.getStringExtra("msg_id") + ) + "router_resume" -> routerResume(context) "dm_recv" -> dmRecv(context, intent) "msg_recv" -> msgRecv(context, intent) "favorite_set" -> favoriteSet( @@ -89,7 +103,11 @@ object TestHookDriver { "ptt_send" -> pttSend(context, intent) "ptt_recv" -> pttRecv(context, intent) "raw_send" -> rawSend(context, intent) + "courier_contract" -> courierContract(context) + "cache_peer_identity" -> cachePeerIdentity(context, intent.requiredString("peer")) + "sync_request" -> syncRequest(intent.requiredString("peer")) "ble" -> setBle(intent.getBooleanExtra("enabled", true)) + "wifi_aware" -> setWifiAware(intent.getBooleanExtra("enabled", true)) "inject_peers" -> injectPeers(intent.getStringExtra("peers")) "state" -> state(context) "clear_results" -> clearResults(context) @@ -243,6 +261,27 @@ object TestHookDriver { return ok("dm_send").put("peer", peerID).put("msg_id", id) } + /** + * Drives the same durable outbox/router path used by private-chat sends instead of the + * lower-level direct mesh API that backs `dm_send`. + */ + private fun routerPrivateSend(context: Context, peerID: String, content: String, msgID: String?): JSONObject { + val mesh = mesh(context) + val nickname = mesh.getPeerNicknames()[peerID] ?: peerID + val id = msgID ?: "testhook-router-${System.currentTimeMillis()}" + val route = MessageRouter.getInstance(context, mesh).sendPrivate(content, peerID, nickname, id) + return ok("router_private_send") + .put("peer", peerID) + .put("msg_id", id) + .put("route", route.name) + } + + /** Recreates the durable router after a process restart without adding another message. */ + private fun routerResume(context: Context): JSONObject { + MessageRouter.getInstance(context, mesh(context)) + return ok("router_resume") + } + private suspend fun dmRecv(context: Context, intent: Intent): JSONObject { val timeoutMs = intent.getLongExtra("timeout_ms", DEFAULT_RECV_TIMEOUT_MS) val fromPeer = intent.getStringExtra("peer") @@ -276,10 +315,11 @@ object TestHookDriver { val timeoutMs = intent.getLongExtra("timeout_ms", DEFAULT_RECV_TIMEOUT_MS) val contains = intent.getStringExtra("contains") val channel = intent.getStringExtra("channel") + val includeExisting = intent.getBooleanExtra("include_existing", false) val startTime = System.currentTimeMillis() val mesh = mesh(context) val matches: (com.bitchat.android.model.BitchatMessage) -> Boolean = { msg -> - msg.timestamp.time >= startTime && + (includeExisting || msg.timestamp.time >= startTime) && msg.senderPeerID != mesh.myPeerID && (contains == null || msg.content.contains(contains)) && (channel == null || msg.channel == channel) @@ -653,6 +693,128 @@ object TestHookDriver { .put("peer", peerID) } + /** + * Exercises the real courier wire/store implementation on a physical debug build. + * This is intentionally local: the two-phone harness has no third identity to act as + * both recipient and an independent courier, so transport scenarios cannot observe the + * spray budget without weakening the assertion. + */ + private fun courierContract(context: Context): JSONObject { + val filesDir = File(context.cacheDir, "testhook/courier-contract-files") + filesDir.deleteRecursively() + filesDir.mkdirs() + val labContext = LabStorageContext(context, filesDir) + val cipher = LabCipher(0x5a) + val now = System.currentTimeMillis() + val recipientKey = ByteArray(32) { 7 } + val depositorKey = ByteArray(32) { 8 } + val firstCourier = ByteArray(32) { 11 } + val secondCourier = ByteArray(32) { 12 } + val thirdCourier = ByteArray(32) { 13 } + val fourthCourier = ByteArray(32) { 14 } + val tag = CourierEnvelope.recipientTag(recipientKey, CourierEnvelope.epochDay(now)) + val store = CourierStore(labContext, cipher) { now } + try { + val firstEnvelope = CourierEnvelope( + recipientTag = tag, + expiry = (now + CourierEnvelope.MAX_LIFETIME_MS).toULong(), + ciphertext = ByteArray(32) { (it + 1).toByte() }, + copies = 4u, + prekeyID = 0x11223344u + ) + require(store.deposit(firstEnvelope, depositorKey, CourierDepositTier.VERIFIED)) + val wire = requireNotNull(firstEnvelope.encode()) + val decoded = requireNotNull(CourierEnvelope.decode(wire)) + val first = store.sprayCopiesFor(firstCourier).single() + val second = store.sprayCopiesFor(secondCourier).single() + val sameCourierEmpty = store.sprayCopiesFor(firstCourier).isEmpty() + val secondCommitted = store.commitSpray(second, secondCourier) + val firstCommitted = store.commitSpray(first, firstCourier) + val thirdCourierEmpty = store.sprayCopiesFor(thirdCourier).isEmpty() + + val secondEnvelope = firstEnvelope.copy(ciphertext = ByteArray(32) { (it + 65).toByte() }) + require(store.deposit(secondEnvelope, depositorKey, CourierDepositTier.VERIFIED)) + val cancelledPreview = store.sprayCopiesFor(fourthCourier).single() + val cancelled = store.cancelSpray(cancelledPreview, fourthCourier) + val retry = store.sprayCopiesFor(fourthCourier).single() + val retryCommitted = store.commitSpray(retry, fourthCourier) + + val reloaded = CourierStore(labContext, LabCipher(0x5a)) { now } + val persistedSprayHistory = reloaded.sprayCopiesFor(fourthCourier) + .none { it.ciphertext.contentEquals(secondEnvelope.ciphertext) } + val remainingCopies = reloaded.sprayCopiesFor(thirdCourier) + .firstOrNull { it.ciphertext.contentEquals(secondEnvelope.ciphertext) } + ?.copies + ?.toInt() + reloaded.wipe() + + require(decoded.prekeyID == firstEnvelope.prekeyID) + require(decoded.encode()?.contentEquals(wire) == true) + require(first.prekeyID == firstEnvelope.prekeyID) + require(first.copies == 2u.toUByte()) + require(second.copies == 1u.toUByte()) + require(sameCourierEmpty) + require(secondCommitted && firstCommitted && thirdCourierEmpty) + require(cancelled && retry.copies == 2u.toUByte() && retryCommitted) + require(persistedSprayHistory && remainingCopies == 1) + + return ok("courier_contract") + .put("wire_prekey_id_preserved", true) + .put("stored_prekey_id_preserved", true) + .put("first_reserved_copies", first.copies.toInt()) + .put("second_reserved_copies", second.copies.toInt()) + .put("same_courier_second_reservation_empty", sameCourierEmpty) + .put("reverse_order_commits", true) + .put("cancel_restored_eligibility", cancelled) + .put("persisted_spray_history", persistedSprayHistory) + .put("remaining_copies_after_restart", remainingCopies) + } finally { + store.wipe() + filesDir.deleteRecursively() + } + } + + /** Persist the currently authenticated peer key exactly as the normal UI session observer does. */ + private fun cachePeerIdentity(context: Context, peerID: String): JSONObject { + val info = mesh(context).getPeerInfo(peerID) + ?: return err("cache_peer_identity", "peer is not known") + val noiseKey = info.noisePublicKey + ?: return err("cache_peer_identity", "peer Noise key is unavailable") + val noiseKeyHex = noiseKey.toHex() + val identityManager = SecureIdentityStateManager(context) + identityManager.cachePeerNoiseKey(peerID, noiseKeyHex) + identityManager.cacheNoiseFingerprint(noiseKeyHex, com.bitchat.android.services.ContactIdentityResolver.fingerprintHex(noiseKey)) + info.nickname.takeIf { it.isNotBlank() }?.let { nickname -> + identityManager.cacheFingerprintNickname( + com.bitchat.android.services.ContactIdentityResolver.fingerprintHex(noiseKey), + nickname + ) + } + return ok("cache_peer_identity").put("peer", peerID) + } + + private fun syncRequest(peerID: String): JSONObject { + val manager = MeshServiceHolder.sharedGossipSyncManager + ?: return err("sync_request", "gossip sync manager is unavailable") + manager.scheduleInitialSyncToPeer(peerID, 0) + return ok("sync_request").put("peer", peerID) + } + + private class LabStorageContext(base: Context, private val labFilesDir: File) : ContextWrapper(base) { + override fun getApplicationContext(): Context = this + override fun getFilesDir(): File = labFilesDir + } + + private class LabCipher(private val mask: Int) : ConversationStorageCipher { + override fun encrypt(plaintext: ByteArray, associatedData: ByteArray): ByteArray = + plaintext.map { (it.toInt() xor mask).toByte() }.toByteArray() + + override fun decrypt(envelope: ByteArray, associatedData: ByteArray): ByteArray = + encrypt(envelope, associatedData) + + override fun destroyKey() = Unit + } + // MARK: - Transport / state private fun setBle(enabled: Boolean): JSONObject { @@ -661,6 +823,14 @@ object TestHookDriver { return ok("ble").put("enabled", enabled) } + private fun setWifiAware(enabled: Boolean): JSONObject { + val previous = com.bitchat.android.wifiaware.WifiAwareController.enabled.value + DebugSettingsManager.getInstance().setWifiAwareEnabled(enabled) + return ok("wifi_aware") + .put("enabled", enabled) + .put("previous_enabled", previous) + } + private fun state(context: Context): JSONObject { val mesh = mesh(context) val peersJson = peerInfosJson(mesh, AppStateStore.peers.value) diff --git a/app/src/main/java/com/bitchat/android/crypto/EncryptionService.kt b/app/src/main/java/com/bitchat/android/crypto/EncryptionService.kt index b15a6254..8f078a3d 100644 --- a/app/src/main/java/com/bitchat/android/crypto/EncryptionService.kt +++ b/app/src/main/java/com/bitchat/android/crypto/EncryptionService.kt @@ -112,6 +112,12 @@ open class EncryptionService(private val context: Context) { fun getStaticPublicKey(): ByteArray? { return noiseService.getStaticPublicKeyData() } + + fun sealCourierPayload(payload: ByteArray, recipientStaticKey: ByteArray): ByteArray = + noiseService.sealCourierPayload(payload, recipientStaticKey) + + fun openCourierPayload(ciphertext: ByteArray): Pair = + noiseService.openCourierPayload(ciphertext) /** * Get our signing public key for Ed25519 signatures (for identity announcements) diff --git a/app/src/main/java/com/bitchat/android/mesh/BLEPacketPaddingPolicy.kt b/app/src/main/java/com/bitchat/android/mesh/BLEPacketPaddingPolicy.kt index 279fe527..d9276151 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BLEPacketPaddingPolicy.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BLEPacketPaddingPolicy.kt @@ -11,7 +11,7 @@ import com.bitchat.android.protocol.MessageType object BLEPacketPaddingPolicy { fun shouldPadForBLE(type: UByte): Boolean { return when (MessageType.fromValue(type)) { - MessageType.NOISE_ENCRYPTED, MessageType.NOISE_HANDSHAKE -> true + MessageType.NOISE_ENCRYPTED, MessageType.NOISE_HANDSHAKE, MessageType.COURIER_ENVELOPE -> true else -> false } } diff --git a/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionManager.kt b/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionManager.kt index 0e4f29af..81e80b68 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionManager.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionManager.kt @@ -367,6 +367,26 @@ class BluetoothConnectionManager( ) } + suspend fun sendToPeerAndAwaitAcceptance(peerID: String, routed: RoutedPacket): Boolean { + if (!isActive || !isBleTransportEnabled()) return false + return packetBroadcaster.sendPacketToPeerAndAwaitAcceptance( + routed, + peerID, + serverManager.getGattServer(), + serverManager.getCharacteristic() + ) + } + + suspend fun sendToPeerAndAwaitCompletion(peerID: String, routed: RoutedPacket): Boolean { + if (!isActive || !isBleTransportEnabled()) return false + return packetBroadcaster.sendPacketToPeerAndAwaitCompletion( + routed, + peerID, + serverManager.getGattServer(), + serverManager.getCharacteristic() + ) + } + fun cancelTransfer(transferId: String): Boolean { return packetBroadcaster.cancelTransfer(transferId) } 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 22f17867..07f46d44 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt @@ -49,6 +49,12 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic // Core components - each handling specific responsibilities private val encryptionService = EncryptionService(context) + private val courierStore = CourierStore(context) + private val bridgeCourierService by lazy { + com.bitchat.android.nostr.BridgeCourierService(context, encryptionService) { envelope -> + handleLocalCourierEnvelope(envelope) + } + } // My peer identification - derived from persisted Noise identity fingerprint (first 16 hex chars) val myPeerID: String = encryptionService.getIdentityFingerprint().take(16) @@ -156,10 +162,11 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic gossipSyncManager = GossipSyncManager( myPeerID = myPeerID, scope = serviceScope, + context = context, configProvider = object : GossipSyncManager.ConfigProvider { override fun seenCapacity(): Int = try { - com.bitchat.android.ui.debug.DebugPreferenceManager.getSeenPacketCapacity(500) - } catch (_: Exception) { 500 } + com.bitchat.android.ui.debug.DebugPreferenceManager.getSeenPacketCapacity(1000) + } catch (_: Exception) { 1000 } override fun gcsMaxBytes(): Int = try { com.bitchat.android.ui.debug.DebugPreferenceManager.getGcsMaxFilterBytes(400) @@ -177,6 +184,7 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic if (isBleTransportEnabled()) { TransportBridgeService.register("BLE", this) } + bridgeCourierService.start() // Inject dynamic direct connection check into PeerManager // Matches iOS logic: checks if we have an active hardware mapping for this peer @@ -200,6 +208,11 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic connectionManager.sendPacketToPeer(peerID, packet) } + override fun sendToPeerAndReport(peerID: String, packet: BitchatPacket): Boolean { + if (!isBleTransportEnabled()) return false + return connectionManager.sendPacketToPeer(peerID, packet) + } + private fun broadcastRoutedPacket(routed: RoutedPacket): Boolean { if (!isBleTransportEnabled()) return false val queued = connectionManager.broadcastPacket(routed) @@ -514,6 +527,7 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic } override fun onDeliveryAckReceived(messageID: String, peerID: String) { + try { com.bitchat.android.services.MessageRouter.tryGetInstance()?.onMessageAcknowledged(messageID, peerID) } catch (_: Exception) { } // Status events can arrive while MainActivity has detached the UI delegate. // Persist first so the next UI collector observes the advancement. try { @@ -526,6 +540,7 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic } override fun onReadReceiptReceived(messageID: String, peerID: String) { + try { com.bitchat.android.services.MessageRouter.tryGetInstance()?.onMessageAcknowledged(messageID, peerID) } catch (_: Exception) { } try { com.bitchat.android.services.AppStateStore.updatePrivateMessageStatus( messageID, @@ -574,6 +589,10 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic override fun handleNoiseEncrypted(routed: RoutedPacket): Boolean { return runBlocking { messageHandler.handleNoiseEncrypted(routed) } } + + override fun handleCourierEnvelope(routed: RoutedPacket): Boolean { + return handleCourierEnvelopePacket(routed) + } override suspend fun handleAnnounce(routed: RoutedPacket): Boolean { val result = messageHandler.handleAnnounceWithResult(routed) @@ -599,6 +618,7 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic } } try { gossipSyncManager.onPublicPacketSeen(routed.packet) } catch (_: Exception) { } + handleCourierAnnounce(routed) return true } @@ -608,7 +628,7 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic try { val pkt = routed.packet val isBroadcast = (pkt.recipientID == null || pkt.recipientID.contentEquals(SpecialRecipients.BROADCAST)) - if (isBroadcast && pkt.type == MessageType.MESSAGE.value) { + if (isBroadcast && pkt.type in setOf(MessageType.MESSAGE.value, MessageType.FILE_TRANSFER.value)) { gossipSyncManager.onPublicPacketSeen(pkt) } } catch (_: Exception) { } @@ -809,6 +829,7 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic * Stop all mesh services */ fun stopServices() { + if (bridgeCourierService.isStarted) bridgeCourierService.stop() if (!isActive) { Log.w(TAG, "Mesh service not active, ignoring stop request") return @@ -1442,6 +1463,209 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic return peerManager.getPeerInfo(peerID) } + fun getPeerInfos(): List = peerManager.getAllPeerNicknames().keys.mapNotNull(peerManager::getPeerInfo) + + fun sendCourierMessage( + content: String, + messageID: String, + recipientNoiseKey: ByteArray, + courierPeerIDs: List + ): List { + val privateMessage = com.bitchat.android.model.PrivateMessagePacket(messageID, content).encode() ?: return emptyList() + val typedPayload = com.bitchat.android.model.NoisePayload( + com.bitchat.android.model.NoisePayloadType.PRIVATE_MESSAGE, + privateMessage + ).encode() + val sealed = try { encryptionService.sealCourierPayload(typedPayload, recipientNoiseKey) } catch (_: Exception) { return emptyList() } + val now = System.currentTimeMillis() + val couriers = courierPeerIDs.distinct().take(4) + if (couriers.isEmpty()) return emptyList() + return couriers.filter { courierID -> + val envelope = com.bitchat.android.model.CourierEnvelope( + recipientTag = com.bitchat.android.model.CourierEnvelope.recipientTag( + recipientNoiseKey, + com.bitchat.android.model.CourierEnvelope.epochDay(now) + ), + expiry = (now + com.bitchat.android.model.CourierEnvelope.MAX_LIFETIME_MS).toULong(), + ciphertext = sealed, + copies = 4u + ) + val payload = envelope.encode() ?: return@filter false + val packet = BitchatPacket( + type = MessageType.COURIER_ENVELOPE.value, + senderID = hexStringToByteArray(myPeerID), + recipientID = hexStringToByteArray(courierID), + timestamp = now.toULong(), + payload = payload, + ttl = MAX_TTL + ) + TransportBridgeService.sendToPeerFromLocalAndReport(courierID, signPacketBeforeBroadcast(packet)) + } + } + + fun sendBridgeCourierMessage( + content: String, + messageID: String, + recipientNoiseKey: ByteArray, + onAccepted: () -> Unit = {} + ): Boolean = bridgeCourierService.deposit(content, messageID, recipientNoiseKey, onAccepted) + + private fun handleLocalCourierEnvelope(envelope: com.bitchat.android.model.CourierEnvelope) { + val packet = BitchatPacket( + type = MessageType.COURIER_ENVELOPE.value, + senderID = ByteArray(8), + recipientID = hexStringToByteArray(myPeerID), + timestamp = System.currentTimeMillis().toULong(), + payload = envelope.encode() ?: return, + ttl = MAX_TTL + ) + handleCourierEnvelopePacket(RoutedPacket(packet, peerID = "bridge")) + } + + private fun handleCourierEnvelopePacket(routed: RoutedPacket): Boolean { + val envelope = com.bitchat.android.model.CourierEnvelope.decode(routed.packet.payload) ?: return false + val now = System.currentTimeMillis() + if (envelope.expiry.toLong() <= now) return false + val localKey = encryptionService.getStaticPublicKey() ?: return false + if (envelope.matchesRecipient(localKey, now)) { + val (senderKey, typedPayload) = try { encryptionService.openCourierPayload(envelope.ciphertext) } catch (_: Exception) { return false } + val noisePayload = com.bitchat.android.model.NoisePayload.decode(typedPayload) ?: return false + if (noisePayload.type !in setOf( + com.bitchat.android.model.NoisePayloadType.PRIVATE_MESSAGE, + com.bitchat.android.model.NoisePayloadType.DELIVERED + ) + ) return false + val senderPeerID = com.bitchat.android.services.ContactIdentityResolver.peerIdForNoiseKey(senderKey) + val synthetic = routed.copy( + packet = routed.packet.copy( + type = MessageType.NOISE_ENCRYPTED.value, + senderID = hexStringToByteArray(senderPeerID), + payload = typedPayload, + timestamp = System.currentTimeMillis().toULong() + ), + peerID = senderPeerID + ) + val delivered = runBlocking { messageHandler.handleOpenedCourierPayload(synthetic) } + if (delivered && noisePayload.type == com.bitchat.android.model.NoisePayloadType.PRIVATE_MESSAGE) { + val messageID = com.bitchat.android.model.PrivateMessagePacket.decode(noisePayload.data)?.messageID + if (messageID != null) sendCourierDeliveryAck(messageID, senderKey, routed) + } + return delivered + } + val peerID = routed.peerID ?: return false + if (!DirectCourierDepositPolicy.accepts( + routed, + MAX_TTL, + connectionManager::getCurrentLinkID, + connectionManager.addressPeerMap::get + ) + ) return false + val depositor = peerManager.getPeerInfo(peerID) ?: return false + val key = depositor.noisePublicKey ?: return false + val favorite = try { + com.bitchat.android.favorites.FavoritesPersistenceService.shared.getFavoriteStatus(key)?.isMutual == true + } catch (_: Exception) { false } + val tier = if (favorite) CourierDepositTier.FAVORITE + else if (depositor.hasVerifiedAnnouncement) CourierDepositTier.VERIFIED + else return false + return courierStore.deposit(envelope, key, tier) + } + + private fun sendCourierDeliveryAck(messageID: String, senderNoiseKey: ByteArray, ingress: RoutedPacket) { + val typedPayload = com.bitchat.android.model.NoisePayload( + com.bitchat.android.model.NoisePayloadType.DELIVERED, + messageID.toByteArray(Charsets.UTF_8) + ).encode() + if (ingress.peerID == "bridge") { + bridgeCourierService.depositPayload(typedPayload, senderNoiseKey) + return + } + val courierPeerID = ingress.peerID ?: return + val now = System.currentTimeMillis() + val sealed = try { encryptionService.sealCourierPayload(typedPayload, senderNoiseKey) } catch (_: Exception) { return } + val envelope = com.bitchat.android.model.CourierEnvelope( + recipientTag = com.bitchat.android.model.CourierEnvelope.recipientTag( + senderNoiseKey, + com.bitchat.android.model.CourierEnvelope.epochDay(now) + ), + expiry = (now + com.bitchat.android.model.CourierEnvelope.MAX_LIFETIME_MS).toULong(), + ciphertext = sealed, + copies = 4u + ) + val packet = BitchatPacket( + type = MessageType.COURIER_ENVELOPE.value, + senderID = hexStringToByteArray(myPeerID), + recipientID = hexStringToByteArray(courierPeerID), + timestamp = now.toULong(), + payload = envelope.encode() ?: return, + ttl = MAX_TTL + ) + TransportBridgeService.sendToPeerFromLocalAndReport(courierPeerID, signPacketBeforeBroadcast(packet)) + } + + private fun handleCourierAnnounce(routed: RoutedPacket) { + val peerID = routed.peerID ?: return + val info = peerManager.getPeerInfo(peerID) ?: return + val noiseKey = info.noisePublicKey ?: return + val direct = routed.packet.ttl >= MAX_TTL + val envelopes = if (direct) courierStore.copiesForRecipient(noiseKey) else courierStore.copiesForRemoteHandover(noiseKey) + envelopes.forEach { envelope -> + val packet = BitchatPacket( + type = MessageType.COURIER_ENVELOPE.value, + senderID = hexStringToByteArray(myPeerID), + recipientID = hexStringToByteArray(peerID), + timestamp = System.currentTimeMillis().toULong(), + payload = envelope.encode() ?: return@forEach, + ttl = MAX_TTL + ) + if (direct) { + serviceScope.launch { + if (connectionManager.sendToPeerAndAwaitCompletion( + peerID, + RoutedPacket(signPacketBeforeBroadcast(packet)) + ) + ) { + courierStore.remove(envelope) + } + } + } else TransportBridgeService.broadcastFromLocal(RoutedPacket(signPacketBeforeBroadcast(packet))) + } + if (direct) { + courierStore.sprayCopiesFor(noiseKey).forEach { envelope -> + val payload = envelope.encode() + if (payload == null) { + courierStore.cancelSpray(envelope, noiseKey) + return@forEach + } + val packet = BitchatPacket( + type = MessageType.COURIER_ENVELOPE.value, + senderID = hexStringToByteArray(myPeerID), + recipientID = hexStringToByteArray(peerID), + timestamp = System.currentTimeMillis().toULong(), + payload = payload, + ttl = MAX_TTL + ) + serviceScope.launch { + var committed = false + try { + if (connectionManager.sendToPeerAndAwaitCompletion( + peerID, + RoutedPacket(signPacketBeforeBroadcast(packet)) + ) + ) { + committed = courierStore.commitSpray(envelope, noiseKey) + } + } finally { + if (!committed) courierStore.cancelSpray(envelope, noiseKey) + } + }.invokeOnCompletion { + // A coroutine cancelled before its body starts never reaches finally. + courierStore.cancelSpray(envelope, noiseKey) + } + } + } + } + /** * Update peer information with verification data */ @@ -1620,19 +1844,21 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic */ fun clearAllInternalData() { Log.w(TAG, "Clearing all mesh service internal data") - try { - // Stop services to cease broadcasting old ID immediately - stopServices() - - // Clear all managers - fragmentManager.clearAllFragments() - storeForwardManager.clearAllCache() - securityManager.clearAllData() - peerManager.clearAllPeers() - peerManager.clearAllFingerprints() - try { gossipSyncManager.clear() } catch (_: Exception) { } - } catch (e: Exception) { - Log.e(TAG, "Error clearing mesh service internal data: ${e.message}") + val operations = listOf<() -> Unit>( + ::stopServices, + fragmentManager::clearAllFragments, + storeForwardManager::clearAllCache, + securityManager::clearAllData, + peerManager::clearAllPeers, + peerManager::clearAllFingerprints, + courierStore::wipe, + bridgeCourierService::stop, + gossipSyncManager::clear + ) + operations.forEach { operation -> + try { operation() } catch (e: Exception) { + Log.e(TAG, "Error clearing mesh service internal data: ${e.message}") + } } } diff --git a/app/src/main/java/com/bitchat/android/mesh/BluetoothPacketBroadcaster.kt b/app/src/main/java/com/bitchat/android/mesh/BluetoothPacketBroadcaster.kt index 9ff9856d..a8fb804c 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothPacketBroadcaster.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothPacketBroadcaster.kt @@ -22,6 +22,7 @@ import kotlinx.coroutines.cancel import kotlinx.coroutines.delay import kotlinx.coroutines.isActive import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeoutOrNull import kotlinx.coroutines.channels.actor import java.util.ArrayDeque @@ -55,6 +56,7 @@ class BluetoothPacketBroadcaster( private const val MAX_PENDING_BYTES_PER_LINK = 1_048_576 private const val SEND_RETRY_DELAY_MS = 15L private const val MAX_CALLBACK_RETRIES = 3 + private const val SEND_COMPLETION_TIMEOUT_MS = 30_000L } // Optional nickname resolver injected by higher layer (peerID -> nickname?) @@ -140,6 +142,7 @@ class BluetoothPacketBroadcaster( val gatt: BluetoothGatt? = null, val gattServer: BluetoothGattServer? = null, val characteristic: BluetoothGattCharacteristic, + val completion: CompletableDeferred? = null, var callbackFailures: Int = 0 ) @@ -204,6 +207,30 @@ class BluetoothPacketBroadcaster( } } + suspend fun sendPacketToPeerAndAwaitAcceptance( + routed: RoutedPacket, + targetPeerID: String, + gattServer: BluetoothGattServer?, + characteristic: BluetoothGattCharacteristic? + ): Boolean { + if (!hasPeerConnection(targetPeerID)) return false + return fragmentingSender.sendAndAwaitAcceptance(routed, "BLE peer ${targetPeerID.take(8)}") { packet -> + sendSinglePacketToPeer(packet, targetPeerID, gattServer, characteristic) + } + } + + suspend fun sendPacketToPeerAndAwaitCompletion( + routed: RoutedPacket, + targetPeerID: String, + gattServer: BluetoothGattServer?, + characteristic: BluetoothGattCharacteristic? + ): Boolean { + if (!hasPeerConnection(targetPeerID)) return false + return fragmentingSender.sendAndAwaitAcceptance(routed, "BLE peer ${targetPeerID.take(8)}") { packet -> + sendSinglePacketToPeerAndAwaitCompletion(packet, targetPeerID, gattServer, characteristic) + } + } + fun sendPacketToLink( routed: RoutedPacket, deviceAddress: String, @@ -267,6 +294,35 @@ class BluetoothPacketBroadcaster( return false } + private suspend fun sendSinglePacketToPeerAndAwaitCompletion( + routed: RoutedPacket, + targetPeerID: String, + gattServer: BluetoothGattServer?, + characteristic: BluetoothGattCharacteristic? + ): Boolean { + val packet = routed.packet + val data = packet.toBinaryData( + padding = BLEPacketPaddingPolicy.shouldPadForBLE(packet.type) + ) ?: return false + val completion = CompletableDeferred() + + val serverTarget = connectionTracker.getSubscribedDevices() + .firstOrNull { connectionTracker.addressPeerMap[it.address] == targetPeerID } + val queuedOnServer = serverTarget != null && + notifyDevice(serverTarget, data, gattServer, characteristic, completion) + val queued = if (queuedOnServer) { + true + } else { + val clientTarget = connectionTracker.getConnectedDevices().values + .firstOrNull { connectionTracker.addressPeerMap[it.device.address] == targetPeerID } + ?: return false + writeToDeviceConn(clientTarget, data, completion) + } + if (!queued) return false + + return withTimeoutOrNull(SEND_COMPLETION_TIMEOUT_MS) { completion.await() } ?: false + } + /** * Public entry point for broadcasting - submits request to actor for serialization @@ -474,7 +530,8 @@ class BluetoothPacketBroadcaster( device: BluetoothDevice, data: ByteArray, gattServer: BluetoothGattServer?, - characteristic: BluetoothGattCharacteristic? + characteristic: BluetoothGattCharacteristic?, + completion: CompletableDeferred? = null ): Boolean { val server = gattServer ?: return false val char = characteristic ?: return false @@ -484,7 +541,13 @@ class BluetoothPacketBroadcaster( ?: return false return enqueueSend( SendKey(device.address, linkID, SendDirection.SERVER_NOTIFICATION), - PendingSend(data.copyOf(), device, gattServer = server, characteristic = char) + PendingSend( + data.copyOf(), + device, + gattServer = server, + characteristic = char, + completion = completion + ) ) } @@ -493,13 +556,20 @@ class BluetoothPacketBroadcaster( */ private fun writeToDeviceConn( deviceConn: BluetoothConnectionTracker.DeviceConnection, - data: ByteArray + data: ByteArray, + completion: CompletableDeferred? = null ): Boolean { val gatt = deviceConn.gatt ?: return false val char = deviceConn.characteristic ?: return false return enqueueSend( SendKey(deviceConn.device.address, deviceConn.linkID, SendDirection.CLIENT_WRITE), - PendingSend(data.copyOf(), deviceConn.device, gatt = gatt, characteristic = char) + PendingSend( + data.copyOf(), + deviceConn.device, + gatt = gatt, + characteristic = char, + completion = completion + ) ) } @@ -631,6 +701,7 @@ class BluetoothPacketBroadcaster( } state.pending.removeFirst() state.pendingBytes -= head.data.size + head.completion?.complete(status == BluetoothGatt.GATT_SUCCESS) if (state.pending.isEmpty()) { sendStates.remove(key) false @@ -645,8 +716,13 @@ class BluetoothPacketBroadcaster( fun onLinkDisconnected(deviceAddress: String, linkID: String?) { synchronized(sendLock) { - sendStates.keys.removeAll { key -> - key.deviceAddress == deviceAddress && (linkID == null || key.linkID == linkID) + val iterator = sendStates.entries.iterator() + while (iterator.hasNext()) { + val (key, state) = iterator.next() + if (key.deviceAddress == deviceAddress && (linkID == null || key.linkID == linkID)) { + state.pending.forEach { it.completion?.complete(false) } + iterator.remove() + } } } } @@ -667,7 +743,12 @@ class BluetoothPacketBroadcaster( * Shutdown the broadcaster actor gracefully */ fun shutdown() { - synchronized(sendLock) { sendStates.clear() } + synchronized(sendLock) { + sendStates.values.forEach { state -> + state.pending.forEach { it.completion?.complete(false) } + } + sendStates.clear() + } // Close the actor gracefully broadcasterActor.close() diff --git a/app/src/main/java/com/bitchat/android/mesh/CourierStore.kt b/app/src/main/java/com/bitchat/android/mesh/CourierStore.kt new file mode 100644 index 00000000..d15d40ed --- /dev/null +++ b/app/src/main/java/com/bitchat/android/mesh/CourierStore.kt @@ -0,0 +1,241 @@ +package com.bitchat.android.mesh + +import android.content.Context +import com.bitchat.android.model.CourierEnvelope +import com.bitchat.android.services.AndroidConversationStorageCipher +import com.bitchat.android.services.ConversationStorageCipher +import com.google.gson.Gson +import com.google.gson.reflect.TypeToken +import java.io.File +import java.util.Base64 +import java.nio.file.StandardCopyOption + +enum class CourierDepositTier { FAVORITE, VERIFIED } + +/** Bounded persistent mailbag for opaque envelopes deposited by other peers. */ +internal class CourierStore( + context: Context, + private val cipher: ConversationStorageCipher = AndroidConversationStorageCipher(KEY_ALIAS), + private val now: () -> Long = System::currentTimeMillis +) { + private data class Stored( + val encoded: String, + val depositorKey: String, + val tier: CourierDepositTier, + var copies: Int, + val sprayedTo: MutableSet = mutableSetOf(), + var lastRemoteHandoverAtMs: Long = 0 + ) + + private data class SprayReservation( + val envelopeKey: String, + val courierKey: String, + val copies: Int + ) + + companion object { + private const val KEY_ALIAS = "bitchat_courier_store_v1" + private val AAD = "bitchat-courier-store-v1".toByteArray(Charsets.UTF_8) + private const val MAX_ENVELOPES = 40 + private const val MAX_VERIFIED_ENVELOPES = 20 + private const val MAX_PER_FAVORITE = 5 + private const val MAX_PER_VERIFIED = 2 + private const val EXPIRY_SLACK_MS = 60 * 60 * 1000L + private const val REMOTE_HANDOVER_COOLDOWN_MS = 10 * 60 * 1000L + } + + private val gson = Gson() + private val file = File(context.applicationContext.filesDir, "courier-store.sealed") + private val stored = load() + private val sprayReservations = mutableListOf() + + @Synchronized + fun deposit(envelope: CourierEnvelope, depositorNoiseKey: ByteArray, tier: CourierDepositTier): Boolean { + pruneExpired() + val nowMs = now() + if (envelope.expiry.toLong() <= nowMs || + envelope.expiry.toLong() > nowMs + CourierEnvelope.MAX_LIFETIME_MS + EXPIRY_SLACK_MS + ) return false + val encodedBytes = envelope.encode() ?: return false + val encoded = Base64.getEncoder().encodeToString(encodedBytes) + if (stored.any { it.envelope()?.ciphertext?.contentEquals(envelope.ciphertext) == true }) return true + val depositor = depositorNoiseKey.toHex() + val perDepositor = if (tier == CourierDepositTier.FAVORITE) MAX_PER_FAVORITE else MAX_PER_VERIFIED + if (stored.count { it.depositorKey == depositor && it.tier == tier } >= perDepositor) return false + if (tier == CourierDepositTier.VERIFIED && stored.count { it.tier == tier } >= MAX_VERIFIED_ENVELOPES) { + stored.removeAt(stored.indexOfFirst { it.tier == CourierDepositTier.VERIFIED }) + } + if (stored.size >= MAX_ENVELOPES) { + val verifiedIndex = stored.indexOfFirst { it.tier == CourierDepositTier.VERIFIED } + if (tier == CourierDepositTier.VERIFIED && verifiedIndex < 0) return false + val index = verifiedIndex.takeIf { it >= 0 } ?: 0 + stored.removeAt(index) + } + stored += Stored(encoded, depositor, tier, envelope.copies.toInt()) + persist() + return true + } + + @Synchronized + fun copiesForRecipient(recipientNoiseKey: ByteArray): List { + pruneExpired() + val nowMs = now() + return stored.mapNotNull { record -> + record.envelope() + ?.takeIf { it.matchesRecipient(recipientNoiseKey, nowMs) } + ?.copy(copies = 1u) + } + } + + @Synchronized + fun remove(envelope: CourierEnvelope): Boolean { + val envelopeKey = envelope.ciphertext.storageKey() + val removed = stored.removeAll { + it.envelope()?.ciphertext?.contentEquals(envelope.ciphertext) == true + } + if (removed) { + sprayReservations.removeAll { it.envelopeKey == envelopeKey } + persist() + } + return removed + } + + @Synchronized + fun copiesForRemoteHandover(recipientNoiseKey: ByteArray): List { + pruneExpired() + val nowMs = now() + val result = mutableListOf() + stored.forEach { record -> + val envelope = record.envelope() ?: return@forEach + if (envelope.matchesRecipient(recipientNoiseKey, nowMs) && + nowMs - record.lastRemoteHandoverAtMs >= REMOTE_HANDOVER_COOLDOWN_MS + ) { + record.lastRemoteHandoverAtMs = nowMs + result += envelope.copy(copies = 1u) + } + } + if (result.isNotEmpty()) persist() + return result + } + + @Synchronized + fun sprayCopiesFor(courierNoiseKey: ByteArray): List { + pruneExpired() + val key = courierNoiseKey.toHex() + val nowMs = now() + val courierTags = listOf(-1, 0, 1).map { + CourierEnvelope.recipientTag(courierNoiseKey, CourierEnvelope.epochDay(nowMs) + it.toUInt()) + } + val result = mutableListOf() + stored.forEach { record -> + val envelope = record.envelope() ?: return@forEach + val envelopeKey = envelope.ciphertext.storageKey() + if (record.copies <= 1 || record.depositorKey == key || key in record.sprayedTo || + sprayReservations.any { it.envelopeKey == envelopeKey && it.courierKey == key } || + courierTags.any { it.contentEquals(envelope.recipientTag) } + ) return@forEach + val reserved = sprayReservations + .filter { it.envelopeKey == envelopeKey } + .sumOf { it.copies } + val available = record.copies - reserved + if (available <= 1) return@forEach + val given = available / 2 + sprayReservations += SprayReservation(envelopeKey, key, given) + result += envelope.copy(copies = given.toUByte()) + } + return result + } + + @Synchronized + fun commitSpray(envelope: CourierEnvelope, courierNoiseKey: ByteArray): Boolean { + val key = courierNoiseKey.toHex() + val envelopeKey = envelope.ciphertext.storageKey() + val reservationIndex = sprayReservations.indexOfFirst { + it.envelopeKey == envelopeKey && it.courierKey == key && it.copies == envelope.copies.toInt() + } + if (reservationIndex < 0) return false + val reservation = sprayReservations[reservationIndex] + val record = stored.firstOrNull { + it.envelope()?.ciphertext?.contentEquals(envelope.ciphertext) == true + } + val otherReservedCopies = sprayReservations.withIndex() + .filter { (index, candidate) -> index != reservationIndex && candidate.envelopeKey == envelopeKey } + .sumOf { it.value.copies } + if (record == null || key in record.sprayedTo || + record.copies - otherReservedCopies <= reservation.copies + ) { + sprayReservations.removeAt(reservationIndex) + return false + } + record.copies -= reservation.copies + record.sprayedTo += key + sprayReservations.removeAt(reservationIndex) + persist() + return true + } + + @Synchronized + fun cancelSpray(envelope: CourierEnvelope, courierNoiseKey: ByteArray): Boolean { + val envelopeKey = envelope.ciphertext.storageKey() + val courierKey = courierNoiseKey.toHex() + return sprayReservations.removeAll { + it.envelopeKey == envelopeKey && it.courierKey == courierKey && it.copies == envelope.copies.toInt() + } + } + + @Synchronized + fun wipe() { + stored.clear() + sprayReservations.clear() + file.delete() + cipher.destroyKey() + } + + private fun pruneExpired() { + val nowMs = now().toULong() + if (stored.removeAll { (it.envelope()?.expiry ?: 0u) <= nowMs }) { + val retainedEnvelopeKeys = stored.mapNotNull { it.envelope()?.ciphertext?.storageKey() }.toSet() + sprayReservations.removeAll { it.envelopeKey !in retainedEnvelopeKeys } + persist() + } + } + + private fun Stored.envelope(): CourierEnvelope? = try { + CourierEnvelope.decode(Base64.getDecoder().decode(encoded)) + } catch (_: Exception) { null } + + private fun load(): MutableList = try { + if (!file.exists()) return mutableListOf() + val plaintext = cipher.decrypt(file.readBytes(), AAD) + val type = object : TypeToken>() {}.type + gson.fromJson>(plaintext.toString(Charsets.UTF_8), type) ?: mutableListOf() + } catch (_: Exception) { mutableListOf() } + + private fun persist() { + if (stored.isEmpty()) { + file.delete() + return + } + val encrypted = cipher.encrypt(gson.toJson(stored).toByteArray(Charsets.UTF_8), AAD) + val temporary = File(file.parentFile, "${file.name}.tmp") + temporary.writeBytes(encrypted) + try { + java.nio.file.Files.move( + temporary.toPath(), + file.toPath(), + StandardCopyOption.ATOMIC_MOVE, + StandardCopyOption.REPLACE_EXISTING + ) + } catch (_: Exception) { + java.nio.file.Files.move( + temporary.toPath(), + file.toPath(), + StandardCopyOption.REPLACE_EXISTING + ) + } + } + + private fun ByteArray.toHex(): String = joinToString("") { "%02x".format(it) } + + private fun ByteArray.storageKey(): String = Base64.getEncoder().encodeToString(this) +} diff --git a/app/src/main/java/com/bitchat/android/mesh/DirectCourierDepositPolicy.kt b/app/src/main/java/com/bitchat/android/mesh/DirectCourierDepositPolicy.kt new file mode 100644 index 00000000..188872b9 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/mesh/DirectCourierDepositPolicy.kt @@ -0,0 +1,20 @@ +package com.bitchat.android.mesh + +import com.bitchat.android.model.RoutedPacket + +/** Accepts courier custody only from the authenticated peer on the current direct link. */ +internal object DirectCourierDepositPolicy { + fun accepts( + routed: RoutedPacket, + maxTtl: UByte, + currentLinkID: (String) -> String?, + peerForAddress: (String) -> String? + ): Boolean { + val peerID = routed.peerID ?: return false + val relayAddress = routed.relayAddress ?: return false + val ingressLinkID = routed.ingressLinkID ?: return false + return routed.packet.ttl == maxTtl && + currentLinkID(relayAddress) == ingressLinkID && + peerForAddress(relayAddress) == peerID + } +} diff --git a/app/src/main/java/com/bitchat/android/mesh/FragmentingPacketSender.kt b/app/src/main/java/com/bitchat/android/mesh/FragmentingPacketSender.kt index 9c6bd15e..adac55c2 100644 --- a/app/src/main/java/com/bitchat/android/mesh/FragmentingPacketSender.kt +++ b/app/src/main/java/com/bitchat/android/mesh/FragmentingPacketSender.kt @@ -112,6 +112,23 @@ class FragmentingPacketSender( return true } + suspend fun sendAndAwaitAcceptance( + routed: RoutedPacket, + description: String, + sendSingle: suspend (RoutedPacket) -> Boolean + ): Boolean { + val packets = packetsForTransport(routed) ?: return false + Log.d(logTag, "Sending ${packets.size} packet(s) for $description") + for ((index, packet) in packets.withIndex()) { + val accepted = sendSingle( + routed.copy(packet = packet, transferId = null, preparedPackets = null) + ) + if (!accepted) return false + if (index < packets.lastIndex) delay(interFragmentDelayMs) + } + return true + } + fun cancelTransfer(transferId: String): Boolean { val job = transferJobs.remove(transferId) ?: return false job.cancel() 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 28cd3d02..aac2a880 100644 --- a/app/src/main/java/com/bitchat/android/mesh/MeshCore.kt +++ b/app/src/main/java/com/bitchat/android/mesh/MeshCore.kt @@ -468,6 +468,8 @@ class MeshCore( return runBlocking { messageHandler.handleNoiseEncrypted(routed) } } + override fun handleCourierEnvelope(routed: RoutedPacket): Boolean = false + override suspend fun handleAnnounce(routed: RoutedPacket): Boolean { val result = messageHandler.handleAnnounceWithResult(routed) if (result !is AnnounceHandlingResult.Accepted) return false @@ -481,7 +483,7 @@ class MeshCore( try { val pkt = routed.packet val isBroadcast = (pkt.recipientID == null || pkt.recipientID.contentEquals(SpecialRecipients.BROADCAST)) - if (isBroadcast && pkt.type == MessageType.MESSAGE.value) { + if (isBroadcast && pkt.type in setOf(MessageType.MESSAGE.value, MessageType.FILE_TRANSFER.value)) { gossipSyncManager.onPublicPacketSeen(pkt) } } catch (_: Exception) { } diff --git a/app/src/main/java/com/bitchat/android/mesh/MeshService.kt b/app/src/main/java/com/bitchat/android/mesh/MeshService.kt index 7b357149..65d88ba0 100644 --- a/app/src/main/java/com/bitchat/android/mesh/MeshService.kt +++ b/app/src/main/java/com/bitchat/android/mesh/MeshService.kt @@ -41,6 +41,19 @@ interface MeshService { fun initiateNoiseHandshake(peerID: String) fun getPeerFingerprint(peerID: String): String? fun getPeerInfo(peerID: String): PeerInfo? + fun getPeerInfos(): List = getPeerNicknames().keys.mapNotNull(::getPeerInfo) + fun sendCourierMessage( + content: String, + messageID: String, + recipientNoiseKey: ByteArray, + courierPeerIDs: List + ): List = emptyList() + fun sendBridgeCourierMessage( + content: String, + messageID: String, + recipientNoiseKey: ByteArray, + onAccepted: () -> Unit = {} + ): Boolean = false fun updatePeerInfo( peerID: String, nickname: String, diff --git a/app/src/main/java/com/bitchat/android/mesh/MessageHandler.kt b/app/src/main/java/com/bitchat/android/mesh/MessageHandler.kt index 9434fc6e..ae890316 100644 --- a/app/src/main/java/com/bitchat/android/mesh/MessageHandler.kt +++ b/app/src/main/java/com/bitchat/android/mesh/MessageHandler.kt @@ -207,6 +207,32 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro return true } + /** Admit an already authenticated Noise X payload through the normal private-message path. */ + suspend fun handleOpenedCourierPayload(routed: RoutedPacket): Boolean { + val packet = routed.packet + val peerID = routed.peerID ?: return false + val noisePayload = com.bitchat.android.model.NoisePayload.decode(packet.payload) ?: return false + if (noisePayload.type == com.bitchat.android.model.NoisePayloadType.DELIVERED) { + val messageID = noisePayload.data.toString(Charsets.UTF_8) + if (messageID.isBlank()) return false + delegate?.onDeliveryAckReceived(messageID, peerID) + return true + } + if (noisePayload.type != com.bitchat.android.model.NoisePayloadType.PRIVATE_MESSAGE) return false + val privateMessage = com.bitchat.android.model.PrivateMessagePacket.decode(noisePayload.data) ?: return false + val message = BitchatMessage( + id = privateMessage.messageID, + sender = delegate?.getPeerNickname(peerID) ?: "Unknown", + content = privateMessage.content, + timestamp = Date(packet.timestamp.toLong()), + isPrivate = true, + recipientNickname = delegate?.getMyNickname(), + senderPeerID = peerID + ) + delegate?.onMessageReceived(message) + return true + } + /** * Count consecutive decrypt failures from a signature-verified peer that we still hold an * established session for. After repeated failures the session is stale (the peer completed @@ -453,6 +479,12 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro private suspend fun handleBroadcastMessage(routed: RoutedPacket) { val packet = routed.packet val peerID = routed.peerID ?: "unknown" + if (packet.timestamp > Long.MAX_VALUE.toULong()) return + val ageMs = System.currentTimeMillis() - packet.timestamp.toLong() + if (ageMs !in + -com.bitchat.android.sync.GossipSyncManager.PUBLIC_PACKET_FUTURE_SKEW_MS.. + com.bitchat.android.sync.GossipSyncManager.PUBLIC_MESSAGE_MAX_AGE_MS + ) return // Enforce: only accept public messages from verified peers we know val peerInfo = delegate?.getPeerInfo(peerID) 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 37a295e8..039c08e1 100644 --- a/app/src/main/java/com/bitchat/android/mesh/PacketProcessor.kt +++ b/app/src/main/java/com/bitchat/android/mesh/PacketProcessor.kt @@ -122,6 +122,22 @@ class PacketProcessor(private val myPeerID: String) { var validPacket = true val messageType = MessageType.fromValue(packet.type) + val isBroadcast = packet.recipientID == null || + packet.recipientID.contentEquals(com.bitchat.android.protocol.SpecialRecipients.BROADCAST) + if (isBroadcast && packet.timestamp <= Long.MAX_VALUE.toULong()) { + val ageMs = System.currentTimeMillis() - packet.timestamp.toLong() + val maxAgeMs = when (messageType) { + MessageType.MESSAGE -> com.bitchat.android.sync.GossipSyncManager.PUBLIC_MESSAGE_MAX_AGE_MS + MessageType.FRAGMENT, MessageType.FILE_TRANSFER -> + com.bitchat.android.sync.GossipSyncManager.FRAGMENT_MAX_AGE_MS + else -> null + } + if (maxAgeMs != null && ageMs !in + -com.bitchat.android.sync.GossipSyncManager.PUBLIC_PACKET_FUTURE_SKEW_MS..maxAgeMs + ) return + } else if (isBroadcast && packet.timestamp > Long.MAX_VALUE.toULong()) { + return + } // Verbose logging to debug manager (and chat via ChatViewModel observer) try { val mt = messageType?.name ?: packet.type.toString() @@ -146,6 +162,7 @@ class PacketProcessor(private val myPeerID: String) { when (messageType) { MessageType.NOISE_HANDSHAKE -> validPacket = handleNoiseHandshake(routed) MessageType.NOISE_ENCRYPTED -> validPacket = handleNoiseEncrypted(routed) + MessageType.COURIER_ENVELOPE -> validPacket = delegate?.handleCourierEnvelope(routed) ?: false MessageType.FILE_TRANSFER -> handleMessage(routed) else -> { validPacket = false @@ -295,6 +312,7 @@ interface PacketProcessorDelegate { // Message type handlers fun handleNoiseHandshake(routed: RoutedPacket): Boolean fun handleNoiseEncrypted(routed: RoutedPacket): Boolean + fun handleCourierEnvelope(routed: RoutedPacket): Boolean = false suspend fun handleAnnounce(routed: RoutedPacket): Boolean fun handleMessage(routed: RoutedPacket) fun handleVoiceFrame(routed: RoutedPacket): Boolean = false diff --git a/app/src/main/java/com/bitchat/android/mesh/SecurityManager.kt b/app/src/main/java/com/bitchat/android/mesh/SecurityManager.kt index 3f684679..40cacbda 100644 --- a/app/src/main/java/com/bitchat/android/mesh/SecurityManager.kt +++ b/app/src/main/java/com/bitchat/android/mesh/SecurityManager.kt @@ -268,6 +268,7 @@ class SecurityManager(private val encryptionService: EncryptionService, private MessageType.ANNOUNCE, MessageType.MESSAGE, MessageType.FILE_TRANSFER, + MessageType.COURIER_ENVELOPE, MessageType.VOICE_FRAME, MessageType.LEAVE )) { diff --git a/app/src/main/java/com/bitchat/android/mesh/UnifiedMeshService.kt b/app/src/main/java/com/bitchat/android/mesh/UnifiedMeshService.kt index 3efe758d..054ca25e 100644 --- a/app/src/main/java/com/bitchat/android/mesh/UnifiedMeshService.kt +++ b/app/src/main/java/com/bitchat/android/mesh/UnifiedMeshService.kt @@ -116,6 +116,22 @@ class UnifiedMeshService( } } + override fun getPeerInfos(): List = bluetooth.getPeerInfos() + + override fun sendCourierMessage( + content: String, + messageID: String, + recipientNoiseKey: ByteArray, + courierPeerIDs: List + ): List = bluetooth.sendCourierMessage(content, messageID, recipientNoiseKey, courierPeerIDs) + + override fun sendBridgeCourierMessage( + content: String, + messageID: String, + recipientNoiseKey: ByteArray, + onAccepted: () -> Unit + ): Boolean = bluetooth.sendBridgeCourierMessage(content, messageID, recipientNoiseKey, onAccepted) + override fun sendReadReceipt(messageID: String, recipientPeerID: String, readerNickname: String) { when { isBleReady(recipientPeerID) -> bluetooth.sendReadReceipt(messageID, recipientPeerID, readerNickname) diff --git a/app/src/main/java/com/bitchat/android/model/BitchatMessage.kt b/app/src/main/java/com/bitchat/android/model/BitchatMessage.kt index 528a269e..a70fc069 100644 --- a/app/src/main/java/com/bitchat/android/model/BitchatMessage.kt +++ b/app/src/main/java/com/bitchat/android/model/BitchatMessage.kt @@ -22,6 +22,9 @@ sealed class DeliveryStatus : Parcelable { @Parcelize object Sending : DeliveryStatus() + @Parcelize + object Queued : DeliveryStatus() + @Parcelize object Sent : DeliveryStatus() @@ -40,6 +43,7 @@ sealed class DeliveryStatus : Parcelable { fun getDisplayText(): String { return when (this) { is Sending -> "Sending..." + is Queued -> "Queued" is Sent -> "Sent" is Delivered -> "Delivered to ${this.to}" is Read -> "Read by ${this.by}" @@ -362,4 +366,3 @@ data class BitchatMessage( return result } } - diff --git a/app/src/main/java/com/bitchat/android/model/CourierEnvelope.kt b/app/src/main/java/com/bitchat/android/model/CourierEnvelope.kt new file mode 100644 index 00000000..4765a273 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/model/CourierEnvelope.kt @@ -0,0 +1,115 @@ +package com.bitchat.android.model + +import java.nio.ByteBuffer +import java.nio.ByteOrder +import javax.crypto.Mac +import javax.crypto.spec.SecretKeySpec + +/** Opaque store-and-forward envelope, wire-compatible with iOS CourierEnvelope. */ +data class CourierEnvelope( + val recipientTag: ByteArray, + val expiry: ULong, + val ciphertext: ByteArray, + val copies: UByte = 1u, + val prekeyID: UInt? = null +) { + companion object { + const val TAG_LENGTH = 16 + const val MAX_CIPHERTEXT_BYTES = 16 * 1024 + const val MAX_LIFETIME_MS = 24 * 60 * 60 * 1000L + const val MAX_COPIES = 8 + private val TAG_DOMAIN = "bitchat-courier-tag-v1".toByteArray(Charsets.UTF_8) + + fun epochDay(nowMs: Long): UInt = Math.floorDiv(nowMs, 86_400_000L).toUInt() + + fun recipientTag(noiseStaticKey: ByteArray, epochDay: UInt): ByteArray { + require(noiseStaticKey.size == 32) + val mac = Mac.getInstance("HmacSHA256") + mac.init(SecretKeySpec(noiseStaticKey, "HmacSHA256")) + mac.update(TAG_DOMAIN) + mac.update(ByteBuffer.allocate(4).order(ByteOrder.BIG_ENDIAN).putInt(epochDay.toInt()).array()) + return mac.doFinal().copyOf(TAG_LENGTH) + } + + fun decode(data: ByteArray): CourierEnvelope? { + var offset = 0 + var tag: ByteArray? = null + var expiry: ULong? = null + var ciphertext: ByteArray? = null + var copies: UByte = 1u + var prekeyID: UInt? = null + while (offset + 3 <= data.size) { + val type = data[offset].toUByte() + val length = ((data[offset + 1].toInt() and 0xff) shl 8) or + (data[offset + 2].toInt() and 0xff) + offset += 3 + if (offset + length > data.size) return null + val value = data.copyOfRange(offset, offset + length) + offset += length + when (type.toInt()) { + 1 -> if (length == TAG_LENGTH && tag == null) tag = value else return null + 2 -> if (length == 8 && expiry == null) expiry = ByteBuffer.wrap(value).order(ByteOrder.BIG_ENDIAN).long.toULong() else return null + 3 -> if (ciphertext == null) ciphertext = value else return null + 4 -> { + if (length != 1 || copies != 1u.toUByte()) return null + copies = value[0].toUByte() + if (copies !in 2u.toUByte()..MAX_COPIES.toUByte()) return null + } + 5 -> if (length == 4 && prekeyID == null) { + prekeyID = ByteBuffer.wrap(value).order(ByteOrder.BIG_ENDIAN).int.toUInt() + } else { + return null + } + } + } + if (offset != data.size) return null + val requiredTag = tag ?: return null + val requiredExpiry = expiry ?: return null + val requiredCiphertext = ciphertext ?: return null + if (requiredCiphertext.isEmpty() || requiredCiphertext.size > MAX_CIPHERTEXT_BYTES) return null + return CourierEnvelope(requiredTag, requiredExpiry, requiredCiphertext, copies, prekeyID) + } + } + + fun encode(): ByteArray? { + if (recipientTag.size != TAG_LENGTH || ciphertext.isEmpty() || ciphertext.size > MAX_CIPHERTEXT_BYTES || + copies !in 1u.toUByte()..MAX_COPIES.toUByte() + ) return null + val fields = mutableListOf>() + fields += 1 to recipientTag + fields += 2 to ByteBuffer.allocate(8).order(ByteOrder.BIG_ENDIAN).putLong(expiry.toLong()).array() + fields += 3 to ciphertext + if (copies > 1u) fields += 4 to byteArrayOf(copies.toByte()) + prekeyID?.let { + fields += 5 to ByteBuffer.allocate(4).order(ByteOrder.BIG_ENDIAN).putInt(it.toInt()).array() + } + val size = fields.sumOf { 3 + it.second.size } + val buffer = ByteBuffer.allocate(size).order(ByteOrder.BIG_ENDIAN) + fields.forEach { (type, value) -> + buffer.put(type.toByte()) + buffer.putShort(value.size.toShort()) + buffer.put(value) + } + return buffer.array() + } + + fun matchesRecipient(noiseStaticKey: ByteArray, nowMs: Long): Boolean { + val day = epochDay(nowMs) + return listOf(day - 1u, day, day + 1u).any { + recipientTag.contentEquals(recipientTag(noiseStaticKey, it)) + } + } + + override fun equals(other: Any?): Boolean = other is CourierEnvelope && + recipientTag.contentEquals(other.recipientTag) && expiry == other.expiry && + ciphertext.contentEquals(other.ciphertext) && copies == other.copies && prekeyID == other.prekeyID + + override fun hashCode(): Int { + var result = recipientTag.contentHashCode() + result = 31 * result + expiry.hashCode() + result = 31 * result + ciphertext.contentHashCode() + result = 31 * result + copies.hashCode() + result = 31 * result + (prekeyID?.hashCode() ?: 0) + return result + } +} diff --git a/app/src/main/java/com/bitchat/android/model/RequestSyncPacket.kt b/app/src/main/java/com/bitchat/android/model/RequestSyncPacket.kt index ab52f071..f7c448ba 100644 --- a/app/src/main/java/com/bitchat/android/model/RequestSyncPacket.kt +++ b/app/src/main/java/com/bitchat/android/model/RequestSyncPacket.kt @@ -1,6 +1,8 @@ package com.bitchat.android.model import com.bitchat.android.sync.SyncDefaults +import com.bitchat.android.sync.GCSFilter +import com.bitchat.android.sync.SyncTypeFlags /** * REQUEST_SYNC payload using GCS (Golomb-Coded Set) parameters. @@ -8,11 +10,15 @@ import com.bitchat.android.sync.SyncDefaults * - 0x01: P (uint8) — Golomb-Rice parameter * - 0x02: M (uint32, big-endian) — hash range (N * 2^P) * - 0x03: data (opaque) — GR bitstream bytes + * - 0x04: types (compact little-endian SyncTypeFlags) — packet types covered by the filter + * - 0x05: sinceTimestamp (uint64, big-endian) — oldest timestamp covered by the filter */ data class RequestSyncPacket( val p: Int, val m: Long, - val data: ByteArray + val data: ByteArray, + val types: SyncTypeFlags? = null, + val sinceTimestamp: ULong? = null ) { fun encode(): ByteArray { val out = ArrayList() @@ -38,6 +44,15 @@ data class RequestSyncPacket( ) // data putTLV(0x03, data) + types?.encode()?.let { putTLV(0x04, it) } + sinceTimestamp?.let { timestamp -> + putTLV( + 0x05, + ByteArray(8) { index -> + (timestamp shr ((7 - index) * 8) and 0xffu).toByte() + } + ) + } return out.toByteArray() } @@ -50,6 +65,8 @@ data class RequestSyncPacket( var p: Int? = null var m: Long? = null var payload: ByteArray? = null + var types: SyncTypeFlags? = null + var sinceTimestamp: ULong? = null while (off + 3 <= data.size) { val t = (data[off].toInt() and 0xFF); off += 1 @@ -69,14 +86,20 @@ data class RequestSyncPacket( if (v.size > MAX_ACCEPT_FILTER_BYTES) return null payload = v } + 0x04 -> SyncTypeFlags.decode(v)?.let { types = it } + 0x05 -> if (v.size == 8) { + var timestamp = 0uL + v.forEach { byte -> timestamp = (timestamp shl 8) or byte.toUByte().toULong() } + sinceTimestamp = timestamp + } } } val pp = p ?: return null val mm = m ?: return null val dd = payload ?: return null - if (pp < 1 || mm <= 0L) return null - return RequestSyncPacket(pp, mm, dd) + if (pp !in 1..GCSFilter.MAX_P || mm <= 0L) return null + return RequestSyncPacket(pp, mm, dd, types, sinceTimestamp) } } } diff --git a/app/src/main/java/com/bitchat/android/noise/NoiseEncryptionService.kt b/app/src/main/java/com/bitchat/android/noise/NoiseEncryptionService.kt index 909ca82b..254a7b62 100644 --- a/app/src/main/java/com/bitchat/android/noise/NoiseEncryptionService.kt +++ b/app/src/main/java/com/bitchat/android/noise/NoiseEncryptionService.kt @@ -5,6 +5,7 @@ import android.util.Log import com.bitchat.android.identity.SecureIdentityStateManager import com.bitchat.android.mesh.PeerFingerprintManager import com.bitchat.android.noise.southernstorm.protocol.Noise +import com.bitchat.android.noise.southernstorm.protocol.HandshakeState import java.security.MessageDigest import java.security.SecureRandom import java.util.concurrent.ConcurrentHashMap @@ -18,7 +19,10 @@ import java.util.concurrent.ConcurrentHashMap * - Channel encryption using password-derived keys * - Peer fingerprint mapping and identity persistence */ -class NoiseEncryptionService(private val context: Context) { +class NoiseEncryptionService( + private val context: Context, + private val identityStateManager: SecureIdentityStateManager = SecureIdentityStateManager(context) +) { companion object { private const val TAG = "NoiseEncryptionService" @@ -42,9 +46,6 @@ class NoiseEncryptionService(private val context: Context) { // Channel encryption for password-protected channels private val channelEncryption = NoiseChannelEncryption() - // Identity management for peer ID rotation support - private val identityStateManager: SecureIdentityStateManager - // Centralized fingerprint management - NO LOCAL STORAGE private val fingerprintManager = PeerFingerprintManager.getInstance() @@ -53,9 +54,6 @@ class NoiseEncryptionService(private val context: Context) { var onHandshakeRequired: ((String) -> Unit)? = null // peerID needs handshake init { - // Initialize identity state manager for persistent storage - identityStateManager = SecureIdentityStateManager(context) - // Load or create keys - temporary placeholders staticIdentityPrivateKey = ByteArray(32) staticIdentityPublicKey = ByteArray(32) @@ -147,6 +145,40 @@ class NoiseEncryptionService(private val context: Context) { return sessionManager.getRemoteStaticKey(peerID) } + fun sealCourierPayload(payload: ByteArray, recipientStaticKey: ByteArray): ByteArray { + require(recipientStaticKey.size == 32 && recipientStaticKey.any { it != 0.toByte() }) + val state = HandshakeState("Noise_X_25519_ChaChaPoly_SHA256", HandshakeState.INITIATOR) + return try { + state.getLocalKeyPair().setPrivateKey(staticIdentityPrivateKey, 0) + state.getRemotePublicKey().setPublicKey(recipientStaticKey, 0) + val prologue = "bitchat-courier-v1".toByteArray(Charsets.UTF_8) + state.setPrologue(prologue, 0, prologue.size) + state.start() + val output = ByteArray(payload.size + 128) + output.copyOf(state.writeMessage(output, 0, payload, 0, payload.size)) + } finally { + state.destroy() + } + } + + fun openCourierPayload(ciphertext: ByteArray): Pair { + require(ciphertext.size >= 96) + val state = HandshakeState("Noise_X_25519_ChaChaPoly_SHA256", HandshakeState.RESPONDER) + return try { + state.getLocalKeyPair().setPrivateKey(staticIdentityPrivateKey, 0) + val prologue = "bitchat-courier-v1".toByteArray(Charsets.UTF_8) + state.setPrologue(prologue, 0, prologue.size) + state.start() + val payload = ByteArray(ciphertext.size) + val length = state.readMessage(ciphertext, 0, ciphertext.size, payload, 0) + val senderKey = ByteArray(32) + state.getRemotePublicKey().getPublicKey(senderKey, 0) + senderKey to payload.copyOf(length) + } finally { + state.destroy() + } + } + fun getAuthenticatedSession(peerID: String): AuthenticatedNoiseSession? = sessionManager.getAuthenticatedSession(peerID) diff --git a/app/src/main/java/com/bitchat/android/nostr/BridgeCourierService.kt b/app/src/main/java/com/bitchat/android/nostr/BridgeCourierService.kt new file mode 100644 index 00000000..d3ca9208 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/nostr/BridgeCourierService.kt @@ -0,0 +1,234 @@ +package com.bitchat.android.nostr + +import android.content.Context +import android.util.Base64 +import com.bitchat.android.crypto.EncryptionService +import com.bitchat.android.model.CourierEnvelope +import java.util.Collections +import java.util.LinkedHashMap +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch + +/** + * Minimal relay surface used by the bridge courier. + * + * Keeping this adapter boundary small lets the kind-1401 contract be exercised with a + * deterministic in-memory relay in unit tests, without opening a network connection. + */ +internal interface BridgeCourierRelay { + fun subscribe( + filter: NostrFilter, + id: String, + targetRelayUrls: List, + handler: (NostrEvent) -> Unit + ) + + fun unsubscribe(id: String) + + fun hasConnectedRelay(relayUrls: Collection): Boolean + + fun sendEvent( + event: NostrEvent, + relayUrls: List, + onAccepted: () -> Unit + ): Boolean +} + +internal interface BridgeCourierCipher { + fun staticPublicKey(): ByteArray? + + fun seal(payload: ByteArray, recipientNoiseKey: ByteArray): ByteArray +} + +private class NostrBridgeCourierRelay( + private val relayManager: NostrRelayManager +) : BridgeCourierRelay { + override fun subscribe( + filter: NostrFilter, + id: String, + targetRelayUrls: List, + handler: (NostrEvent) -> Unit + ) { + relayManager.subscribe( + filter = filter, + id = id, + targetRelayUrls = targetRelayUrls, + handler = handler + ) + } + + override fun unsubscribe(id: String) { + relayManager.unsubscribe(id) + } + + override fun hasConnectedRelay(relayUrls: Collection): Boolean = + relayManager.hasConnectedRelay(relayUrls) + + override fun sendEvent( + event: NostrEvent, + relayUrls: List, + onAccepted: () -> Unit + ): Boolean = relayManager.sendEvent(event, relayUrls, onAccepted = onAccepted) +} + +private class EncryptionBridgeCourierCipher( + private val encryptionService: EncryptionService +) : BridgeCourierCipher { + override fun staticPublicKey(): ByteArray? = encryptionService.getStaticPublicKey() + + override fun seal(payload: ByteArray, recipientNoiseKey: ByteArray): ByteArray = + encryptionService.sealCourierPayload(payload, recipientNoiseKey) +} + +/** Parks opaque courier envelopes on default Nostr relays using the iOS kind-1401 contract. */ +class BridgeCourierService internal constructor( + private val cipher: BridgeCourierCipher, + private val onEnvelope: (CourierEnvelope) -> Unit, + private val relayManager: BridgeCourierRelay, + private val relayUrls: List = NostrRelayManager.defaultRelays(), + private val clock: () -> Long = System::currentTimeMillis, + private val identityFactory: () -> NostrIdentity = NostrIdentity::generate +) { + constructor( + context: Context, + encryptionService: EncryptionService, + onEnvelope: (CourierEnvelope) -> Unit + ) : this( + cipher = EncryptionBridgeCourierCipher(encryptionService), + onEnvelope = onEnvelope, + relayManager = NostrBridgeCourierRelay( + NostrRelayManager.getInstance(context.applicationContext) + ) + ) + + companion object { + private const val KIND = 1401 + private const val MAX_ENCODED_BYTES = 20 * 1024 + private const val TAG_REFRESH_INTERVAL_MS = 60 * 60 * 1000L + } + + private val seenEvents = Collections.synchronizedMap( + object : LinkedHashMap(512, 0.75f, true) { + override fun removeEldestEntry(eldest: MutableMap.MutableEntry?) = size > 512 + } + ) + private val subscriptionID = "bridge-courier-drops-${System.identityHashCode(this)}" + @Volatile private var subscribedDay: UInt? = null + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private var refreshJob: Job? = null + val isStarted: Boolean get() = subscribedDay != null + + @Synchronized + fun start() { + val localKey = cipher.staticPublicKey() ?: ByteArray(0) + if (localKey.size == 32) { + val now = clock() + val day = CourierEnvelope.epochDay(now) + if (subscribedDay == day) return + if (subscribedDay != null) relayManager.unsubscribe(subscriptionID) + val tags = listOf(day - 1u, day, day + 1u).map { + CourierEnvelope.recipientTag(localKey, it).toHex() + } + val filter = NostrFilter.Builder() + .kinds(KIND) + .since(now - CourierEnvelope.MAX_LIFETIME_MS) + .limit(100) + .tag("x", *tags.toTypedArray()) + .build() + relayManager.subscribe( + filter = filter, + id = subscriptionID, + targetRelayUrls = relayUrls, + handler = ::handleEvent + ) + subscribedDay = day + if (refreshJob?.isActive != true) { + refreshJob = scope.launch { + while (isActive) { + delay(TAG_REFRESH_INTERVAL_MS) + start() + } + } + } + } + } + + fun deposit( + content: String, + messageID: String, + recipientNoiseKey: ByteArray, + onAccepted: () -> Unit = {} + ): Boolean { + val privateMessage = com.bitchat.android.model.PrivateMessagePacket(messageID, content).encode() ?: return false + val typed = com.bitchat.android.model.NoisePayload( + com.bitchat.android.model.NoisePayloadType.PRIVATE_MESSAGE, + privateMessage + ).encode() + return depositPayload(typed, recipientNoiseKey, onAccepted) + } + + fun depositPayload( + typedPayload: ByteArray, + recipientNoiseKey: ByteArray, + onAccepted: () -> Unit = {} + ): Boolean { + start() + if (!relayManager.hasConnectedRelay(relayUrls)) return false + val sealed = try { cipher.seal(typedPayload, recipientNoiseKey) } catch (_: Exception) { return false } + val now = clock() + val envelope = CourierEnvelope( + recipientTag = CourierEnvelope.recipientTag(recipientNoiseKey, CourierEnvelope.epochDay(now)), + expiry = (now + CourierEnvelope.MAX_LIFETIME_MS).toULong(), + ciphertext = sealed, + copies = 1u + ) + val encoded = envelope.encode() ?: return false + if (encoded.size > MAX_ENCODED_BYTES) return false + val identity = try { identityFactory() } catch (_: Exception) { return false } + val event = identity.signEvent( + NostrEvent( + pubkey = identity.publicKeyHex, + createdAt = (now / 1000).toInt(), + kind = KIND, + tags = listOf( + listOf("x", envelope.recipientTag.toHex()), + listOf("expiration", (envelope.expiry / 1000u).toString()) + ), + content = Base64.encodeToString(encoded, Base64.NO_WRAP) + ) + ) + return relayManager.sendEvent(event, relayUrls, onAccepted) + } + + @Synchronized + fun stop() { + relayManager.unsubscribe(subscriptionID) + subscribedDay = null + refreshJob?.cancel() + refreshJob = null + } + + private fun handleEvent(event: NostrEvent) { + if (event.kind != KIND || !event.isValidSignature()) return + synchronized(seenEvents) { if (seenEvents.put(event.id, Unit) != null) return } + if (event.content.length > ((MAX_ENCODED_BYTES + 2) / 3) * 4) return + val encoded = try { Base64.decode(event.content, Base64.DEFAULT) } catch (_: Exception) { return } + if (encoded.size > MAX_ENCODED_BYTES) return + val envelope = CourierEnvelope.decode(encoded) ?: return + val now = clock() + if (envelope.expiry.toLong() <= now || envelope.expiry.toLong() > now + CourierEnvelope.MAX_LIFETIME_MS + 60 * 60 * 1000L) return + val eventTag = event.tags.firstOrNull { it.size > 1 && it[0] == "x" }?.get(1) ?: return + val expiration = event.tags.firstOrNull { it.size > 1 && it[0] == "expiration" }?.get(1)?.toULongOrNull() ?: return + if (eventTag != envelope.recipientTag.toHex() || expiration != envelope.expiry / 1000u) return + val localKey = cipher.staticPublicKey() ?: return + if (!envelope.matchesRecipient(localKey, now)) return + onEnvelope(envelope) + } + + private fun ByteArray.toHex() = joinToString("") { "%02x".format(it) } +} diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrDirectMessageHandler.kt b/app/src/main/java/com/bitchat/android/nostr/NostrDirectMessageHandler.kt index 4125d5ca..ac31f76b 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrDirectMessageHandler.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrDirectMessageHandler.kt @@ -188,6 +188,7 @@ class NostrDirectMessageHandler( } NoisePayloadType.DELIVERED -> { val messageId = String(payload.data, Charsets.UTF_8) + com.bitchat.android.services.MessageRouter.tryGetInstance()?.onMessageAcknowledged(messageId, conversationID) withContext(Dispatchers.Main) { updateDeliveryStatus( messageId, @@ -197,6 +198,7 @@ class NostrDirectMessageHandler( } NoisePayloadType.READ_RECEIPT -> { val messageId = String(payload.data, Charsets.UTF_8) + com.bitchat.android.services.MessageRouter.tryGetInstance()?.onMessageAcknowledged(messageId, conversationID) withContext(Dispatchers.Main) { updateDeliveryStatus( messageId, diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrEvent.kt b/app/src/main/java/com/bitchat/android/nostr/NostrEvent.kt index 92752b17..2cdf75d6 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrEvent.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrEvent.kt @@ -214,6 +214,7 @@ object NostrKind { const val FILE_MESSAGE = 15 // NIP-17 file message (unsigned) const val SEAL = 13 // NIP-17 sealed event const val GIFT_WRAP = 1059 // NIP-17 gift wrap + const val COURIER_DROP = 1401 // Opaque bridge courier envelope const val EPHEMERAL_EVENT = 20000 // For geohash channels const val GEOHASH_PRESENCE = 20001 // For geohash presence heartbeat } diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrRelayManager.kt b/app/src/main/java/com/bitchat/android/nostr/NostrRelayManager.kt index 772e2a60..d8e431a8 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrRelayManager.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrRelayManager.kt @@ -85,6 +85,11 @@ class NostrRelayManager private constructor() { private val _isConnected = MutableStateFlow(false) val isConnected: StateFlow = _isConnected.asStateFlow() + private data class PendingAcceptance( + val callback: () -> Unit, + val pendingRelays: MutableSet + ) + private val eventAcceptanceHandlers = java.util.concurrent.ConcurrentHashMap() // Internal state private val relaysList = mutableListOf() @@ -452,12 +457,14 @@ class NostrRelayManager private constructor() { fun sendEvent( event: NostrEvent, relayUrls: List? = null, - liveLocationToken: Long? = null - ) { + liveLocationToken: Long? = null, + onAccepted: (() -> Unit)? = null + ): Boolean { val targetRelays = (relayUrls ?: relaysList.map { it.url }) .filter { it.isNotBlank() } .distinct() - if (targetRelays.isEmpty()) return + if (targetRelays.isEmpty()) return false + var enqueued = false val queued = runNetworkAction(liveLocationToken) { val queueId = messageQueue.enqueue( @@ -465,6 +472,13 @@ class NostrRelayManager private constructor() { relayUrls = targetRelays, liveLocationToken = liveLocationToken ) ?: return@runNetworkAction + enqueued = true + if (onAccepted != null) { + eventAcceptanceHandlers[event.id] = PendingAcceptance( + onAccepted, + targetRelays.map { it.trim().trimEnd('/') }.toMutableSet() + ) + } scope.launch { if (!isNetworkActionAllowed(liveLocationToken)) return@launch targetRelays.forEach { relayUrl -> @@ -477,7 +491,12 @@ class NostrRelayManager private constructor() { } } } - if (!queued) return + return queued && enqueued + } + + fun hasConnectedRelay(relayUrls: Collection): Boolean { + val targets = relayUrls.map { it.trim().trimEnd('/') }.toSet() + return relaysList.any { it.isConnected && it.url.trim().trimEnd('/') in targets } } /** @@ -673,6 +692,7 @@ class NostrRelayManager private constructor() { // Clear any queued messages waiting to be sent messageQueue.clear() + eventAcceptanceHandlers.clear() Log.i(TAG, "Cleared all Nostr subscriptions and routing caches") } catch (e: Exception) { @@ -966,8 +986,14 @@ class NostrRelayManager private constructor() { is NostrResponse.Ok -> { val wasGiftWrap = pendingGiftWrapIDs.remove(response.eventId) if (!response.accepted) { + eventAcceptanceHandlers[response.eventId]?.let { pending -> + pending.pendingRelays.remove(relayUrl.trim().trimEnd('/')) + if (pending.pendingRelays.isEmpty()) eventAcceptanceHandlers.remove(response.eventId, pending) + } val level = if (wasGiftWrap) Log.WARN else Log.ERROR Log.println(level, TAG, "Event rejected by relay: ${response.message ?: "no reason"}") + } else { + eventAcceptanceHandlers.remove(response.eventId)?.callback?.invoke() } } diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrTransport.kt b/app/src/main/java/com/bitchat/android/nostr/NostrTransport.kt index f39324fd..5dd1d49f 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrTransport.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrTransport.kt @@ -46,6 +46,11 @@ class NostrTransport( // MARK: - Transport Interface Methods val myPeerID: String get() = senderPeerID + + fun canDeliverPromptly(): Boolean = try { + NostrRelayManager.getInstance(context) + .hasConnectedRelay(NostrRelayManager.defaultRelays()) + } catch (_: Exception) { false } fun sendPrivateMessage( content: String, diff --git a/app/src/main/java/com/bitchat/android/protocol/BinaryProtocol.kt b/app/src/main/java/com/bitchat/android/protocol/BinaryProtocol.kt index 6a0445b0..aea072e4 100644 --- a/app/src/main/java/com/bitchat/android/protocol/BinaryProtocol.kt +++ b/app/src/main/java/com/bitchat/android/protocol/BinaryProtocol.kt @@ -14,6 +14,7 @@ enum class MessageType(val value: UByte) { ANNOUNCE(0x01u), MESSAGE(0x02u), // All user messages (private and broadcast) LEAVE(0x03u), + COURIER_ENVELOPE(0x04u), // Opaque Noise X store-and-forward envelope NOISE_HANDSHAKE(0x10u), // Noise handshake NOISE_ENCRYPTED(0x11u), // Noise encrypted transport message FRAGMENT(0x20u), // Fragmentation for large packets diff --git a/app/src/main/java/com/bitchat/android/service/TransportBridgeService.kt b/app/src/main/java/com/bitchat/android/service/TransportBridgeService.kt index 67a7a1d8..ab0baeb6 100644 --- a/app/src/main/java/com/bitchat/android/service/TransportBridgeService.kt +++ b/app/src/main/java/com/bitchat/android/service/TransportBridgeService.kt @@ -44,6 +44,9 @@ object TransportBridgeService { * Send a packet to a specific peer via this transport (optional). */ fun sendToPeer(peerID: String, packet: BitchatPacket) { } + + /** Send directly and report whether the transport accepted the write. */ + fun sendToPeerAndReport(peerID: String, packet: BitchatPacket): Boolean = false } private val transports = ConcurrentHashMap() @@ -200,6 +203,19 @@ object TransportBridgeService { } } + fun sendToPeerFromLocalAndReport(peerID: String, packet: BitchatPacket): Boolean { + val targets = transports.toMap() + if (targets.isEmpty()) return false + return targets.values.fold(false) { accepted, layer -> + try { + layer.sendToPeerAndReport(peerID, packet) || accepted + } catch (e: Exception) { + Log.e(TAG, "Failed to send local peer packet: ${e.message}") + accepted + } + } + } + private fun prepareForwardedPacket(kind: String, packet: BitchatPacket): PreparedForward? { if (packet.ttl == 0u.toUByte()) { Log.d(TAG, "Dropping bridged packet type ${packet.type}: TTL expired") 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 c4f52675..d229da3e 100644 --- a/app/src/main/java/com/bitchat/android/services/AppStateStore.kt +++ b/app/src/main/java/com/bitchat/android/services/AppStateStore.kt @@ -411,10 +411,11 @@ object AppStateStore { private fun statusPriority(status: DeliveryStatus?): Int = when (status) { null -> 0 is DeliveryStatus.Sending -> 1 - is DeliveryStatus.Sent -> 2 - is DeliveryStatus.PartiallyDelivered -> 3 - is DeliveryStatus.Delivered -> 4 - is DeliveryStatus.Read -> 5 + is DeliveryStatus.Queued -> 2 + is DeliveryStatus.Sent -> 3 + is DeliveryStatus.PartiallyDelivered -> 4 + is DeliveryStatus.Delivered -> 5 + is DeliveryStatus.Read -> 6 is DeliveryStatus.Failed -> 0 } diff --git a/app/src/main/java/com/bitchat/android/services/ContactDirectory.kt b/app/src/main/java/com/bitchat/android/services/ContactDirectory.kt index 7c733958..a025c1b8 100644 --- a/app/src/main/java/com/bitchat/android/services/ContactDirectory.kt +++ b/app/src/main/java/com/bitchat/android/services/ContactDirectory.kt @@ -72,6 +72,9 @@ object ContactDirectory { favorite != null -> favorite.peerNoisePublicKey ContactIdentityResolver.isNoiseKeyHex(peerOrConversationID) -> ContactIdentityResolver.bytesFromHex(peerOrConversationID) + contactFingerprint != null -> cachedNoiseKeyForFingerprint(contactFingerprint) + ContactIdentityResolver.isMeshPeerId(peerOrConversationID) -> + noiseKeyForAlias(peerOrConversationID) else -> null } val liveMeshPeerID = contactFingerprint?.let { findLiveMeshPeerForFingerprint(it) } @@ -156,6 +159,18 @@ object ContactDirectory { } } + /** + * A contact conversation ID contains the SHA-256 fingerprint of the Noise key, whose + * first 16 hex characters are the stable mesh peer ID. Recovering that cached key keeps + * offline routing viable after the outbox has canonicalized a peer ID to `contact_…`. + * Recompute the fingerprint before returning it so stale or mismatched cache entries cannot + * redirect a private message. + */ + private fun cachedNoiseKeyForFingerprint(fingerprint: String): ByteArray? = + cachedNoiseKey(fingerprint.take(16))?.takeIf { + ContactIdentityResolver.fingerprintHex(it).equals(fingerprint, ignoreCase = true) + } + private fun cachedFingerprintNickname(fingerprint: String): String? { val context = appContext ?: return null return try { diff --git a/app/src/main/java/com/bitchat/android/services/ConversationRepository.kt b/app/src/main/java/com/bitchat/android/services/ConversationRepository.kt index bc474158..a6b849d7 100644 --- a/app/src/main/java/com/bitchat/android/services/ConversationRepository.kt +++ b/app/src/main/java/com/bitchat/android/services/ConversationRepository.kt @@ -1684,6 +1684,7 @@ internal class ConversationDatabase( null -> put("delivery_type", 0) DeliveryStatus.Sending -> put("delivery_type", 1) DeliveryStatus.Sent -> put("delivery_type", 2) + DeliveryStatus.Queued -> put("delivery_type", 7) is DeliveryStatus.Delivered -> { put("delivery_type", 3) if (includeSensitiveText) put("delivery_text", status.to) else putNull("delivery_text") @@ -1710,10 +1711,11 @@ internal class ConversationDatabase( null -> 0 is DeliveryStatus.Failed -> 0 DeliveryStatus.Sending -> 1 - DeliveryStatus.Sent -> 2 - is DeliveryStatus.PartiallyDelivered -> 3 - is DeliveryStatus.Delivered -> 4 - is DeliveryStatus.Read -> 5 + DeliveryStatus.Queued -> 2 + DeliveryStatus.Sent -> 3 + is DeliveryStatus.PartiallyDelivered -> 4 + is DeliveryStatus.Delivered -> 5 + is DeliveryStatus.Read -> 6 } private fun Cursor.toMessage(): BitchatMessage { @@ -1781,6 +1783,7 @@ internal class ConversationDatabase( reached = nullableInt("delivery_reached") ?: 0, total = nullableInt("delivery_total") ?: 0 ) + 7 -> DeliveryStatus.Queued else -> null } diff --git a/app/src/main/java/com/bitchat/android/services/MessageOutboxStore.kt b/app/src/main/java/com/bitchat/android/services/MessageOutboxStore.kt new file mode 100644 index 00000000..687de6bc --- /dev/null +++ b/app/src/main/java/com/bitchat/android/services/MessageOutboxStore.kt @@ -0,0 +1,73 @@ +package com.bitchat.android.services + +import android.content.Context +import com.google.gson.Gson +import com.google.gson.reflect.TypeToken +import java.io.File +import java.nio.file.StandardCopyOption + +/** Keystore-sealed persistence for private messages awaiting final acknowledgement. */ +internal class MessageOutboxStore( + context: Context, + private val cipher: ConversationStorageCipher = AndroidConversationStorageCipher(KEY_ALIAS) +) { + data class Entry( + val content: String, + val nickname: String, + val messageID: String, + val enqueuedAtMs: Long, + var sendAttempts: Int = 0, + var lastAttemptAtMs: Long = 0, + var bridgeDeposited: Boolean = false, + var lastBridgeAttemptAtMs: Long = 0, + val depositedCourierKeys: MutableSet = mutableSetOf() + ) + + companion object { + private const val KEY_ALIAS = "bitchat_message_outbox_v1" + private val AAD = "bitchat-message-outbox-v1".toByteArray(Charsets.UTF_8) + } + + private val gson = Gson() + private val file = File(context.applicationContext.filesDir, "message-outbox.sealed") + + @Synchronized + fun load(): MutableMap> = try { + if (!file.exists()) return mutableMapOf() + val plaintext = cipher.decrypt(file.readBytes(), AAD) + val type = object : TypeToken>>() {}.type + gson.fromJson>>(plaintext.toString(Charsets.UTF_8), type) + ?: mutableMapOf() + } catch (_: Exception) { + mutableMapOf() + } + + @Synchronized + fun save(outbox: Map>) { + if (outbox.values.all { it.isEmpty() }) { + file.delete() + return + } + val plaintext = gson.toJson(outbox).toByteArray(Charsets.UTF_8) + val encrypted = cipher.encrypt(plaintext, AAD) + val temporary = File(file.parentFile, "${file.name}.tmp") + temporary.writeBytes(encrypted) + try { + java.nio.file.Files.move( + temporary.toPath(), + file.toPath(), + StandardCopyOption.ATOMIC_MOVE, + StandardCopyOption.REPLACE_EXISTING + ) + } catch (e: Exception) { + temporary.delete() + throw IllegalStateException("Failed to persist message outbox", e) + } + } + + @Synchronized + fun wipe() { + file.delete() + cipher.destroyKey() + } +} 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 dc6ad4d2..ee83cf07 100644 --- a/app/src/main/java/com/bitchat/android/services/MessageRouter.kt +++ b/app/src/main/java/com/bitchat/android/services/MessageRouter.kt @@ -31,13 +31,6 @@ class MessageRouter private constructor( DROPPED } - private data class QueuedMessage( - val content: String, - val nickname: String, - val messageID: String, - val enqueuedAtMs: Long - ) - private data class ConversationRetry( val handshakeAttempts: Int, val nextHandshakeAttemptAtMs: Long @@ -48,10 +41,15 @@ class MessageRouter private constructor( private const val OUTBOX_TICK_MS = AppConstants.Router.OUTBOX_TICK_MS private const val OUTBOX_MESSAGE_TTL_MS = AppConstants.Router.OUTBOX_MESSAGE_TTL_MS private const val OUTBOX_MAX_PER_PEER = AppConstants.Router.OUTBOX_MAX_PER_PEER + private const val MAX_COURIERS_PER_MESSAGE = AppConstants.Router.MAX_COURIERS_PER_MESSAGE + private const val OUTBOX_MAX_TOTAL = 1_000 + private const val OUTBOX_MAX_SEND_ATTEMPTS = 8 + private const val BRIDGE_RETRY_COOLDOWN_MS = 30 * 60 * 1000L private val HANDSHAKE_RETRY_BACKOFF_MS = AppConstants.Router.HANDSHAKE_RETRY_BACKOFF_MS @Volatile private var INSTANCE: MessageRouter? = null internal var disableSchedulerForTesting = false + internal var outboxStoreFactory: (Context) -> MessageOutboxStore = ::MessageOutboxStore fun tryGetInstance(): MessageRouter? = INSTANCE fun getInstance(context: Context, mesh: MeshService): MessageRouter { val instance = INSTANCE ?: synchronized(this) { @@ -78,10 +76,19 @@ class MessageRouter private constructor( INSTANCE?.schedulerScope?.cancel() INSTANCE = null } + + fun panicClear(context: Context) { + val instance = INSTANCE + if (instance != null) instance.clearAll() + else outboxStoreFactory(context.applicationContext).wipe() + } } // Outbox: conversationID -> queued messages, oldest first - private val outbox = ConcurrentHashMap>() + private val outboxStore = outboxStoreFactory(context) + private val outbox = ConcurrentHashMap>().apply { + putAll(outboxStore.load()) + } // Per-conversation handshake retry state for queued messages private val retryState = ConcurrentHashMap() @@ -99,9 +106,13 @@ class MessageRouter private constructor( startOutboxScheduler() } + @Synchronized fun clearAll() { + schedulerJob?.cancel() + schedulerJob = null outbox.clear() retryState.clear() + outboxStore.wipe() Log.d(TAG, "Cleared all MessageRouter outbox messages and retry state") } @@ -134,17 +145,23 @@ class MessageRouter private constructor( } val hasMesh = meshTarget?.let { isConnected(mesh, it) } == true + val entry = MessageOutboxStore.Entry(content, recipientNickname, messageID, clock()) + enqueue(conversationID, entry) if (meshTarget != null && isReady(mesh, meshTarget)) { Log.d(TAG, "Routing PM via mesh to ${meshTarget} msg_id=${messageID.take(8)}…") mesh.sendPrivateMessage(content, meshTarget, recipientNickname, messageID) + markAttempt(conversationID, messageID) return RouteResult.MESH } else if (canSendViaNostr(nostrTarget)) { 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 + markAttempt(conversationID, messageID) + val prompt = canDeliverViaNostrPromptly() + if (!prompt) attemptCourierDeposit(conversationID, entry) + return if (prompt) RouteResult.NOSTR else RouteResult.QUEUED } else { Log.d(TAG, "Queued PM for ${conversationID} (no mesh, no Nostr mapping) msg_id=${messageID.take(8)}…") - enqueue(conversationID, QueuedMessage(content, recipientNickname, messageID, clock())) + attemptCourierDeposit(conversationID, entry) Log.d(TAG, "Initiating noise handshake after queueing PM for ${conversationID.take(16)}…") if (hasMesh) meshTarget?.let { kickHandshake(conversationID, it, immediate = true) } return RouteResult.QUEUED @@ -209,12 +226,17 @@ class MessageRouter private constructor( val resolution = ContactDirectory.resolve(conversationID) val meshTarget = resolution.meshPeerID val nostrTarget = resolution.noiseKeyHex ?: conversationID + if (clock() - entry.lastAttemptAtMs < retryDelay(entry.sendAttempts)) continue + if (entry.sendAttempts >= OUTBOX_MAX_SEND_ATTEMPTS) continue if (meshTarget != null && isReady(mesh, meshTarget)) { mesh.sendPrivateMessage(entry.content, meshTarget, entry.nickname, entry.messageID) - iterator.remove() + entry.sendAttempts++ + entry.lastAttemptAtMs = clock() } else if (canSendViaNostr(nostrTarget)) { nostr.sendPrivateMessage(entry.content, nostrTarget, entry.nickname, entry.messageID) - iterator.remove() + entry.sendAttempts++ + entry.lastAttemptAtMs = clock() + if (!canDeliverViaNostrPromptly()) attemptCourierDeposit(conversationID, entry) } } if (queued.isEmpty()) { @@ -223,6 +245,7 @@ class MessageRouter private constructor( retryState.remove(conversationID) retryState.remove(peerID) } + persistOutbox() } // Flush everything (rarely used) @@ -231,14 +254,120 @@ class MessageRouter private constructor( } @Synchronized - private fun enqueue(conversationID: String, entry: QueuedMessage) { + private fun enqueue(conversationID: String, entry: MessageOutboxStore.Entry) { val queue = outbox.getOrPut(conversationID) { mutableListOf() } + if (queue.any { it.messageID == entry.messageID }) return queue.add(entry) while (queue.size > OUTBOX_MAX_PER_PEER) { val evicted = queue.removeAt(0) Log.w(TAG, "Outbox full for ${conversationID.take(16)}…; evicting oldest msg_id=${evicted.messageID.take(8)}…") notifyExpired(evicted.messageID) } + while (outbox.values.sumOf { it.size } > OUTBOX_MAX_TOTAL) { + val oldest = outbox.entries + .flatMap { (id, entries) -> entries.map { id to it } } + .minByOrNull { it.second.enqueuedAtMs } ?: break + outbox[oldest.first]?.remove(oldest.second) + if (outbox[oldest.first].isNullOrEmpty()) outbox.remove(oldest.first) + notifyExpired(oldest.second.messageID) + } + persistOutbox() + } + + @Synchronized + fun onMessageAcknowledged(messageID: String, peerID: String) { + val acknowledgedConversation = ContactDirectory.canonicalConversationId(peerID) + var changed = false + outbox.entries.toList().forEach { (conversationID, queue) -> + if (ContactDirectory.canonicalConversationId(conversationID) != acknowledgedConversation) return@forEach + changed = queue.removeAll { it.messageID == messageID } || changed + if (queue.isEmpty()) { + outbox.remove(conversationID, queue) + retryState.remove(conversationID) + } + } + if (changed) persistOutbox() + } + + @Synchronized + private fun markAttempt(conversationID: String, messageID: String) { + outbox[conversationID]?.firstOrNull { it.messageID == messageID }?.sendAttempts = + (outbox[conversationID]?.firstOrNull { it.messageID == messageID }?.sendAttempts ?: 0) + 1 + outbox[conversationID]?.firstOrNull { it.messageID == messageID }?.lastAttemptAtMs = clock() + persistOutbox() + } + + private fun retryDelay(attempts: Int): Long = when (attempts) { + 0 -> 0L + 1 -> 30_000L + 2 -> 2 * 60_000L + else -> 10 * 60_000L + } + + private fun attemptCourierDeposit(conversationID: String, entry: MessageOutboxStore.Entry) { + val resolution = ContactDirectory.resolve(conversationID) + val recipientKey = resolution.noiseKeyHex?.let(ContactIdentityResolver::bytesFromHex) ?: return + if (!entry.bridgeDeposited && + (entry.lastBridgeAttemptAtMs == 0L || clock() - entry.lastBridgeAttemptAtMs >= BRIDGE_RETRY_COOLDOWN_MS) + ) { + val submitted = mesh.sendBridgeCourierMessage(entry.content, entry.messageID, recipientKey) { + synchronized(this) { + val current = outbox[conversationID]?.firstOrNull { it.messageID == entry.messageID } + if (current != null) { + current.bridgeDeposited = true + persistOutbox() + } + } + } + if (submitted) { + entry.lastBridgeAttemptAtMs = clock() + persistOutbox() + } + } + if (entry.depositedCourierKeys.size >= MAX_COURIERS_PER_MESSAGE) return + val candidates = mesh.getPeerInfos() + .asSequence() + .filter { it.isConnected && it.noisePublicKey != null && !it.noisePublicKey!!.contentEquals(recipientKey) } + .filter { peer -> + val favorite = try { + com.bitchat.android.favorites.FavoritesPersistenceService.shared + .getFavoriteStatus(peer.noisePublicKey!!)?.isMutual == true + } catch (_: Exception) { false } + favorite || peer.hasVerifiedAnnouncement + } + .map { peer -> + val favorite = try { + com.bitchat.android.favorites.FavoritesPersistenceService.shared + .getFavoriteStatus(peer.noisePublicKey!!)?.isMutual == true + } catch (_: Exception) { false } + peer to favorite + } + .filter { (peer, _) -> ContactIdentityResolver.noiseKeyHex(peer.noisePublicKey!!) !in entry.depositedCourierKeys } + .sortedByDescending { (_, favorite) -> favorite } + .take(MAX_COURIERS_PER_MESSAGE - entry.depositedCourierKeys.size) + .map { (peer, _) -> peer } + .toList() + if (candidates.isEmpty()) return + val accepted = mesh.sendCourierMessage( + entry.content, + entry.messageID, + recipientKey, + candidates.map { it.id } + ).toSet() + candidates.filter { it.id in accepted }.forEach { + entry.depositedCourierKeys += ContactIdentityResolver.noiseKeyHex(it.noisePublicKey!!) + } + if (accepted.isNotEmpty()) persistOutbox() + } + + private fun canDeliverViaNostrPromptly(): Boolean = try { + nostr.canDeliverPromptly() + } catch (_: Exception) { false } + + private fun persistOutbox() { + try { outboxStore.save(outbox) } catch (e: Exception) { + Log.e(TAG, "Failed to persist sealed outbox: ${e.message}") + } } private fun notifyExpired(messageID: String) { @@ -286,6 +415,7 @@ class MessageRouter private constructor( * follow the MeshForegroundService lifecycle; getInstance restarts the scheduler * and rebinds the mesh reference when the service comes back. */ + @Synchronized fun stopOutboxScheduler() { schedulerJob?.cancel() schedulerJob = null @@ -304,6 +434,7 @@ class MessageRouter private constructor( expireOldEntries(conversationID, nowMs) val queued = outbox[conversationID] ?: return@forEach if (queued.isEmpty()) return@forEach + queued.forEach { attemptCourierDeposit(conversationID, it) } val resolution = ContactDirectory.resolve(conversationID) val meshTarget = resolution.meshPeerID @@ -338,6 +469,7 @@ class MessageRouter private constructor( outbox.remove(conversationID, queued) retryState.remove(conversationID) } + persistOutbox() } private fun canSendViaNostr(peerID: String): Boolean { @@ -385,8 +517,18 @@ class MessageRouter private constructor( } catch (_: Exception) { null } noiseHex?.let { kickHandshakeIfPending(it) - flushOutboxFor(it) + if (ContactDirectory.canonicalConversationId(it) != ContactDirectory.canonicalConversationId(pid)) { + flushOutboxFor(it) + } } + retryCourierDeposits() + } + } + + @Synchronized + private fun retryCourierDeposits() { + outbox.forEach { (conversationID, entries) -> + entries.forEach { attemptCourierDeposit(conversationID, it) } } } @@ -399,7 +541,9 @@ class MessageRouter private constructor( } catch (_: Exception) { null } noiseHex?.let { resetRetry(it) - flushOutboxFor(it) + if (ContactDirectory.canonicalConversationId(it) != ContactDirectory.canonicalConversationId(peerID)) { + flushOutboxFor(it) + } } } diff --git a/app/src/main/java/com/bitchat/android/sync/GCSFilter.kt b/app/src/main/java/com/bitchat/android/sync/GCSFilter.kt index 212def6a..9d5b9fa2 100644 --- a/app/src/main/java/com/bitchat/android/sync/GCSFilter.kt +++ b/app/src/main/java/com/bitchat/android/sync/GCSFilter.kt @@ -21,9 +21,12 @@ object GCSFilter { data class Params( val p: Int, // Golomb-Rice parameter (>= 1) val m: Long, // Range M = N * 2^P - val data: ByteArray // Encoded GR bitstream + val data: ByteArray, // Encoded GR bitstream + val includedCount: Int // Number of newest-first input IDs actually encoded ) + const val MAX_P = 32 + // Derive P from target FPR; FPR ~= 1 / 2^P fun deriveP(targetFpr: Double): Int { val f = targetFpr.coerceIn(0.000001, 0.25) @@ -66,7 +69,12 @@ object GCSFilter { encoded = encode(mapped, p) } - return Params(p = p, m = finalM, data = encoded) + return Params( + p = p, + m = finalM, + data = encoded, + includedCount = if (encoded.isEmpty()) 0 else trimmedN + ) } fun decodeToSortedSet(p: Int, m: Long, data: ByteArray): LongArray { @@ -196,4 +204,3 @@ object GCSFilter { } } } - diff --git a/app/src/main/java/com/bitchat/android/sync/GossipSyncManager.kt b/app/src/main/java/com/bitchat/android/sync/GossipSyncManager.kt index 42a10ceb..eed9a505 100644 --- a/app/src/main/java/com/bitchat/android/sync/GossipSyncManager.kt +++ b/app/src/main/java/com/bitchat/android/sync/GossipSyncManager.kt @@ -8,6 +8,9 @@ import com.bitchat.android.protocol.MessageType import com.bitchat.android.protocol.SpecialRecipients import kotlinx.coroutines.* import java.util.concurrent.ConcurrentHashMap +import android.content.Context +import java.io.File +import java.nio.file.StandardCopyOption /** * Gossip-based synchronization manager using on-demand GCS filters. @@ -17,7 +20,8 @@ import java.util.concurrent.ConcurrentHashMap class GossipSyncManager( private val myPeerID: String, private val scope: CoroutineScope, - private val configProvider: ConfigProvider + private val configProvider: ConfigProvider, + context: Context? = null ) { interface Delegate { fun sendPacket(packet: BitchatPacket) @@ -33,6 +37,10 @@ class GossipSyncManager( companion object { private const val TAG = "GossipSyncManager" + const val PUBLIC_MESSAGE_MAX_AGE_MS = 6 * 60 * 60 * 1000L + const val FRAGMENT_MAX_AGE_MS = 15 * 60 * 1000L + const val PUBLIC_PACKET_FUTURE_SKEW_MS = 10 * 60 * 1000L + private const val ARCHIVE_FILE = "gossip-public-history.bin" } var delegate: Delegate? = null @@ -44,11 +52,15 @@ class GossipSyncManager( // Stored packets for sync: // - broadcast messages: keep up to seenCapacity() most recent, keyed by packetId private val messages = LinkedHashMap() + private val fragments = LinkedHashMap() + private val archiveFile = context?.applicationContext?.filesDir?.let { File(it, ARCHIVE_FILE) } + private var restoringArchive = false // - announcements: only keep latest per sender peerID private val latestAnnouncementByPeer = ConcurrentHashMap>() private var periodicJob: Job? = null private var cleanupJob: Job? = null + init { restoreArchive() } fun start() { periodicJob?.cancel() periodicJob = scope.launch(Dispatchers.IO) { @@ -84,7 +96,9 @@ class GossipSyncManager( synchronized(messages) { messages.clear() } + synchronized(fragments) { fragments.clear() } latestAnnouncementByPeer.clear() + archiveFile?.delete() Log.d(TAG, "Cleared all gossip sync messages and announcements") } @@ -106,13 +120,20 @@ class GossipSyncManager( // Only ANNOUNCE or broadcast MESSAGE val mt = MessageType.fromValue(packet.type) val isBroadcastMessage = (mt == MessageType.MESSAGE && (packet.recipientID == null || packet.recipientID.contentEquals(SpecialRecipients.BROADCAST))) + val isBroadcastFile = mt == MessageType.FILE_TRANSFER && + (packet.recipientID == null || packet.recipientID.contentEquals(SpecialRecipients.BROADCAST)) val isAnnouncement = (mt == MessageType.ANNOUNCE) - if (!isBroadcastMessage && !isAnnouncement) return + val isFragment = (mt == MessageType.FRAGMENT || isBroadcastFile) && + (packet.recipientID == null || packet.recipientID.contentEquals(SpecialRecipients.BROADCAST)) + if (!isBroadcastMessage && !isAnnouncement && !isFragment) return val idBytes = PacketIdUtil.computeIdBytes(packet) val id = idBytes.joinToString("") { b -> "%02x".format(b) } if (isBroadcastMessage) { + val now = System.currentTimeMillis() + val age = now - packet.timestamp.toLong() + if (age !in -PUBLIC_PACKET_FUTURE_SKEW_MS..PUBLIC_MESSAGE_MAX_AGE_MS) return synchronized(messages) { messages[id] = packet // Enforce capacity (remove oldest when exceeded) @@ -122,6 +143,17 @@ class GossipSyncManager( if (it.hasNext()) { it.next(); it.remove() } else break } } + if (!restoringArchive) persistArchive() + } else if (isFragment) { + val age = System.currentTimeMillis() - packet.timestamp.toLong() + if (age !in -PUBLIC_PACKET_FUTURE_SKEW_MS..FRAGMENT_MAX_AGE_MS) return + synchronized(fragments) { + fragments[id] = packet + while (fragments.size > configProvider.seenCapacity().coerceAtLeast(1)) { + val iterator = fragments.entries.iterator() + if (iterator.hasNext()) { iterator.next(); iterator.remove() } else break + } + } } else if (isAnnouncement) { // Ignore stale announcements older than STALE_PEER_TIMEOUT val now = System.currentTimeMillis() @@ -143,22 +175,27 @@ class GossipSyncManager( } private fun sendRequestSync() { - val payload = buildGcsPayload() - - val packet = BitchatPacket( - type = MessageType.REQUEST_SYNC.value, - senderID = hexStringToByteArray(myPeerID), - timestamp = System.currentTimeMillis().toULong(), - payload = payload, - ttl = com.bitchat.android.util.AppConstants.SYNC_TTL_HOPS // neighbors only - ) - // Sign and broadcast - val signed = delegate?.signPacketForBroadcast(packet) ?: packet - delegate?.sendPacket(signed) + listOf( + SyncTypeFlags.PUBLIC_MESSAGES, + SyncTypeFlags.FRAGMENT, + SyncTypeFlags.FILE_TRANSFER + ).forEach { types -> + val payload = buildGcsPayload(types) + val packet = BitchatPacket( + type = MessageType.REQUEST_SYNC.value, + senderID = hexStringToByteArray(myPeerID), + timestamp = System.currentTimeMillis().toULong(), + payload = payload, + ttl = com.bitchat.android.util.AppConstants.SYNC_TTL_HOPS // neighbors only + ) + val signed = delegate?.signPacketForBroadcast(packet) ?: packet + delegate?.sendPacket(signed) + } } private fun sendRequestSyncToPeer(peerID: String) { - val payload = buildGcsPayload() + val types = SyncTypeFlags.PUBLIC_MESSAGES.union(SyncTypeFlags.FRAGMENTS_AND_FILES) + val payload = buildGcsPayload(types) val packet = BitchatPacket( type = MessageType.REQUEST_SYNC.value, @@ -175,6 +212,8 @@ class GossipSyncManager( } fun handleRequestSync(fromPeerID: String, request: RequestSyncPacket) { + val requestedTypes = request.types ?: SyncTypeFlags.PUBLIC_MESSAGES + val sinceTimestamp = request.sinceTimestamp // Decode GCS into sorted set for membership checks val sorted = GCSFilter.decodeToSortedSet(request.p, request.m, request.data) fun mightContain(id: ByteArray): Boolean { @@ -183,26 +222,41 @@ class GossipSyncManager( return GCSFilter.contains(sorted, nonZeroV) } - // 1) Announcements: send latest per peerID if remote doesn't have them - for ((_, pair) in latestAnnouncementByPeer.entries) { - val (id, pkt) = pair - val idBytes = hexToBytes(id) - if (!mightContain(idBytes)) { - // Send original packet unchanged to requester only (keep local TTL) - val toSend = pkt.copy(ttl = com.bitchat.android.util.AppConstants.SYNC_TTL_HOPS) - delegate?.sendPacketToPeer(fromPeerID, toSend) - Log.d(TAG, "Sent sync announce: Type ${toSend.type} from ${toSend.senderID.toHexString()} to $fromPeerID packet id ${idBytes.toHexString()}") + // Announcements are exempt from the cursor: only the latest per peer is retained, + // and peers need their signing keys before they can verify other sync responses. + if (requestedTypes.contains(MessageType.ANNOUNCE)) { + for ((_, pair) in latestAnnouncementByPeer.entries) { + val (id, pkt) = pair + val idBytes = hexToBytes(id) + if (!mightContain(idBytes)) { + val toSend = pkt.copy(ttl = com.bitchat.android.util.AppConstants.SYNC_TTL_HOPS) + delegate?.sendPacketToPeer(fromPeerID, toSend) + Log.d(TAG, "Sent sync announce: Type ${toSend.type} from ${toSend.senderID.toHexString()} to $fromPeerID packet id ${idBytes.toHexString()}") + } } } - // 2) Broadcast messages: send all they lack - val toSendMsgs = synchronized(messages) { messages.values.toList() } - for (pkt in toSendMsgs) { + if (requestedTypes.contains(MessageType.MESSAGE)) { + val toSendMsgs = synchronized(messages) { messages.values.toList() } + for (pkt in toSendMsgs) { + if (sinceTimestamp != null && pkt.timestamp < sinceTimestamp) continue + val idBytes = PacketIdUtil.computeIdBytes(pkt) + if (!mightContain(idBytes)) { + val toSend = pkt.copy(ttl = com.bitchat.android.util.AppConstants.SYNC_TTL_HOPS) + delegate?.sendPacketToPeer(fromPeerID, toSend) + Log.d(TAG, "Sent sync message: Type ${toSend.type} to $fromPeerID packet id ${idBytes.toHexString()}") + } + } + } + + val toSendFragments = synchronized(fragments) { fragments.values.toList() } + for (pkt in toSendFragments) { + val type = MessageType.fromValue(pkt.type) ?: continue + if (!requestedTypes.contains(type)) continue + if (sinceTimestamp != null && pkt.timestamp < sinceTimestamp) continue val idBytes = PacketIdUtil.computeIdBytes(pkt) if (!mightContain(idBytes)) { - val toSend = pkt.copy(ttl = com.bitchat.android.util.AppConstants.SYNC_TTL_HOPS) - delegate?.sendPacketToPeer(fromPeerID, toSend) - Log.d(TAG, "Sent sync message: Type ${toSend.type} to $fromPeerID packet id ${idBytes.toHexString()}") + delegate?.sendPacketToPeer(fromPeerID, pkt.copy(ttl = com.bitchat.android.util.AppConstants.SYNC_TTL_HOPS)) } } } @@ -232,16 +286,23 @@ class GossipSyncManager( return out } - private fun buildGcsPayload(): ByteArray { - // Collect candidates: latest announcement per peer + recent broadcast messages + internal fun buildGcsPayload(types: SyncTypeFlags): ByteArray { + // Collect only the packet types represented by this filter. val list = ArrayList() - // announcements - for ((_, pair) in latestAnnouncementByPeer) { - list.add(pair.second) + if (types.contains(MessageType.ANNOUNCE)) { + for ((_, pair) in latestAnnouncementByPeer) { + list.add(pair.second) + } } - // messages - synchronized(messages) { - list.addAll(messages.values) + if (types.contains(MessageType.MESSAGE)) { + synchronized(messages) { + list.addAll(messages.values) + } + } + synchronized(fragments) { + list.addAll(fragments.values.filter { packet -> + MessageType.fromValue(packet.type)?.let(types::contains) == true + }) } // sort by timestamp desc, then take up to min(seenCapacity, fit capacity) list.sortByDescending { it.timestamp.toLong() } @@ -254,15 +315,24 @@ class GossipSyncManager( val takeN = minOf(nMax, cap, list.size) if (takeN <= 0) { val p0 = GCSFilter.deriveP(fpr) - return RequestSyncPacket(p = p0, m = 1, data = ByteArray(0)).encode() + return RequestSyncPacket(p = p0, m = 1, data = ByteArray(0), types = types).encode() } - val ids = list.take(takeN).map { pkt -> PacketIdUtil.computeIdBytes(pkt) } + val included = list.take(takeN) + val ids = included.map { pkt -> PacketIdUtil.computeIdBytes(pkt) } val params = GCSFilter.buildFilter(ids, maxBytes, fpr) val mVal = if (params.m <= 0L) 1 else params.m - return RequestSyncPacket(p = params.p, m = mVal, data = params.data).encode() + val covered = params.includedCount + val sinceTimestamp = if (covered in 1 until list.size) included[covered - 1].timestamp else null + return RequestSyncPacket( + p = params.p, + m = mVal, + data = params.data, + types = types, + sinceTimestamp = sinceTimestamp + ).encode() } - // Periodically remove stale announcements and all their messages + // Announcements age out quickly; public history remains independently sync-able for six hours. private fun pruneStaleAnnouncements() { val now = System.currentTimeMillis() val stalePeers = mutableListOf() @@ -276,26 +346,17 @@ class GossipSyncManager( } } - if (stalePeers.isEmpty()) return - - // Remove announcements and their messages - var totalPrunedMsgs = 0 + var changed = false for (peerID in stalePeers) { - // Count messages to be pruned for logging - val toRemove = mutableListOf() - synchronized(messages) { - for ((id, message) in messages) { - val sender = message.senderID.joinToString("") { b -> "%02x".format(b) } - if (sender == peerID) toRemove.add(id) - } - } - totalPrunedMsgs += toRemove.size - - // Reuse existing removal which also clears announcement entry - removeAnnouncementForPeer(peerID) + changed = latestAnnouncementByPeer.remove(peerID) != null || changed } - - Log.d(TAG, "Pruned ${stalePeers.size} stale announcements and $totalPrunedMsgs messages") + synchronized(messages) { + changed = messages.entries.removeAll { now - it.value.timestamp.toLong() > PUBLIC_MESSAGE_MAX_AGE_MS } || changed + } + synchronized(fragments) { + fragments.entries.removeAll { now - it.value.timestamp.toLong() > FRAGMENT_MAX_AGE_MS } + } + if (changed) persistArchive() } // Explicitly remove stored announcement for a given peer (hex ID) @@ -305,26 +366,63 @@ class GossipSyncManager( Log.d(TAG, "Removed stored announcement for peer $peerID") } - // Collect IDs to remove first to avoid modifying collection while iterating - val idsToRemove = mutableListOf() - synchronized(messages) { - for ((id, message) in messages) { - val sender = message.senderID.joinToString("") { b -> "%02x".format(b) } - if (sender == key) { - idsToRemove.add(id) - } + } + + private fun restoreArchive() { + val file = archiveFile ?: return + if (!file.exists()) return + try { + restoringArchive = true + val input = java.io.DataInputStream(file.inputStream().buffered()) + val count = input.readInt().coerceIn(0, configProvider.seenCapacity()) + repeat(count) { + val length = input.readInt() + if (length <= 0 || length > com.bitchat.android.util.AppConstants.Protocol.MAX_PAYLOAD_LENGTH + 256) return@repeat + val packet = com.bitchat.android.protocol.BinaryProtocol.decode(input.readNBytes(length)) ?: return@repeat + onPublicPacketSeen(packet) } - } - - // Now remove the collected IDs - synchronized(messages) { - for (id in idsToRemove) { - messages.remove(id) - } - } - - if (idsToRemove.isNotEmpty()) { - Log.d(TAG, "Pruned ${idsToRemove.size} messages with senders without announcements") + input.close() + } catch (_: Exception) { + synchronized(messages) { messages.clear() } + } finally { + restoringArchive = false } } + + @Synchronized + private fun persistArchive() { + val file = archiveFile ?: return + try { + val packets = synchronized(messages) { messages.values.toList() } + val temporary = File(file.parentFile, "${file.name}.tmp") + java.io.DataOutputStream(temporary.outputStream().buffered()).use { output -> + output.writeInt(packets.size) + packets.forEach { packet -> + val encoded = com.bitchat.android.protocol.BinaryProtocol.encode(packet, padding = false) ?: return@forEach + output.writeInt(encoded.size) + output.write(encoded) + } + } + try { + java.nio.file.Files.move( + temporary.toPath(), + file.toPath(), + StandardCopyOption.ATOMIC_MOVE, + StandardCopyOption.REPLACE_EXISTING + ) + } catch (_: Exception) { + // Some Android filesystems do not support ATOMIC_MOVE. Preserve the durable + // public-history guarantee with a regular replacement move before giving up. + try { + java.nio.file.Files.move( + temporary.toPath(), + file.toPath(), + StandardCopyOption.REPLACE_EXISTING + ) + } catch (_: Exception) { + temporary.delete() + } + } + } catch (_: Exception) { } + } } diff --git a/app/src/main/java/com/bitchat/android/sync/SyncTypeFlags.kt b/app/src/main/java/com/bitchat/android/sync/SyncTypeFlags.kt new file mode 100644 index 00000000..ab521be7 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/sync/SyncTypeFlags.kt @@ -0,0 +1,68 @@ +package com.bitchat.android.sync + +import com.bitchat.android.protocol.MessageType + +/** Compact little-endian bitfield matching iOS SyncTypeFlags. */ +@JvmInline +value class SyncTypeFlags private constructor(val rawValue: ULong) { + companion object { + private const val KNOWN_TYPE_MASK: ULong = 0xffu + + val ANNOUNCE = fromMessageTypes(MessageType.ANNOUNCE) + val MESSAGE = fromMessageTypes(MessageType.MESSAGE) + val FRAGMENT = fromMessageTypes(MessageType.FRAGMENT) + val FILE_TRANSFER = fromMessageTypes(MessageType.FILE_TRANSFER) + val PUBLIC_MESSAGES = fromMessageTypes(MessageType.ANNOUNCE, MessageType.MESSAGE) + val FRAGMENTS_AND_FILES = fromMessageTypes(MessageType.FRAGMENT, MessageType.FILE_TRANSFER) + + fun fromRawValue(rawValue: ULong): SyncTypeFlags = SyncTypeFlags(rawValue and KNOWN_TYPE_MASK) + + fun fromMessageTypes(vararg types: MessageType): SyncTypeFlags { + var rawValue = 0uL + types.forEach { type -> + bitIndex(type)?.let { bit -> rawValue = rawValue or (1uL shl bit) } + } + return fromRawValue(rawValue) + } + + fun decode(data: ByteArray): SyncTypeFlags? { + if (data.size !in 1..8) return null + var rawValue = 0uL + data.forEachIndexed { index, byte -> + rawValue = rawValue or (byte.toUByte().toULong() shl (index * 8)) + } + return fromRawValue(rawValue) + } + + private fun bitIndex(type: MessageType): Int? = when (type) { + MessageType.ANNOUNCE -> 0 + MessageType.MESSAGE -> 1 + MessageType.LEAVE -> 2 + MessageType.NOISE_HANDSHAKE -> 3 + MessageType.NOISE_ENCRYPTED -> 4 + MessageType.FRAGMENT -> 5 + MessageType.REQUEST_SYNC -> 6 + MessageType.FILE_TRANSFER -> 7 + MessageType.COURIER_ENVELOPE, + MessageType.VOICE_FRAME -> null + } + } + + fun contains(type: MessageType): Boolean { + val bit = bitIndex(type) ?: return false + return (rawValue and (1uL shl bit)) != 0uL + } + + fun union(other: SyncTypeFlags): SyncTypeFlags = fromRawValue(rawValue or other.rawValue) + + fun encode(): ByteArray? { + if (rawValue == 0uL) return null + var remaining = rawValue + val bytes = ArrayList(8) + while (remaining != 0uL && bytes.size < 8) { + bytes += (remaining and 0xffu).toByte() + remaining = remaining shr 8 + } + return bytes.toByteArray() + } +} 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 f40549b6..3a4012e2 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt @@ -1020,6 +1020,11 @@ class ChatViewModel( messageId, com.bitchat.android.model.DeliveryStatus.Sent ) + } else if (route == com.bitchat.android.services.MessageRouter.RouteResult.QUEUED) { + messageManager.updateMessageDeliveryStatus( + messageId, + com.bitchat.android.model.DeliveryStatus.Queued + ) } } onAccepted(accepted) @@ -1483,7 +1488,7 @@ class ChatViewModel( com.bitchat.android.services.SeenMessageStore.getInstance(getApplication()).clear() } catch (_: Exception) { } try { - com.bitchat.android.services.MessageRouter.tryGetInstance()?.clearAll() + com.bitchat.android.services.MessageRouter.panicClear(getApplication()) } catch (_: Exception) { } // Clear all cryptographic data diff --git a/app/src/main/java/com/bitchat/android/ui/MessageManager.kt b/app/src/main/java/com/bitchat/android/ui/MessageManager.kt index 31e7d1af..70ef0b53 100644 --- a/app/src/main/java/com/bitchat/android/ui/MessageManager.kt +++ b/app/src/main/java/com/bitchat/android/ui/MessageManager.kt @@ -283,10 +283,11 @@ class MessageManager(private val state: ChatState) { private fun statusPriority(status: DeliveryStatus?): Int = when (status) { null -> 0 is DeliveryStatus.Sending -> 1 - is DeliveryStatus.Sent -> 2 - is DeliveryStatus.PartiallyDelivered -> 3 - is DeliveryStatus.Delivered -> 4 - is DeliveryStatus.Read -> 5 + is DeliveryStatus.Queued -> 2 + is DeliveryStatus.Sent -> 3 + is DeliveryStatus.PartiallyDelivered -> 4 + is DeliveryStatus.Delivered -> 5 + is DeliveryStatus.Read -> 6 is DeliveryStatus.Failed -> 0 } diff --git a/app/src/main/java/com/bitchat/android/util/AppConstants.kt b/app/src/main/java/com/bitchat/android/util/AppConstants.kt index b1822806..f690b215 100644 --- a/app/src/main/java/com/bitchat/android/util/AppConstants.kt +++ b/app/src/main/java/com/bitchat/android/util/AppConstants.kt @@ -136,6 +136,7 @@ object AppConstants { const val OUTBOX_TICK_MS: Long = 2_000L const val OUTBOX_MESSAGE_TTL_MS: Long = 86_400_000L // 24 hours const val OUTBOX_MAX_PER_PEER: Int = 100 + const val MAX_COURIERS_PER_MESSAGE: Int = 3 val HANDSHAKE_RETRY_BACKOFF_MS: LongArray = longArrayOf(5_000L, 15_000L, 30_000L, 60_000L) } diff --git a/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareMeshService.kt b/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareMeshService.kt index 02bfb2d3..4b6061d0 100644 --- a/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareMeshService.kt +++ b/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareMeshService.kt @@ -240,6 +240,9 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor sendPacketToPeer(peerID, packet) } + override fun sendToPeerAndReport(peerID: String, packet: BitchatPacket): Boolean = + sendPacketToPeer(peerID, packet) + /** * Broadcasts routed packet to currently connected peers. */ diff --git a/app/src/test/java/com/bitchat/android/crypto/NoiseCourierTest.kt b/app/src/test/java/com/bitchat/android/crypto/NoiseCourierTest.kt new file mode 100644 index 00000000..a0eda7d9 --- /dev/null +++ b/app/src/test/java/com/bitchat/android/crypto/NoiseCourierTest.kt @@ -0,0 +1,37 @@ +package com.bitchat.android.crypto + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import com.bitchat.android.identity.SecureIdentityStateManager +import com.bitchat.android.noise.NoiseEncryptionService +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import java.util.UUID + +@RunWith(RobolectricTestRunner::class) +class NoiseCourierTest { + @Test + fun `Noise X seal authenticates sender and rejects tampering`() { + val context = ApplicationProvider.getApplicationContext() + val prefs = context.getSharedPreferences("noise-courier-${UUID.randomUUID()}", Context.MODE_PRIVATE) + val service = NoiseEncryptionService( + context, + SecureIdentityStateManager(prefs, testOnly = true) + ) + val payload = byteArrayOf(1, 0, 1, 65, 1, 1, 66) + + val sealed = service.sealCourierPayload(payload, service.getStaticPublicKeyData()) + val (senderKey, opened) = service.openCourierPayload(sealed) + + assertEquals(payload.size + 96, sealed.size) + assertArrayEquals(service.getStaticPublicKeyData(), senderKey) + assertArrayEquals(payload, opened) + + sealed[sealed.lastIndex] = (sealed.last().toInt() xor 1).toByte() + assertThrows(Exception::class.java) { service.openCourierPayload(sealed) } + } +} diff --git a/app/src/test/java/com/bitchat/android/mesh/FragmentingPacketSenderTest.kt b/app/src/test/java/com/bitchat/android/mesh/FragmentingPacketSenderTest.kt index c4225288..49d31c5a 100644 --- a/app/src/test/java/com/bitchat/android/mesh/FragmentingPacketSenderTest.kt +++ b/app/src/test/java/com/bitchat/android/mesh/FragmentingPacketSenderTest.kt @@ -83,6 +83,25 @@ class FragmentingPacketSenderTest { assertTrue(writes > 0) } + @Test + fun `awaited fragmented send reports a later fragment rejection`() = runBlocking { + val sender = FragmentingPacketSender( + CoroutineScope(Dispatchers.Default + SupervisorJob()), + FragmentManager(), + "test", + interFragmentDelayMs = 0L + ) + var writes = 0 + + val accepted = sender.sendAndAwaitAcceptance(RoutedPacket(packetWithPayload(10_000)), "test") { + writes += 1 + writes < 2 + } + + assertFalse(accepted) + assertTrue(writes >= 2) + } + @Test fun `fragment count at cap boundary is not rejected`() { val manager = FragmentManager() diff --git a/app/src/test/kotlin/com/bitchat/android/contracts/ClientRewriteWireContractTest.kt b/app/src/test/kotlin/com/bitchat/android/contracts/ClientRewriteWireContractTest.kt index d1381170..d627fd72 100644 --- a/app/src/test/kotlin/com/bitchat/android/contracts/ClientRewriteWireContractTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/contracts/ClientRewriteWireContractTest.kt @@ -9,6 +9,7 @@ import com.bitchat.android.model.NoisePayloadType import com.bitchat.android.model.PeerCapabilities import com.bitchat.android.model.PrivateMessagePacket import com.bitchat.android.model.RequestSyncPacket +import com.bitchat.android.sync.SyncTypeFlags import com.bitchat.android.model.UnknownAnnouncementTLV import com.bitchat.android.protocol.BinaryProtocol import com.bitchat.android.protocol.BitchatPacket @@ -203,9 +204,14 @@ class ClientRewriteWireContractTest { val request = RequestSyncPacket( p = 19, m = 0x01020304L, - data = hex("aabb") + data = hex("aabb"), + types = SyncTypeFlags.FRAGMENTS_AND_FILES, + sinceTimestamp = 0x0102030405060708u + ) + val wire = hex( + "0100011302000401020304030002aabb" + + "040001a00500080102030405060708" ) - val wire = hex("0100011302000401020304030002aabb") assertArrayEquals(wire, request.encode()) assertSyncRequestEquals(request, RequestSyncPacket.decode(wire)) @@ -297,5 +303,7 @@ class ClientRewriteWireContractTest { assertEquals(expected.p, actual!!.p) assertEquals(expected.m, actual.m) assertArrayEquals(expected.data, actual.data) + assertEquals(expected.types, actual.types) + assertEquals(expected.sinceTimestamp, actual.sinceTimestamp) } } diff --git a/app/src/test/kotlin/com/bitchat/android/mesh/BluetoothPacketBroadcasterCompletionTest.kt b/app/src/test/kotlin/com/bitchat/android/mesh/BluetoothPacketBroadcasterCompletionTest.kt new file mode 100644 index 00000000..121c0f91 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/mesh/BluetoothPacketBroadcasterCompletionTest.kt @@ -0,0 +1,110 @@ +package com.bitchat.android.mesh + +import android.bluetooth.BluetoothDevice +import android.bluetooth.BluetoothGatt +import android.bluetooth.BluetoothGattCharacteristic +import android.os.Build +import com.bitchat.android.model.RoutedPacket +import com.bitchat.android.protocol.BitchatPacket +import com.bitchat.android.protocol.MessageType +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async +import kotlinx.coroutines.cancel +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.timeout +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [Build.VERSION_CODES.P], manifest = Config.NONE) +class BluetoothPacketBroadcasterCompletionTest { + private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) + private val tracker = BluetoothConnectionTracker(scope, mock()) + private val broadcaster = BluetoothPacketBroadcaster(scope, tracker, null, MY_PEER_ID) + + @After + fun tearDown() { + broadcaster.shutdown() + scope.cancel() + } + + @Test + fun `queue admission waits for successful GATT completion`() = runBlocking { + val connection = connectedPeer() + + val result = async(Dispatchers.Default) { + broadcaster.sendPacketToPeerAndAwaitCompletion(packet(), PEER_ID, null, null) + } + + verify(connection.gatt!!, timeout(1_000)).writeCharacteristic(connection.characteristic!!) + assertFalse(result.isCompleted) + + broadcaster.onGattClientWriteComplete(DEVICE_ADDRESS, LINK_ID, BluetoothGatt.GATT_SUCCESS) + assertTrue(result.await()) + } + + @Test + fun `disconnect fails an admitted send before custody can be released`() = runBlocking { + val connection = connectedPeer() + + val result = async(Dispatchers.Default) { + broadcaster.sendPacketToPeerAndAwaitCompletion(packet(), PEER_ID, null, null) + } + + verify(connection.gatt!!, timeout(1_000)).writeCharacteristic(connection.characteristic!!) + assertFalse(result.isCompleted) + + broadcaster.onLinkDisconnected(DEVICE_ADDRESS, LINK_ID) + assertFalse(result.await()) + } + + private fun connectedPeer(): BluetoothConnectionTracker.DeviceConnection { + val device = mock() + val gatt = mock() + val characteristic = mock() + whenever(device.address).thenReturn(DEVICE_ADDRESS) + whenever(gatt.writeCharacteristic(any())).thenReturn(true) + val connection = BluetoothConnectionTracker.DeviceConnection( + device = device, + gatt = gatt, + characteristic = characteristic, + isClient = true, + linkID = LINK_ID + ) + tracker.addDeviceConnection(DEVICE_ADDRESS, connection) + tracker.observePeerIfCurrent(DEVICE_ADDRESS, LINK_ID, PEER_ID) + return connection + } + + private fun packet() = RoutedPacket( + BitchatPacket( + type = MessageType.COURIER_ENVELOPE.value, + senderID = MY_PEER_ID.hexToBytes(), + recipientID = PEER_ID.hexToBytes(), + timestamp = System.currentTimeMillis().toULong(), + payload = byteArrayOf(1, 2, 3), + ttl = 7u + ) + ) + + private fun String.hexToBytes(): ByteArray = + chunked(2).map { it.toInt(16).toByte() }.toByteArray() + + private companion object { + const val MY_PEER_ID = "1111222233334444" + const val PEER_ID = "aaaabbbbccccdddd" + const val DEVICE_ADDRESS = "00:11:22:33:44:55" + const val LINK_ID = "synthetic-link" + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/mesh/CourierStoreTest.kt b/app/src/test/kotlin/com/bitchat/android/mesh/CourierStoreTest.kt new file mode 100644 index 00000000..6d9a9482 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/mesh/CourierStoreTest.kt @@ -0,0 +1,148 @@ +package com.bitchat.android.mesh + +import android.os.Build +import com.bitchat.android.model.CourierEnvelope +import com.bitchat.android.services.ConversationStorageCipher +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config +import java.io.File + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [Build.VERSION_CODES.P], manifest = Config.NONE) +class CourierStoreTest { + private val now = 2_000_000L + private val recipient = ByteArray(32) { 7 } + private val favorite = ByteArray(32) { 8 } + private val verified = ByteArray(32) { 9 } + private val cipher = TestCipher(0x5a) + + @Test + fun `direct pickup remains retryable until explicitly removed`() { + val store = newStore() + val envelope = envelope(1) + assertTrue(store.deposit(envelope, favorite, CourierDepositTier.FAVORITE)) + + assertEquals(1, store.copiesForRecipient(recipient).size) + assertEquals(1, store.copiesForRecipient(recipient).size) + assertTrue(store.remove(envelope)) + assertTrue(store.copiesForRecipient(recipient).isEmpty()) + } + + @Test + fun `verified pool evicts oldest verified and never favorites`() { + val store = newStore() + repeat(20) { index -> + assertTrue(store.deposit(envelope(index), ByteArray(32) { index.toByte() }, CourierDepositTier.VERIFIED)) + } + val favoriteEnvelope = envelope(100) + assertTrue(store.deposit(favoriteEnvelope, favorite, CourierDepositTier.FAVORITE)) + assertTrue(store.deposit(envelope(200), ByteArray(32) { 100 }, CourierDepositTier.VERIFIED)) + + val copies = reloaded().copiesForRecipient(recipient) + assertEquals(21, copies.size) + assertTrue(copies.any { it.ciphertext.contentEquals(favoriteEnvelope.ciphertext) }) + } + + @Test + fun `spray history and copy budget survive restart`() { + val store = newStore() + assertTrue(store.deposit(envelope(1, copies = 4u), verified, CourierDepositTier.VERIFIED)) + val courier = ByteArray(32) { 11 } + + val spray = store.sprayCopiesFor(courier).single() + assertEquals(2u.toUByte(), spray.copies) + assertTrue(store.commitSpray(spray, courier)) + assertTrue(reloaded().sprayCopiesFor(courier).isEmpty()) + } + + @Test + fun `failed spray preview does not consume custody`() { + val store = newStore() + assertTrue(store.deposit(envelope(1, copies = 4u), verified, CourierDepositTier.VERIFIED)) + val courier = ByteArray(32) { 12 } + + assertEquals(2u.toUByte(), store.sprayCopiesFor(courier).single().copies) + assertEquals(2u.toUByte(), reloaded().sprayCopiesFor(courier).single().copies) + } + + @Test + fun `concurrent spray reservations cannot over allocate custody`() { + val store = newStore() + assertTrue(store.deposit(envelope(1, copies = 4u), verified, CourierDepositTier.VERIFIED)) + val firstCourier = ByteArray(32) { 12 } + val secondCourier = ByteArray(32) { 13 } + val thirdCourier = ByteArray(32) { 14 } + + val first = store.sprayCopiesFor(firstCourier).single() + val second = store.sprayCopiesFor(secondCourier).single() + + assertEquals(2u.toUByte(), first.copies) + assertEquals(1u.toUByte(), second.copies) + assertTrue(store.sprayCopiesFor(firstCourier).isEmpty()) + assertTrue(store.sprayCopiesFor(thirdCourier).isEmpty()) + assertTrue(store.commitSpray(second, secondCourier)) + assertTrue(store.commitSpray(first, firstCourier)) + assertTrue(reloaded().sprayCopiesFor(thirdCourier).isEmpty()) + } + + @Test + fun `cancelled spray reservation makes its copies eligible again`() { + val store = newStore() + assertTrue(store.deposit(envelope(1, copies = 4u), verified, CourierDepositTier.VERIFIED)) + val courier = ByteArray(32) { 15 } + + val first = store.sprayCopiesFor(courier).single() + + assertTrue(store.cancelSpray(first, courier)) + assertEquals(2u.toUByte(), store.sprayCopiesFor(courier).single().copies) + } + + @Test + fun `stored prekey envelope retains its prekey id through spray`() { + val store = newStore() + val envelope = envelope(1, copies = 4u).copy(prekeyID = 0x11223344u) + assertTrue(store.deposit(envelope, verified, CourierDepositTier.VERIFIED)) + + val spray = store.sprayCopiesFor(ByteArray(32) { 16 }).single() + + assertEquals(0x11223344u, spray.prekeyID) + } + + @Test + fun `wipe deletes sealed custody and destroys key`() { + val store = newStore() + assertTrue(store.deposit(envelope(1), favorite, CourierDepositTier.FAVORITE)) + store.wipe() + + assertFalse(File(RuntimeEnvironment.getApplication().filesDir, "courier-store.sealed").exists()) + assertTrue(cipher.destroyed) + } + + private fun envelope(id: Int, copies: UByte = 1u) = CourierEnvelope( + recipientTag = CourierEnvelope.recipientTag(recipient, CourierEnvelope.epochDay(now)), + expiry = (now + 60_000).toULong(), + ciphertext = byteArrayOf((id ushr 8).toByte(), id.toByte()), + copies = copies + ) + + private fun newStore(): CourierStore { + File(RuntimeEnvironment.getApplication().filesDir, "courier-store.sealed").delete() + return reloaded() + } + + private fun reloaded() = CourierStore(RuntimeEnvironment.getApplication(), cipher) { now } + + private class TestCipher(private val mask: Int) : ConversationStorageCipher { + var destroyed = false + override fun encrypt(plaintext: ByteArray, associatedData: ByteArray) = + plaintext.map { (it.toInt() xor mask).toByte() }.toByteArray() + override fun decrypt(envelope: ByteArray, associatedData: ByteArray) = encrypt(envelope, associatedData) + override fun destroyKey() { destroyed = true } + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/mesh/DirectCourierDepositPolicyTest.kt b/app/src/test/kotlin/com/bitchat/android/mesh/DirectCourierDepositPolicyTest.kt new file mode 100644 index 00000000..559b07b4 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/mesh/DirectCourierDepositPolicyTest.kt @@ -0,0 +1,32 @@ +package com.bitchat.android.mesh + +import com.bitchat.android.model.RoutedPacket +import com.bitchat.android.protocol.BitchatPacket +import com.bitchat.android.protocol.MessageType +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class DirectCourierDepositPolicyTest { + private val packet = BitchatPacket( + type = MessageType.COURIER_ENVELOPE.value, + senderID = ByteArray(8), + timestamp = 1u, + payload = byteArrayOf(1), + ttl = 7u + ) + + @Test + fun `accepts current direct ingress from claimed sender`() { + val routed = RoutedPacket(packet, "peer", "address", ingressLinkID = "link") + assertTrue(DirectCourierDepositPolicy.accepts(routed, 7u, { "link" }, { "peer" })) + } + + @Test + fun `rejects relayed stale-link and rebound-sender deposits`() { + val direct = RoutedPacket(packet, "peer", "address", ingressLinkID = "link") + assertFalse(DirectCourierDepositPolicy.accepts(direct.copy(packet = packet.copy(ttl = 6u)), 7u, { "link" }, { "peer" })) + assertFalse(DirectCourierDepositPolicy.accepts(direct, 7u, { "replacement" }, { "peer" })) + assertFalse(DirectCourierDepositPolicy.accepts(direct, 7u, { "link" }, { "other" })) + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/mesh/MessageHandlerTest.kt b/app/src/test/kotlin/com/bitchat/android/mesh/MessageHandlerTest.kt index 79bc4c2d..68e21a72 100644 --- a/app/src/test/kotlin/com/bitchat/android/mesh/MessageHandlerTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/mesh/MessageHandlerTest.kt @@ -7,6 +7,7 @@ import com.bitchat.android.model.BitchatFilePacket import com.bitchat.android.model.NoisePayload import com.bitchat.android.model.NoisePayloadType import com.bitchat.android.model.PeerCapabilities +import com.bitchat.android.model.PrivateMessagePacket import com.bitchat.android.model.RoutedPacket import com.bitchat.android.noise.NoisePeerIdentity import com.bitchat.android.noise.AuthenticatedNoiseSession @@ -25,9 +26,11 @@ import org.junit.Test import org.junit.runner.RunWith import org.mockito.kotlin.any import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.argThat import org.mockito.kotlin.eq import org.mockito.kotlin.mock import org.mockito.kotlin.never +import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import org.robolectric.RobolectricTestRunner @@ -435,6 +438,48 @@ class MessageHandlerTest { assertTrue(handler.handleNoiseEncrypted(RoutedPacket(encryptedPacket(), peerID, "direct-link"))) } + @Test + fun `opened courier private message is admitted as its Noise sender`() = runBlocking { + whenever(delegate.getPeerNickname(peerID)).thenReturn(nickname) + whenever(delegate.getMyNickname()).thenReturn("me") + val payload = NoisePayload( + NoisePayloadType.PRIVATE_MESSAGE, + requireNotNull(PrivateMessagePacket("courier-message", "opaque courier content").encode()) + ).encode() + + assertTrue( + handler.handleOpenedCourierPayload( + RoutedPacket(encryptedPacket().copy(payload = payload), peerID, "courier-ingress") + ) + ) + verify(delegate).onMessageReceived(argThat { + id == "courier-message" && + content == "opaque courier content" && + senderPeerID == peerID && + sender == nickname && + recipientNickname == "me" + }) + } + + @Test + fun `opened courier delivery receipt acknowledges the original sender and rejects blank IDs`() = runBlocking { + val receipt = NoisePayload(NoisePayloadType.DELIVERED, "courier-message".toByteArray()).encode() + assertTrue( + handler.handleOpenedCourierPayload( + RoutedPacket(encryptedPacket().copy(payload = receipt), peerID, "courier-ingress") + ) + ) + verify(delegate).onDeliveryAckReceived("courier-message", peerID) + + val blankReceipt = NoisePayload(NoisePayloadType.DELIVERED, ByteArray(0)).encode() + assertFalse( + handler.handleOpenedCourierPayload( + RoutedPacket(encryptedPacket().copy(payload = blankReceipt), peerID, "courier-ingress") + ) + ) + verify(delegate, times(1)).onDeliveryAckReceived("courier-message", peerID) + } + private fun encryptedPacket(): BitchatPacket = BitchatPacket( version = 1u, type = MessageType.NOISE_ENCRYPTED.value, diff --git a/app/src/test/kotlin/com/bitchat/android/mesh/PacketProcessorAnnounceSideEffectTest.kt b/app/src/test/kotlin/com/bitchat/android/mesh/PacketProcessorAnnounceSideEffectTest.kt index 3a676382..2564273b 100644 --- a/app/src/test/kotlin/com/bitchat/android/mesh/PacketProcessorAnnounceSideEffectTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/mesh/PacketProcessorAnnounceSideEffectTest.kt @@ -64,6 +64,26 @@ class PacketProcessorAnnounceSideEffectTest { assertEquals(PEER_ID, withTimeout(1_000) { delegate.lastSeen.await() }) } + @Test + fun `broadcast message within future skew is handled`() = runBlocking { + val delegate = RecordingDelegate(acceptAnnounce = true) + val processor = processor(delegate) + + processor.processPacket(message(timestampOffsetMs = 60_000)) + + withTimeout(1_000) { delegate.messageHandled.await() } + } + + @Test + fun `broadcast message beyond future skew is rejected`() = runBlocking { + val delegate = RecordingDelegate(acceptAnnounce = true) + val processor = processor(delegate) + + processor.processPacket(message(timestampOffsetMs = 11 * 60_000L)) + + assertNull(withTimeoutOrNull(250) { delegate.messageHandled.await() }) + } + private fun processor(delegate: RecordingDelegate): PacketProcessor = PacketProcessor(MY_PEER_ID).also { it.delegate = delegate @@ -96,12 +116,26 @@ class PacketProcessorAnnounceSideEffectTest { return RoutedPacket(packet, PEER_ID, "direct-link") } + private fun message(timestampOffsetMs: Long): RoutedPacket { + val packet = BitchatPacket( + version = 1u, + type = MessageType.MESSAGE.value, + senderID = PEER_ID.hexToBytes(), + recipientID = SpecialRecipients.BROADCAST, + timestamp = (System.currentTimeMillis() + timestampOffsetMs).toULong(), + payload = byteArrayOf(0x01), + ttl = 7u + ) + return RoutedPacket(packet, PEER_ID, "direct-link") + } + private class RecordingDelegate( private val acceptAnnounce: Boolean, private val acceptHandshake: Boolean = false ) : PacketProcessorDelegate { val handled = CompletableDeferred() val handshakeHandled = CompletableDeferred() + val messageHandled = CompletableDeferred() val lastSeen = CompletableDeferred() @Volatile var relayCount = 0 @@ -121,7 +155,9 @@ class PacketProcessorAnnounceSideEffectTest { handled.complete(Unit) return acceptAnnounce } - override fun handleMessage(routed: RoutedPacket) = Unit + override fun handleMessage(routed: RoutedPacket) { + messageHandled.complete(Unit) + } override fun handleLeave(routed: RoutedPacket) = Unit override fun handleFragment(packet: BitchatPacket): BitchatPacket? = null override fun handleRequestSync(routed: RoutedPacket) = Unit diff --git a/app/src/test/kotlin/com/bitchat/android/model/CourierEnvelopeTest.kt b/app/src/test/kotlin/com/bitchat/android/model/CourierEnvelopeTest.kt new file mode 100644 index 00000000..bf01a53a --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/model/CourierEnvelopeTest.kt @@ -0,0 +1,111 @@ +package com.bitchat.android.model + +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class CourierEnvelopeTest { + @Test + fun `encoding matches fixed iOS wire vector`() { + val envelope = CourierEnvelope( + recipientTag = ByteArray(16) { it.toByte() }, + expiry = 0x0102030405060708u, + ciphertext = byteArrayOf(0xaa.toByte(), 0xbb.toByte(), 0xcc.toByte()), + copies = 4u, + prekeyID = 0x11223344u + ) + assertEquals( + "010010000102030405060708090a0b0c0d0e0f" + + "0200080102030405060708" + + "030003aabbcc" + + "04000104" + + "05000411223344", + envelope.encode()!!.toHex() + ) + } + + @Test + fun `copies TLV round trips and legacy omission defaults to one`() { + val envelope = CourierEnvelope( + recipientTag = ByteArray(16) { it.toByte() }, + expiry = 123456789u, + ciphertext = ByteArray(96) { (it + 1).toByte() }, + copies = 4u + ) + val decoded = CourierEnvelope.decode(envelope.encode()!!)!! + assertEquals(4u.toUByte(), decoded.copies) + assertArrayEquals(envelope.recipientTag, decoded.recipientTag) + assertArrayEquals(envelope.ciphertext, decoded.ciphertext) + + val legacy = envelope.copy(copies = 1u) + assertEquals(1u.toUByte(), CourierEnvelope.decode(legacy.encode()!!)!!.copies) + } + + @Test + fun `prekey id survives decode re-encode and copy changes`() { + val envelope = CourierEnvelope( + recipientTag = ByteArray(16) { it.toByte() }, + expiry = 123456789u, + ciphertext = ByteArray(96) { (it + 1).toByte() }, + copies = 4u, + prekeyID = 0xfedcba98u + ) + + val decoded = CourierEnvelope.decode(envelope.encode()!!)!! + + assertEquals(0xfedcba98u, decoded.prekeyID) + assertArrayEquals(envelope.encode(), decoded.copy(copies = 4u).encode()) + assertEquals(0xfedcba98u, decoded.copy(copies = 2u).prekeyID) + } + + @Test + fun `invalid or duplicate prekey fields are rejected`() { + val envelope = CourierEnvelope(ByteArray(16), 1u, ByteArray(32) { 1 }, prekeyID = 7u) + val encoded = envelope.encode()!! + val prekeyField = encoded.copyOfRange(encoded.size - 7, encoded.size) + + assertNull(CourierEnvelope.decode(encoded + prekeyField)) + assertNull(CourierEnvelope.decode(encoded.copyOf(encoded.size - 1))) + } + + @Test + fun `tag rotates daily and matches adjacent day for clock skew`() { + val key = ByteArray(32) { 0x2a } + val now = 10L * 86_400_000L + val tag = CourierEnvelope.recipientTag(key, CourierEnvelope.epochDay(now) - 1u) + val envelope = CourierEnvelope(tag, (now + 1_000).toULong(), ByteArray(96) { 1 }) + assertEquals(true, envelope.matchesRecipient(key, now)) + assertEquals(false, envelope.matchesRecipient(ByteArray(32) { 0x2b }, now)) + } + + @Test + fun `oversized ciphertext is rejected`() { + val envelope = CourierEnvelope( + ByteArray(16), + 1u, + ByteArray(CourierEnvelope.MAX_CIPHERTEXT_BYTES + 1) + ) + assertNull(envelope.encode()) + } + + @Test + fun `invalid copy budgets are rejected instead of normalized`() { + val envelope = CourierEnvelope(ByteArray(16), 1u, ByteArray(32) { 1 }) + assertNull(envelope.copy(copies = 0u).encode()) + assertNull(envelope.copy(copies = 9u).encode()) + + val encoded = envelope.copy(copies = 2u).encode()!! + encoded[encoded.lastIndex] = 0 + assertNull(CourierEnvelope.decode(encoded)) + } + + @Test + fun `duplicate required fields are rejected`() { + val envelope = CourierEnvelope(ByteArray(16), 1u, ByteArray(32) { 1 }) + val encoded = envelope.encode()!! + assertNull(CourierEnvelope.decode(encoded + encoded.copyOfRange(0, 19))) + } + + private fun ByteArray.toHex() = joinToString("") { "%02x".format(it) } +} diff --git a/app/src/test/kotlin/com/bitchat/android/nostr/BridgeCourierServiceTest.kt b/app/src/test/kotlin/com/bitchat/android/nostr/BridgeCourierServiceTest.kt new file mode 100644 index 00000000..3d887ab1 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/nostr/BridgeCourierServiceTest.kt @@ -0,0 +1,240 @@ +package com.bitchat.android.nostr + +import com.bitchat.android.model.CourierEnvelope +import com.bitchat.android.model.NoisePayload +import com.bitchat.android.model.NoisePayloadType +import com.bitchat.android.model.PrivateMessagePacket +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Test +import java.util.Base64 + +class BridgeCourierServiceTest { + + private val now = 1_750_000_000_000L + private val relays = listOf("wss://bridge-test.invalid") + private val recipientKey = ByteArray(32) { 0x32 } + + @Test + fun `deposit creates a signed iOS-compatible event and receiver admits it once`() { + val senderRelay = FakeRelay() + val sender = BridgeCourierService( + cipher = FakeCipher(ByteArray(32) { 0x11 }), + onEnvelope = {}, + relayManager = senderRelay, + relayUrls = relays, + clock = { now }, + identityFactory = { NostrIdentity.fromSeed("bridge-courier-sender") } + ) + var accepted = false + + val event = try { + assertTrue(sender.deposit("synthetic bridge payload", "bridge-message", recipientKey) { + accepted = true + }) + assertTrue(accepted) + assertEquals(1, senderRelay.sentEvents.size) + + senderRelay.sentEvents.single().also { + assertEquals(1401, it.kind) + assertTrue(it.isValidSignature()) + assertEquals(relays, senderRelay.lastSendRelays) + } + } finally { + sender.stop() + } + + val encoded = Base64.getDecoder().decode(event.content) + val envelope = requireNotNull(CourierEnvelope.decode(encoded)) + assertTrue(envelope.matchesRecipient(recipientKey, now)) + assertEquals(1u.toUByte(), envelope.copies) + assertEquals( + listOf("x", envelope.recipientTag.toHex()), + event.tags.first { it.firstOrNull() == "x" } + ) + assertEquals( + listOf("expiration", (envelope.expiry / 1000u).toString()), + event.tags.first { it.firstOrNull() == "expiration" } + ) + val typed = requireNotNull(NoisePayload.decode(envelope.ciphertext)) + assertEquals(NoisePayloadType.PRIVATE_MESSAGE, typed.type) + val privateMessage = requireNotNull(PrivateMessagePacket.decode(typed.data)) + assertEquals("bridge-message", privateMessage.messageID) + assertEquals("synthetic bridge payload", privateMessage.content) + + val receiverRelay = FakeRelay() + val received = mutableListOf() + val receiver = BridgeCourierService( + cipher = FakeCipher(recipientKey), + onEnvelope = received::add, + relayManager = receiverRelay, + relayUrls = relays, + clock = { now }, + identityFactory = { NostrIdentity.fromSeed("bridge-courier-receiver") } + ) + try { + receiver.start() + assertNotNull(receiverRelay.subscription) + assertTrue(receiverRelay.subscription!!.filter.matches(event)) + assertEquals(relays, receiverRelay.subscription!!.relayUrls) + + receiverRelay.emit(event) + receiverRelay.emit(event) + + assertEquals(listOf(envelope), received) + } finally { + receiver.stop() + } + assertEquals(1, receiverRelay.unsubscribedIDs.size) + } + + @Test + fun `receiver rejects a validly signed event with mismatched metadata and deposit fails closed`() { + val relay = FakeRelay() + val received = mutableListOf() + val receiver = BridgeCourierService( + cipher = FakeCipher(recipientKey), + onEnvelope = received::add, + relayManager = relay, + relayUrls = relays, + clock = { now }, + identityFactory = { NostrIdentity.fromSeed("bridge-courier-receiver") } + ) + val envelope = CourierEnvelope( + recipientTag = CourierEnvelope.recipientTag(recipientKey, CourierEnvelope.epochDay(now)), + expiry = (now + CourierEnvelope.MAX_LIFETIME_MS).toULong(), + ciphertext = byteArrayOf(1, 2, 3), + copies = 1u + ) + val identity = NostrIdentity.fromSeed("bridge-courier-invalid-event") + val mismatchedTag = identity.signEvent( + NostrEvent( + pubkey = identity.publicKeyHex, + createdAt = (now / 1000).toInt(), + kind = 1401, + tags = listOf( + listOf("x", "00".repeat(CourierEnvelope.TAG_LENGTH)), + listOf("expiration", (envelope.expiry / 1000u).toString()) + ), + content = Base64.getEncoder().encodeToString(requireNotNull(envelope.encode())) + ) + ) + try { + receiver.start() + relay.emit(mismatchedTag) + assertTrue(received.isEmpty()) + + val oversizedSender = BridgeCourierService( + cipher = FakeCipher(ByteArray(32) { 0x21 }, ByteArray(CourierEnvelope.MAX_CIPHERTEXT_BYTES + 1)), + onEnvelope = {}, + relayManager = relay, + relayUrls = relays, + clock = { now }, + identityFactory = { NostrIdentity.fromSeed("bridge-courier-oversized") } + ) + try { + assertFalse(oversizedSender.depositPayload(byteArrayOf(7), recipientKey)) + + relay.connected = false + assertFalse(oversizedSender.depositPayload(byteArrayOf(7), recipientKey)) + } finally { + oversizedSender.stop() + } + } finally { + receiver.stop() + } + } + + @Test + fun `deposit completion waits for relay acceptance`() { + val relay = FakeRelay().apply { acceptImmediately = false } + val sender = BridgeCourierService( + cipher = FakeCipher(ByteArray(32) { 0x11 }), + onEnvelope = {}, + relayManager = relay, + relayUrls = relays, + clock = { now }, + identityFactory = { NostrIdentity.fromSeed("bridge-courier-pending") } + ) + var accepted = 0 + + try { + assertTrue(sender.depositPayload(byteArrayOf(1, 2, 3), recipientKey) { accepted++ }) + assertEquals(0, accepted) + + relay.acceptPendingEvent() + assertEquals(1, accepted) + } finally { + sender.stop() + } + } + + private class FakeCipher( + private val staticKey: ByteArray, + private val sealedOverride: ByteArray? = null + ) : BridgeCourierCipher { + override fun staticPublicKey(): ByteArray = staticKey + + override fun seal(payload: ByteArray, recipientNoiseKey: ByteArray): ByteArray = + sealedOverride ?: payload.copyOf() + } + + private class FakeRelay : BridgeCourierRelay { + data class Subscription( + val filter: NostrFilter, + val id: String, + val relayUrls: List, + val handler: (NostrEvent) -> Unit + ) + + var connected = true + var acceptImmediately = true + var subscription: Subscription? = null + val sentEvents = mutableListOf() + var lastSendRelays: List = emptyList() + val unsubscribedIDs = mutableListOf() + private var pendingAccepted: (() -> Unit)? = null + + override fun subscribe( + filter: NostrFilter, + id: String, + targetRelayUrls: List, + handler: (NostrEvent) -> Unit + ) { + subscription = Subscription(filter, id, targetRelayUrls, handler) + } + + override fun unsubscribe(id: String) { + unsubscribedIDs += id + if (subscription?.id == id) subscription = null + } + + override fun hasConnectedRelay(relayUrls: Collection): Boolean = connected + + override fun sendEvent( + event: NostrEvent, + relayUrls: List, + onAccepted: () -> Unit + ): Boolean { + if (!connected) return false + sentEvents += event + lastSendRelays = relayUrls + if (acceptImmediately) onAccepted() else pendingAccepted = onAccepted + return true + } + + fun acceptPendingEvent() { + val callback = requireNotNull(pendingAccepted) + pendingAccepted = null + callback() + } + + fun emit(event: NostrEvent) { + subscription?.handler?.invoke(event) + } + } + + private fun ByteArray.toHex(): String = joinToString("") { "%02x".format(it) } +} diff --git a/app/src/test/kotlin/com/bitchat/android/services/ContactDirectoryTest.kt b/app/src/test/kotlin/com/bitchat/android/services/ContactDirectoryTest.kt index e16996f1..7ff3ec63 100644 --- a/app/src/test/kotlin/com/bitchat/android/services/ContactDirectoryTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/services/ContactDirectoryTest.kt @@ -6,6 +6,7 @@ import com.bitchat.android.identity.SecureIdentityStateManager import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test import org.junit.runner.RunWith @@ -56,4 +57,16 @@ class ContactDirectoryTest { assertNull(resolution.displayName) } + + @Test + fun `offline contact resolves a fingerprint-validated cached Noise key`() { + val noiseKey = ByteArray(32) { it.toByte() } + val fingerprint = ContactIdentityResolver.fingerprintHex(noiseKey) + val peerID = ContactIdentityResolver.peerIdForNoiseKey(noiseKey) + identityManager.cachePeerNoiseKey(peerID, ContactIdentityResolver.noiseKeyHex(noiseKey)) + + val resolution = ContactDirectory.resolve("contact_$fingerprint") + + assertTrue(resolution.noisePublicKey!!.contentEquals(noiseKey)) + } } diff --git a/app/src/test/kotlin/com/bitchat/android/services/MessageOutboxStoreTest.kt b/app/src/test/kotlin/com/bitchat/android/services/MessageOutboxStoreTest.kt new file mode 100644 index 00000000..ded30b31 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/services/MessageOutboxStoreTest.kt @@ -0,0 +1,54 @@ +package com.bitchat.android.services + +import android.os.Build +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config +import java.io.File + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [Build.VERSION_CODES.P], manifest = Config.NONE) +class MessageOutboxStoreTest { + @Test + fun `sealed outbox round trips and wrong key fails closed`() { + val context = RuntimeEnvironment.getApplication() + val file = File(context.filesDir, "message-outbox.sealed") + file.delete() + val store = MessageOutboxStore(context, TestCipher(0x33)) + val entry = MessageOutboxStore.Entry("secret", "peer", "message", 100) + entry.depositedCourierKeys += "courier" + store.save(mapOf("conversation" to listOf(entry))) + + assertFalse(file.readBytes().toString(Charsets.UTF_8).contains("secret")) + assertEquals("secret", MessageOutboxStore(context, TestCipher(0x33)).load()["conversation"]?.single()?.content) + assertTrue(MessageOutboxStore(context, TestCipher(0x44)).load().isEmpty()) + } + + @Test + fun `wipe removes file and destroys key`() { + val context = RuntimeEnvironment.getApplication() + val cipher = TestCipher(0x33) + val store = MessageOutboxStore(context, cipher) + store.save(mapOf("conversation" to listOf(MessageOutboxStore.Entry("secret", "peer", "message", 100)))) + store.wipe() + + assertFalse(File(context.filesDir, "message-outbox.sealed").exists()) + assertTrue(cipher.destroyed) + } + + private class TestCipher(private val mask: Int) : ConversationStorageCipher { + var destroyed = false + override fun encrypt(plaintext: ByteArray, associatedData: ByteArray) = byteArrayOf(mask.toByte()) + + plaintext.map { (it.toInt() xor mask).toByte() }.toByteArray() + override fun decrypt(envelope: ByteArray, associatedData: ByteArray): ByteArray { + require(envelope.firstOrNull() == mask.toByte()) + return envelope.drop(1).map { (it.toInt() xor mask).toByte() }.toByteArray() + } + override fun destroyKey() { destroyed = true } + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/services/MessageRouterTest.kt b/app/src/test/kotlin/com/bitchat/android/services/MessageRouterTest.kt index 48cbe2da..19cee52c 100644 --- a/app/src/test/kotlin/com/bitchat/android/services/MessageRouterTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/services/MessageRouterTest.kt @@ -14,6 +14,7 @@ import org.junit.Test import org.junit.runner.RunWith import org.mockito.kotlin.any import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.argThat import org.mockito.kotlin.clearInvocations import org.mockito.kotlin.eq import org.mockito.kotlin.mock @@ -36,6 +37,7 @@ class MessageRouterTest { private lateinit var mesh: MeshService private lateinit var router: MessageRouter + private lateinit var identityManager: SecureIdentityStateManager private var fakeTime = 1_000_000L private val expired = mutableListOf() @@ -46,7 +48,7 @@ class MessageRouterTest { "message-router-test-${UUID.randomUUID()}", Context.MODE_PRIVATE ) - val identityManager = SecureIdentityStateManager(prefs, testOnly = true) + identityManager = SecureIdentityStateManager(prefs, testOnly = true) ContactDirectory.identityManagerProvider = { identityManager } mesh = mock() @@ -56,11 +58,19 @@ class MessageRouterTest { ContactDirectory.initialize(context) { mesh } MessageRouter.disableSchedulerForTesting = true + MessageRouter.outboxStoreFactory = { testContext -> + MessageOutboxStore(testContext, object : ConversationStorageCipher { + override fun encrypt(plaintext: ByteArray, associatedData: ByteArray) = plaintext + override fun decrypt(envelope: ByteArray, associatedData: ByteArray) = envelope + override fun destroyKey() = Unit + }) + } MessageRouter.resetForTesting() fakeTime = 1_000_000L expired.clear() router = MessageRouter.getInstance(context, mesh) + router.clearAll() router.clock = { fakeTime } router.onMessageExpired = { expired.add(it) } } @@ -69,6 +79,7 @@ class MessageRouterTest { fun tearDown() { MessageRouter.resetForTesting() MessageRouter.disableSchedulerForTesting = false + MessageRouter.outboxStoreFactory = ::MessageOutboxStore ContactDirectory.identityManagerProvider = { SecureIdentityStateManager(it) } } @@ -174,6 +185,159 @@ class MessageRouterTest { verify(mesh, never()).initiateNoiseHandshake(any()) } + @Test + fun `direct send remains retained until delivery acknowledgement`() { + peerReady() + router.sendPrivate("direct", peerID, "peer", "msg-direct") + clearInvocations(mesh) + + fakeTime += 31_000 + router.tickOutbox() + verify(mesh, times(1)).sendPrivateMessage("direct", peerID, "peer", "msg-direct") + + router.onMessageAcknowledged("msg-direct", peerID) + clearInvocations(mesh) + router.tickOutbox() + verify(mesh, never()).sendPrivateMessage(any(), any(), any(), anyOrNull()) + } + + @Test + fun `queued message survives router recreation`() { + peerOffline() + router.sendPrivate("durable", peerID, "peer", "msg-durable") + + MessageRouter.resetForTesting() + router = MessageRouter.getInstance(RuntimeEnvironment.getApplication(), mesh) + router.clock = { fakeTime } + peerReady() + router.onSessionEstablished(peerID) + + verify(mesh).sendPrivateMessage("durable", peerID, "peer", "msg-durable") + } + + @Test + fun `offline cached contact deposits once with a verified peer courier`() { + val recipientNoiseKey = ByteArray(32) { 0x2A } + val recipientPeerID = ContactIdentityResolver.peerIdForNoiseKey(recipientNoiseKey) + val courierPeerID = "9999aaaabbbbcccc" + val courierNoiseKey = ByteArray(32) { 0x3B } + identityManager.cachePeerNoiseKey( + recipientPeerID, + ContactIdentityResolver.noiseKeyHex(recipientNoiseKey) + ) + whenever(mesh.getPeerInfo(recipientPeerID)).thenReturn( + PeerInfo( + id = recipientPeerID, + nickname = "offline contact", + isConnected = false, + isDirectConnection = false, + noisePublicKey = recipientNoiseKey, + signingPublicKey = ByteArray(32) { 0x0A }, + isVerifiedNickname = false, + lastSeen = fakeTime + ) + ) + whenever(mesh.getPeerInfos()).thenReturn( + listOf( + PeerInfo( + id = courierPeerID, + nickname = "verified courier", + isConnected = true, + isDirectConnection = true, + noisePublicKey = courierNoiseKey, + signingPublicKey = ByteArray(32) { 0x0C }, + isVerifiedNickname = false, + lastSeen = fakeTime, + hasVerifiedAnnouncement = true + ) + ) + ) + whenever(mesh.sendCourierMessage(any(), any(), any(), any())).thenReturn(listOf(courierPeerID)) + + val result = router.sendPrivate("courier payload", recipientPeerID, "offline contact", "msg-courier") + + assertEquals(MessageRouter.RouteResult.QUEUED, result) + verify(mesh).sendCourierMessage( + eq("courier payload"), + eq("msg-courier"), + argThat { contentEquals(recipientNoiseKey) }, + eq(listOf(courierPeerID)) + ) + + router.tickOutbox() + + verify(mesh, times(1)).sendCourierMessage( + eq("courier payload"), + eq("msg-courier"), + argThat { contentEquals(recipientNoiseKey) }, + eq(listOf(courierPeerID)) + ) + } + + @Test + fun `courier acknowledgement clears an offline contact outbox entry before direct reconnect`() { + val recipientNoiseKey = ByteArray(32) { 0x4A } + val recipientPeerID = ContactIdentityResolver.peerIdForNoiseKey(recipientNoiseKey) + identityManager.cachePeerNoiseKey( + recipientPeerID, + ContactIdentityResolver.noiseKeyHex(recipientNoiseKey) + ) + whenever(mesh.getPeerNicknames()).thenReturn(mapOf(recipientPeerID to "offline contact")) + whenever(mesh.getPeerInfo(recipientPeerID)).thenReturn( + PeerInfo( + id = recipientPeerID, + nickname = "offline contact", + isConnected = false, + isDirectConnection = false, + noisePublicKey = recipientNoiseKey, + signingPublicKey = ByteArray(32) { 0x0A }, + isVerifiedNickname = false, + lastSeen = fakeTime + ) + ) + + assertEquals( + MessageRouter.RouteResult.QUEUED, + router.sendPrivate("courier payload", recipientPeerID, "offline contact", "msg-courier-ack") + ) + router.onMessageAcknowledged("msg-courier-ack", recipientPeerID) + + whenever(mesh.getPeerInfo(recipientPeerID)).thenReturn( + PeerInfo( + id = recipientPeerID, + nickname = "offline contact", + isConnected = true, + isDirectConnection = true, + noisePublicKey = recipientNoiseKey, + signingPublicKey = ByteArray(32) { 0x0A }, + isVerifiedNickname = false, + lastSeen = fakeTime + ) + ) + whenever(mesh.hasEstablishedSession(recipientPeerID)).thenReturn(true) + + router.tickOutbox() + + verify(mesh, never()).sendPrivateMessage( + eq("courier payload"), + eq(recipientPeerID), + any(), + eq("msg-courier-ack") + ) + } + + @Test + fun `outbox stops transport retries after eight attempts`() { + peerReady() + router.sendPrivate("bounded", peerID, "peer", "msg-bounded") + repeat(10) { + fakeTime += 10 * 60_000L + 1 + router.tickOutbox() + } + + verify(mesh, times(8)).sendPrivateMessage("bounded", peerID, "peer", "msg-bounded") + } + @Test fun `scheduler stops with the mesh service and restarts on rebind`() { MessageRouter.disableSchedulerForTesting = false diff --git a/app/src/test/kotlin/com/bitchat/android/sync/GCSFilterTest.kt b/app/src/test/kotlin/com/bitchat/android/sync/GCSFilterTest.kt index 3a9bfb62..c6c30582 100644 --- a/app/src/test/kotlin/com/bitchat/android/sync/GCSFilterTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/sync/GCSFilterTest.kt @@ -18,6 +18,7 @@ class GCSFilterTest { // Build filter with plenty of bytes (no trimming) val params = GCSFilter.buildFilter(ids, maxBytes = 400, targetFpr = 0.01) + assertEquals(ids.size, params.includedCount) val sorted = GCSFilter.decodeToSortedSet(params.p, params.m, params.data) for (id in ids) { @@ -47,7 +48,7 @@ class GCSFilterTest { val sorted = GCSFilter.decodeToSortedSet(params.p, params.m, params.data) // Let's verify that the first trimmedN elements in ids are all matched - val trimmedN = (params.m ushr params.p).toInt() + val trimmedN = params.includedCount assertTrue("At least some elements should have been encoded", trimmedN > 0) val retainedIds = ids.take(trimmedN) diff --git a/app/src/test/kotlin/com/bitchat/android/sync/GossipSyncManagerTest.kt b/app/src/test/kotlin/com/bitchat/android/sync/GossipSyncManagerTest.kt new file mode 100644 index 00000000..1ffc94a7 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/sync/GossipSyncManagerTest.kt @@ -0,0 +1,223 @@ +package com.bitchat.android.sync + +import android.content.ContextWrapper +import android.os.Build +import com.bitchat.android.model.RequestSyncPacket +import com.bitchat.android.protocol.BitchatPacket +import com.bitchat.android.protocol.MessageType +import kotlinx.coroutines.test.TestScope +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config +import java.io.File +import java.nio.file.Files + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [Build.VERSION_CODES.P], manifest = Config.NONE) +class GossipSyncManagerTest { + private val config = object : GossipSyncManager.ConfigProvider { + override fun seenCapacity() = 100 + override fun gcsMaxBytes() = 400 + override fun gcsTargetFpr() = 0.01 + } + + @Test + fun `whole messages retain six hours while fragments retain fifteen minutes`() { + val sent = mutableListOf() + val manager = GossipSyncManager("1111222233334444", TestScope(), config) + manager.delegate = object : GossipSyncManager.Delegate { + override fun sendPacket(packet: BitchatPacket) = Unit + override fun sendPacketToPeer(peerID: String, packet: BitchatPacket) { sent += packet } + override fun signPacketForBroadcast(packet: BitchatPacket) = packet + } + val now = System.currentTimeMillis() + manager.onPublicPacketSeen(packet(MessageType.MESSAGE, now - 5 * 60 * 60 * 1000L, 1)) + manager.onPublicPacketSeen(packet(MessageType.MESSAGE, now - 7 * 60 * 60 * 1000L, 2)) + manager.onPublicPacketSeen(packet(MessageType.FRAGMENT, now - 10 * 60 * 1000L, 3)) + manager.onPublicPacketSeen(packet(MessageType.FRAGMENT, now - 20 * 60 * 1000L, 4)) + manager.onPublicPacketSeen(packet(MessageType.FILE_TRANSFER, now - 10 * 60 * 1000L, 5)) + + manager.handleRequestSync( + "peer", + RequestSyncPacket( + p = 1, + m = 1, + data = byteArrayOf(), + types = SyncTypeFlags.PUBLIC_MESSAGES.union(SyncTypeFlags.FRAGMENTS_AND_FILES) + ) + ) + + assertEquals(listOf(1, 3, 5), sent.map { it.payload.single().toInt() }) + } + + @Test + fun `public history tolerates bounded future clock skew`() { + val sent = mutableListOf() + val manager = GossipSyncManager("1111222233334444", TestScope(), config) + manager.delegate = object : GossipSyncManager.Delegate { + override fun sendPacket(packet: BitchatPacket) = Unit + override fun sendPacketToPeer(peerID: String, packet: BitchatPacket) { sent += packet } + override fun signPacketForBroadcast(packet: BitchatPacket) = packet + } + val now = System.currentTimeMillis() + manager.onPublicPacketSeen(packet(MessageType.MESSAGE, now + 60_000L, 1)) + manager.onPublicPacketSeen(packet(MessageType.FRAGMENT, now + 60_000L, 2)) + manager.onPublicPacketSeen(packet(MessageType.FILE_TRANSFER, now + 60_000L, 3)) + manager.onPublicPacketSeen(packet(MessageType.MESSAGE, now + 11 * 60_000L, 4)) + manager.onPublicPacketSeen(packet(MessageType.FRAGMENT, now + 11 * 60_000L, 5)) + + manager.handleRequestSync( + "peer", + RequestSyncPacket( + p = 1, + m = 1, + data = byteArrayOf(), + types = SyncTypeFlags.PUBLIC_MESSAGES.union(SyncTypeFlags.FRAGMENTS_AND_FILES) + ) + ) + + assertEquals(listOf(1, 2, 3), sent.map { it.payload.single().toInt() }) + } + + @Test + fun `type scoped fragment request does not replay other packet classes`() { + val sent = mutableListOf() + val manager = GossipSyncManager("1111222233334444", TestScope(), config) + manager.delegate = recordingDelegate(sent) + val now = System.currentTimeMillis() + manager.onPublicPacketSeen(packet(MessageType.MESSAGE, now, 1)) + manager.onPublicPacketSeen(packet(MessageType.FRAGMENT, now, 2)) + manager.onPublicPacketSeen(packet(MessageType.FILE_TRANSFER, now, 3)) + + manager.handleRequestSync( + "peer", + RequestSyncPacket( + p = 1, + m = 1, + data = byteArrayOf(), + types = SyncTypeFlags.FRAGMENT + ) + ) + + assertEquals(listOf(2), sent.map { it.payload.single().toInt() }) + } + + @Test + fun `coverage cursor prevents replay of an identical history tail`() { + val tinyFilterConfig = object : GossipSyncManager.ConfigProvider { + override fun seenCapacity() = 100 + override fun gcsMaxBytes() = 1 + override fun gcsTargetFpr() = 0.01 + } + val requester = GossipSyncManager("1111222233334444", TestScope(), tinyFilterConfig) + val responder = GossipSyncManager("5555666677778888", TestScope(), tinyFilterConfig) + val now = System.currentTimeMillis() + val history = listOf( + packet(MessageType.MESSAGE, now - 100, 1), + packet(MessageType.MESSAGE, now - 200, 2), + packet(MessageType.MESSAGE, now - 300, 3) + ) + history.forEach { + requester.onPublicPacketSeen(it) + responder.onPublicPacketSeen(it) + } + val request = RequestSyncPacket.decode( + requester.buildGcsPayload(SyncTypeFlags.PUBLIC_MESSAGES) + )!! + val sent = mutableListOf() + responder.delegate = recordingDelegate(sent) + + responder.handleRequestSync("peer", request) + + assertEquals(SyncTypeFlags.PUBLIC_MESSAGES, request.types) + assertEquals(history.first().timestamp, request.sinceTimestamp) + assertEquals(emptyList(), sent.map { it.payload.single().toInt() }) + } + + @Test + fun `legacy request without type metadata remains public message only`() { + val sent = mutableListOf() + val manager = GossipSyncManager("1111222233334444", TestScope(), config) + manager.delegate = recordingDelegate(sent) + val now = System.currentTimeMillis() + manager.onPublicPacketSeen(packet(MessageType.MESSAGE, now, 1)) + manager.onPublicPacketSeen(packet(MessageType.FRAGMENT, now, 2)) + + manager.handleRequestSync("peer", RequestSyncPacket(p = 1, m = 1, data = byteArrayOf())) + + assertEquals(listOf(1), sent.map { it.payload.single().toInt() }) + } + + @Test + fun `public history survives manager recreation for post-reconnect sync`() { + val filesDir = Files.createTempDirectory("gossip-sync-test-").toFile() + val context = object : ContextWrapper(RuntimeEnvironment.getApplication()) { + override fun getApplicationContext() = this + override fun getFilesDir(): File = filesDir + } + try { + val now = System.currentTimeMillis() + GossipSyncManager("1111222233334444", TestScope(), config, context) + .onPublicPacketSeen(packet(MessageType.MESSAGE, now, 42)) + + val sent = mutableListOf() + val restored = GossipSyncManager("1111222233334444", TestScope(), config, context) + restored.delegate = recordingDelegate(sent) + restored.handleRequestSync( + "peer", + RequestSyncPacket( + p = 1, + m = 1, + data = byteArrayOf(), + types = SyncTypeFlags.PUBLIC_MESSAGES + ) + ) + + assertEquals(listOf(42), sent.map { it.payload.single().toInt() }) + } finally { + filesDir.deleteRecursively() + } + } + + @Test + fun `announcement-only request returns the latest announcement independently of message history`() { + val sent = mutableListOf() + val manager = GossipSyncManager("1111222233334444", TestScope(), config) + manager.delegate = recordingDelegate(sent) + val now = System.currentTimeMillis() + manager.onPublicPacketSeen(packet(MessageType.ANNOUNCE, now - 1_000, 6)) + manager.onPublicPacketSeen(packet(MessageType.MESSAGE, now, 7)) + + manager.handleRequestSync( + "peer", + RequestSyncPacket( + p = 1, + m = 1, + data = byteArrayOf(), + types = SyncTypeFlags.ANNOUNCE + ) + ) + + assertEquals(listOf(6), sent.map { it.payload.single().toInt() }) + } + + private fun recordingDelegate(sent: MutableList) = + object : GossipSyncManager.Delegate { + override fun sendPacket(packet: BitchatPacket) = Unit + override fun sendPacketToPeer(peerID: String, packet: BitchatPacket) { + sent += packet + } + override fun signPacketForBroadcast(packet: BitchatPacket) = packet + } + + private fun packet(type: MessageType, timestamp: Long, marker: Int) = BitchatPacket( + type = type.value, + senderID = ByteArray(8) { 1 }, + timestamp = timestamp.toULong(), + payload = byteArrayOf(marker.toByte()), + ttl = 3u + ) +} diff --git a/app/src/test/kotlin/com/bitchat/android/sync/SyncTypeFlagsTest.kt b/app/src/test/kotlin/com/bitchat/android/sync/SyncTypeFlagsTest.kt new file mode 100644 index 00000000..263dc712 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/sync/SyncTypeFlagsTest.kt @@ -0,0 +1,36 @@ +package com.bitchat.android.sync + +import com.bitchat.android.protocol.MessageType +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class SyncTypeFlagsTest { + @Test + fun `wire encoding uses compact little endian iOS bit positions`() { + val flags = SyncTypeFlags.PUBLIC_MESSAGES.union(SyncTypeFlags.FRAGMENTS_AND_FILES) + + assertArrayEquals(byteArrayOf(0xa3.toByte()), flags.encode()) + assertEquals(flags, SyncTypeFlags.decode(byteArrayOf(0xa3.toByte()))) + assertTrue(flags.contains(MessageType.ANNOUNCE)) + assertTrue(flags.contains(MessageType.FILE_TRANSFER)) + assertFalse(flags.contains(MessageType.NOISE_HANDSHAKE)) + } + + @Test + fun `unknown extended bits are normalized away`() { + val decoded = SyncTypeFlags.decode(byteArrayOf(0x03, 0xfc.toByte()))!! + + assertEquals(SyncTypeFlags.PUBLIC_MESSAGES, decoded) + assertArrayEquals(byteArrayOf(0x03), decoded.encode()) + } + + @Test + fun `empty and oversized fields are rejected`() { + assertNull(SyncTypeFlags.decode(byteArrayOf())) + assertNull(SyncTypeFlags.decode(ByteArray(9))) + } +} diff --git a/docs/client-rewrite-contracts.md b/docs/client-rewrite-contracts.md index 1abed243..7a8a5bee 100644 --- a/docs/client-rewrite-contracts.md +++ b/docs/client-rewrite-contracts.md @@ -15,8 +15,9 @@ The remaining implementation work and milestone progress are tracked in | Outer mesh packet | v1/v2 header widths, big-endian fields, flags, section order, route placement, signature placement, padding, compression, signing bytes | `BinaryProtocolTest`, `ClientRewriteWireContractTest` | | Chat payload | Flag bits, millisecond timestamp, UTF-8 byte lengths, encrypted-content substitution, optional-field order | `ClientRewriteWireContractTest` | | Inner payloads | Noise type bytes, private-message TLVs, peer-state TLVs, file-transfer TLVs, live-voice bursts, fragment header, sync request TLVs | `ClientRewriteWireContractTest`, `AuthenticatedPeerStateTest`, `PrivateMediaTransferPreparerTest`, `VoiceBurstPacketTest`, `FragmentManagerTest` | +| Store and forward | Courier type `0x04`, rotating HMAC recipient tags, Noise X seals, copy-budget and prekey-ID TLVs, 24-hour expiry, bounded tiered custody | `CourierEnvelopeTest`, `NoiseCourierTest`, `MessageRouterTest` | | Identity/security | Announcement extensions, capability bitfield endianness, Noise static-key binding, handshake identity binding, signatures | `IdentityAnnouncementTest`, `NoiseSessionManagerIdentityBindingTest`, `ClientRewritePrimitiveContractTest` | -| Sync/routing | Stable packet IDs, GCS bitstream, replay collapse, TTL handling, relay choice, confirmed graph edges | `ClientRewritePrimitiveContractTest`, `GCSFilterTest`, `PacketRelayManagerTest`, `MeshGraphServiceTest`, `TransportBridgeServiceTest` | +| Sync/routing | Stable packet IDs, GCS bitstream, type-scoped filters, bounded-history cursors, replay collapse, TTL handling, relay choice, confirmed graph edges | `ClientRewriteWireContractTest`, `ClientRewritePrimitiveContractTest`, `GCSFilterTest`, `GossipSyncManagerTest`, `PacketRelayManagerTest`, `MeshGraphServiceTest`, `TransportBridgeServiceTest` | | Nostr | Bech32, secp256k1 key derivation, NIP-01 event IDs/signatures, NIP-44 authenticated encryption, NIP-13 PoW, authenticated NIP-17 seals | `ClientRewriteNostrContractTest`, `NostrProtocolTest` | | Application state | Peer unions, canonical private conversations, chronological history, delivery/read behavior, media migration policy | `AppStateStoreTest`, `PrivateChatManagerTest`, `MediaSendingManagerMigrationTest` | @@ -53,8 +54,8 @@ shipping a rewrite, run the following on at least two physical devices: 2. Runtime permission denial/retry for Bluetooth, location, notifications, and microphone. 3. Foreground-service survival with the screen off and after process recreation. -4. Cross-client Android/iOS exchange for announce, public/private text, delivery - and read receipts, image/audio/file transfer, sync replay, and Nostr fallback. +4. Cross-client Android/iOS exchange for announce, public/private text, courier + deposit/handover, delivery/read receipts, media, six-hour sync replay, and Nostr fallback. 5. Corrupt, duplicated, reordered, delayed, and partially delivered fragments. 6. Identity rotation, verification continuity, downgrade rejection, and recovery after stale Noise sessions. diff --git a/docs/release-gate-runbook.md b/docs/release-gate-runbook.md index 8de1e063..24e5ddca 100644 --- a/docs/release-gate-runbook.md +++ b/docs/release-gate-runbook.md @@ -298,6 +298,12 @@ python3 tools/release_gate/mesh_lab.py scenario all \ | `dm` | Noise handshake both ways, encrypted DM round trips with content match | | `favorite_verification` | favorite signal, orange-outline/filled mutual state, and peer fingerprint verification | | `broadcast` | public mesh message A→B | +| `sync_recovery` | message missed while B's BLE and Wi-Fi Aware transports are disabled is recovered by the gossip sync request after reconnection | +| `sync_auto_recovery` | message missed while B is offline is recovered after restart by the production gossip scheduler, without a test-hook sync request | +| `sync_file_recovery` | broadcast file missed while B is offline is recovered through sync; validates the synthetic file digest and fragment replay | +| `durable_outbox` | a router-queued DM survives sender process death while the recipient is offline, then reaches the recipient after reconnection | +| `courier_delivery` | three-phone sender→courier→recipient handoff: recipient is offline for deposit and sender remains offline for recipient pickup | +| `courier_contract` | on-device courier wire/store contract: prekey TLV retention, split reservations, cancellation, reverse commits, and persisted spray history | | `ptt_dm` | Noise-encrypted 440 Hz PTT in both directions; asserts real-time capture, zero sequence gaps, decoded PCM duration/energy/continuity, and finalized-note absorption | | `ptt_broadcast` | signed public 440 Hz PTT with the same bidirectional packet and decoded-audio quality assertions | | `file` | 1 KB broadcast file, receiver SHA-256 matches fixture | @@ -307,7 +313,7 @@ python3 tools/release_gate/mesh_lab.py scenario all \ | `raw` | raw packet injection is accepted by the mesh | | `session_recovery` | force-stop B mid-session: identity persists, re-handshake, DMs flow again | | `identity_reset` | pm clear B mid-session: new identity, rediscovery, handshake, DMs | -| `all` | every scenario above in sequence | +| `all` | every supported two-phone scenario in sequence, plus `courier_delivery` when a third phone is supplied | Each run writes `-evidence.json` to `--out` (digests, timings, session states, logcat excerpts on failure) and exits non-zero on failure. @@ -326,10 +332,15 @@ python3 tools/release_gate/mesh_lab.py cmd --serial state # full mesh See `TestHookDriver.kt` for the full command set (`ping`, `start`, `stop`, `whoami`, `set_nickname`, `scan`, `peers`, `connect`, `handshake`, `session`, -`announce`, `broadcast_msg`, `dm_send`, `dm_recv`, `msg_recv`, `favorite_set`, +`announce`, `broadcast_msg`, `dm_send`, `router_private_send`, `router_resume`, +`dm_recv`, `msg_recv`, `favorite_set`, `favorite_status`, `verification_set`, `verification_status`, `file_send`, -`file_recv`, `file_cancel`, `ptt_send`, `ptt_recv`, `raw_send`, `ble`, `state`, -`clear_results`). +`file_recv`, `file_cancel`, `ptt_send`, `ptt_recv`, `raw_send`, `courier_contract`, +`cache_peer_identity`, `sync_request`, `ble`, `wifi_aware`, `state`, `clear_results`). + +`msg_recv` accepts `include_existing=true` for deterministic backfill checks when +the packet's original timestamp predates the receive hook; use a unique synthetic +content token with this mode. ### Troubleshooting diff --git a/docs/sync.md b/docs/sync.md index 0bbd0735..919a8e69 100644 --- a/docs/sync.md +++ b/docs/sync.md @@ -1,17 +1,19 @@ # GCS Filter Sync (REQUEST_SYNC) -This document specifies the gossip-based synchronization feature for BitChat, inspired by Plumtree. It ensures eventual consistency of public packets (ANNOUNCE and broadcast MESSAGE) across nodes via periodic sync requests containing a compact Golomb‑Coded Set (GCS) of recently seen packets. +This document specifies the gossip-based synchronization feature for BitChat, inspired by Plumtree. It ensures eventual consistency of public packets across nodes via periodic, type-scoped sync requests containing a compact Golomb‑Coded Set (GCS) of recently seen packets. ## Overview - Each node maintains a rolling set of public BitChat packets it has seen recently: - Broadcast messages (MessageType.MESSAGE where recipient is broadcast) - Identity announcements (MessageType.ANNOUNCE) - - Default retention is 100 recent packets (configurable in the debug sheet). This value is the maximum number of packets that are synchronized per request (across both types combined). + - Broadcast fragments (MessageType.FRAGMENT) + - Broadcast file transfers (MessageType.FILE_TRANSFER) + - Default retention is 100 recent packets per local store (configurable in the debug sheet). - Nodes do not maintain a rolling Bloom filter. Instead, they compute a GCS filter on demand when sending a REQUEST_SYNC. -- Every 30 seconds, a node sends a REQUEST_SYNC packet to all immediate neighbors (local only; not relayed). -- Additionally, 5 seconds after the first announcement from a newly directly connected peer is detected, a node sends a REQUEST_SYNC only to that peer (unicast; local only). -- The receiver checks which packets are not in the sender’s filter and sends those packets back. For announcements, only the latest announcement per peerID is sent; for broadcast messages, all missing ones are sent. +- Every 30 seconds, Android sends separate public-message, fragment, and file-transfer REQUEST_SYNC rounds to immediate neighbors (local only; not relayed). Keeping the filters type-scoped prevents one high-volume class from crowding another out. +- Additionally, 5 seconds after the first announcement from a newly directly connected peer is detected, a node sends a combined type-scoped REQUEST_SYNC only to that peer (unicast; local only). +- The receiver checks only the packet types named by the request and sends packets absent from the filter. For announcements, only the latest announcement per peerID is sent. This synchronization is strictly local (not relayed), ensuring only immediate neighbors participate and preventing wide-area flooding while converging content across the mesh. @@ -36,9 +38,7 @@ Implementation: `com.bitchat.android.sync.GCSFilter`. - Maximum number of elements that fit into the filter is estimated as: N_max ≈ floor((8 * sizeBytes) / (P + 2)) - This estimate is used to cap the set; the actual encoder will trim further if needed to stay within the configured size. - What goes into the set: - - Combine the following and sort by packet timestamp (descending): - - Broadcast messages (MessageType 1) - - The most recent ANNOUNCE per peer + - Select only the classes named by the request's `types` field, then sort matching packets by timestamp (descending). Android stores broadcast messages, the most recent ANNOUNCE per peer, broadcast fragments, and broadcast file transfers. - Take at most `min(N_max, maxPacketsPerSync)` items from this ordered list. - Compute the 16-byte Packet ID (see below), then for hashing use the first 8 bytes of SHA‑256 over the 16‑byte ID. - Map each hash to [0, M) with M = N * 2^P; sort ascending and encode deltas with Golomb‑Rice parameter P. @@ -56,6 +56,10 @@ MessageType: `REQUEST_SYNC (0x21)` - 0x01: P (uint8) — Golomb‑Rice parameter - 0x02: M (uint32) — hash range N * 2^P - 0x03: data (opaque) — GCS bitstream (MSB‑first bit packing) + - 0x04: types (1–8 byte compact little-endian bitfield) — packet classes represented by this filter + - 0x05: sinceTimestamp (uint64, big-endian) — timestamp of the oldest packet represented when the candidate tail did not fit + +`types` uses the cross-client bit mapping: ANNOUNCE=bit 0, MESSAGE=bit 1, LEAVE=bit 2, NOISE_HANDSHAKE=bit 3, NOISE_ENCRYPTED=bit 4, FRAGMENT=bit 5, REQUEST_SYNC=bit 6, and FILE_TRANSFER=bit 7. Implementations ignore bits they do not recognize. When `types` is absent, receivers use the legacy scope of ANNOUNCE plus broadcast MESSAGE. Notes: - The GCS bitstream uses MSB‑first packing (bit 7 is the first bit in each byte). @@ -71,7 +75,9 @@ Sender behavior: Receiver behavior: - Decode the REQUEST_SYNC payload and reconstruct the sorted set of mapped values using the provided P, M, and bitstream. -- For each locally stored public packet ID: +- Restrict the diff to the packet classes in `types` (or the legacy public-message scope when absent). +- For each locally stored packet ID in that scope: + - If `sinceTimestamp` is present, skip messages, fragments, and files older than it because they were outside the requester's bounded filter rather than missing. ANNOUNCE is exempt so a peer can still obtain current verification keys. - Compute h64(ID) % M and check if it is in the reconstructed set; if NOT present, send the original packet back with `ttl=0` to the requester only. - For announcements, send only the latest announcement per (sender peerID). - For broadcast messages, send all missing ones. @@ -90,6 +96,8 @@ Important: original packets are sent unmodified to preserve original signatures Included in sync: - Public broadcast messages: `MessageType.MESSAGE` with BROADCAST recipient (or null recipient). - Identity announcements: `MessageType.ANNOUNCE`. +- Broadcast fragments: `MessageType.FRAGMENT` with BROADCAST recipient (or null recipient). +- Broadcast files: `MessageType.FILE_TRANSFER` with BROADCAST recipient (or null recipient). - Both packets produced by other peers and packets produced by the requester itself MUST be represented in the requester’s GCS; the responder MUST track and consider its own produced public packets as candidates to return when they are missing on the requester. - Announcements included in the GCS MUST be at most 60 seconds old at the time of filter construction; older announcements are excluded by pruning. @@ -130,8 +138,8 @@ The following items require consensus across all implementations to ensure inter - Packet ID recipe: first 16 bytes of SHA‑256(type | senderID | timestamp | payload). - GCS hashing function and mapping to [0, M) as specified above (v1), and MSB‑first bit packing for the bitstream. -- Payload encoding: TLV with 16‑bit big‑endian lengths; TLV types 0x01 = P (uint8), 0x02 = M (uint32), 0x03 = data (opaque). -- Packet type and scope: REQUEST_SYNC = 0x21; local-only (not relayed); only ANNOUNCE and broadcast MESSAGE are synchronized; ANNOUNCE de‑dupe is “latest per sender peerID”. +- Payload encoding: TLV with 16‑bit big‑endian lengths; TLV types 0x01 = P (uint8), 0x02 = M (uint32), 0x03 = data (opaque), 0x04 = compact little-endian type flags, and 0x05 = big-endian coverage cursor. +- Packet type and scope: REQUEST_SYNC = 0x21; local-only (not relayed); only requested, supported public packet classes are synchronized; ANNOUNCE de‑dupe is “latest per sender peerID”. The following are requester‑defined and communicated or local policy (no global agreement required): @@ -142,7 +150,7 @@ The following are requester‑defined and communicated or local policy (no globa Validation and limits (recommended): - Reject malformed REQUEST_SYNC payloads (e.g., P < 1, M <= 0, or data length too large for local limits). -- Practical bounds: data length in [0, 1024]; P in [1, 24]; M up to 2^32‑1. +- Practical bounds: data length in [0, 1024]; P in [1, 32]; M up to 2^32‑1. Versioning: diff --git a/tools/release_gate/mesh_lab.py b/tools/release_gate/mesh_lab.py index 7abe8085..99aefc5b 100644 --- a/tools/release_gate/mesh_lab.py +++ b/tools/release_gate/mesh_lab.py @@ -166,11 +166,14 @@ class Device: def reset_bluetooth(self) -> None: """Cycle the BT adapter; clears zombie GATT connections from peer restarts.""" - _shell(self.serial, "svc bluetooth disable") + self.disable_bluetooth() time.sleep(2) _shell(self.serial, "svc bluetooth enable") time.sleep(3) + def disable_bluetooth(self) -> None: + _shell(self.serial, "svc bluetooth disable") + def enable_bluetooth(self) -> None: subprocess.run( [find_adb(), "-s", self.serial, "shell", "svc", "bluetooth", "enable"], @@ -365,6 +368,31 @@ def setup_pair( wait_for_mutual_discovery(a, b) +def setup_triad( + a: Device, + b: Device, + c: Device, + apk: Path | None, + nickname_a: str, + nickname_b: str, + nickname_c: str, +) -> None: + """Install and isolate three phones, then ensure every pair can discover each other.""" + for device, nickname in ((a, nickname_a), (b, nickname_b), (c, nickname_c)): + device.reset_bluetooth() + device.enable_bluetooth() + if apk is not None: + device.install(apk) + device.clear_app_data() + device.grant_permissions() + device.wake() + device.launch() + device.cmd_ok("start") + device.cmd_ok("set_nickname", name=nickname) + for left, right in ((a, b), (a, c), (b, c)): + wait_for_mutual_discovery(left, right) + + def whoami(device: Device) -> dict: return device.cmd_ok("whoami") @@ -391,6 +419,47 @@ def wait_for_mutual_discovery(a: Device, b: Device) -> None: fb.result() +def wait_for_peer_absent(device: Device, peer_id: str, timeout_s: int = 90) -> None: + """Wait until the peer is absent or no longer connected in the visible mesh state.""" + deadline = time.monotonic() + timeout_s + while time.monotonic() < deadline: + peers = device.cmd_ok("peers").get("peers", []) + peer = next((item for item in peers if item.get("id") == peer_id), None) + if peer is None or not peer.get("connected", False): + return + time.sleep(2) + raise MeshLabError(f"[{device.alias}] peer {peer_id} remained connected after transport shutdown") + + +def disable_transports(device: Device) -> bool: + """Make a phone unreachable through either local mesh transport and return its Wi-Fi setting.""" + device.cmd_ok("ble", enabled=False) + return bool(device.cmd_ok("wifi_aware", enabled=False)["previous_enabled"]) + + +def resume_transports(device: Device, wifi_enabled: bool) -> None: + """Bring a force-stopped phone back as the same identity with its prior Wi-Fi setting.""" + device.wake() + device.launch() + device.cmd_ok("start") + device.cmd_ok("ble", enabled=True) + device.cmd_ok("wifi_aware", enabled=wifi_enabled) + + +def take_device_offline(device: Device) -> bool: + """Stop the app and radio so the counterpart must observe a real link loss.""" + wifi_enabled = disable_transports(device) + device.force_stop() + device.disable_bluetooth() + return wifi_enabled + + +def bring_device_online(device: Device, wifi_enabled: bool) -> None: + device.enable_bluetooth() + time.sleep(3) + resume_transports(device, wifi_enabled) + + # MARK: - scenarios def scenario_dm(a: Device, b: Device) -> dict: @@ -532,6 +601,306 @@ def scenario_broadcast(a: Device, b: Device) -> dict: return {"send": send_result, "recv": recv_result} +def scenario_sync_recovery(a: Device, b: Device) -> dict: + """Miss a public message while B's transports are offline, then receive it via gossip sync.""" + id_a = whoami(a)["peer_id"] + id_b = whoami(b)["peer_id"] + token = f"sync-{uuid.uuid4().hex[:8]}" + b.cmd_ok("ble", enabled=False) + wifi_before = b.cmd_ok("wifi_aware", enabled=False)["previous_enabled"] + try: + send_result = a.cmd_ok("broadcast_msg", 30_000, content=f"sync recovery {token}") + # The hook returns after dispatching the send coroutine. Give the sender's + # gossip observer time to retain the packet before taking B offline. + time.sleep(2) + offline_result = b.cmd("msg_recv", 5_000, contains=token, include_existing=True) + if offline_result.get("status") == "ok": + raise MeshLabError(f"message was delivered while receiver transports were disabled: {offline_result}") + + # Restart B so the initial sync request is scheduled from a fresh peer + # observation instead of relying on an already-cached relationship. + b.force_stop() + b.wake() + b.launch() + b.cmd_ok("start") + b.cmd_ok("ble", enabled=True) + with concurrent.futures.ThreadPoolExecutor(max_workers=2) as pool: + recv = pool.submit( + b.cmd_ok, + "msg_recv", + 180_000, + contains=token, + include_existing=True, + ) + time.sleep(2) + ensure_direct_link(a, b, id_a, id_b) + b.cmd_ok("sync_request", peer=id_a) + recv_result = recv.result() + assert recv_result["from"] == id_a, recv_result + return { + "send": send_result, + "offline_delivery": {"status": offline_result.get("status", "missing")}, + "synced_recv": recv_result, + } + finally: + # Leave the pair usable if an assertion or timeout interrupts the recovery. + try: + b.cmd_ok("ble", enabled=True) + except MeshLabError: + pass + try: + b.cmd_ok("wifi_aware", enabled=wifi_before) + except MeshLabError: + pass + + +def scenario_sync_auto_recovery(a: Device, b: Device) -> dict: + """A restarted receiver recovers a missed public message without a test-hook sync request.""" + id_a = whoami(a)["peer_id"] + id_b = whoami(b)["peer_id"] + token = f"sync-auto-{uuid.uuid4().hex[:8]}" + wifi_before = disable_transports(b) + try: + send_result = a.cmd_ok("broadcast_msg", 30_000, content=f"automatic sync recovery {token}") + time.sleep(2) + offline_result = b.cmd("msg_recv", 5_000, contains=token, include_existing=True) + if offline_result.get("status") == "ok": + raise MeshLabError(f"message was delivered while receiver transports were disabled: {offline_result}") + + b.force_stop() + resume_transports(b, wifi_before) + with concurrent.futures.ThreadPoolExecutor(max_workers=2) as pool: + recv = pool.submit( + b.cmd_ok, + "msg_recv", + 180_000, + contains=token, + include_existing=True, + ) + time.sleep(2) + ensure_direct_link(a, b, id_a, id_b) + # No sync_request command here: this must arrive from the production initial/periodic + # gossip scheduler created by the restarted receiver. + recv_result = recv.result() + assert recv_result["from"] == id_a, recv_result + return { + "send": send_result, + "offline_delivery": {"status": offline_result.get("status", "missing")}, + "synced_recv": recv_result, + } + finally: + try: + bring_device_online(b, wifi_before) + except MeshLabError: + pass + + +def scenario_sync_file_recovery(a: Device, b: Device) -> dict: + """Sync an offline-missed broadcast transfer, exercising FILE_TRANSFER and FRAGMENT replay.""" + id_a = whoami(a)["peer_id"] + id_b = whoami(b)["peer_id"] + fixture = make_fixtures( + Path(tempfile.mkdtemp(prefix="meshlab-sync-file-")), + names=["small_1k.bin"], + )["small_1k.bin"] + name = f"sync-file-{uuid.uuid4().hex[:8]}.bin" + remote = a.push_fixture(fixture["path"], name=name) + b.clear_incoming() + wifi_before = disable_transports(b) + try: + send_result = a.cmd_ok("file_send", 180_000, path=remote) + time.sleep(2) + offline_result = b.cmd("file_recv", 10_000, name_contains=name) + if offline_result.get("status") == "ok": + raise MeshLabError(f"file was delivered while receiver transports were disabled: {offline_result}") + + b.force_stop() + resume_transports(b, wifi_before) + with concurrent.futures.ThreadPoolExecutor(max_workers=2) as pool: + recv = pool.submit(b.cmd_ok, "file_recv", 240_000, name_contains=name) + time.sleep(2) + ensure_direct_link(a, b, id_a, id_b) + b.cmd_ok("sync_request", peer=id_a) + recv_result = recv.result() + if recv_result["sha256"] != fixture["sha256"]: + raise MeshLabError("synced file digest did not match the synthetic fixture") + return { + "send": send_result, + "offline_delivery": {"status": offline_result.get("status", "missing")}, + "synced_recv": { + "name": recv_result["name"], + "bytes": recv_result["bytes"], + "digest_match": True, + }, + } + finally: + try: + b.cmd_ok("ble", enabled=True) + except MeshLabError: + pass + try: + b.cmd_ok("wifi_aware", enabled=wifi_before) + except MeshLabError: + pass + + +def scenario_durable_outbox(a: Device, b: Device) -> dict: + """Queue a private message offline, kill the sender, and deliver it after both phones return.""" + id_a = whoami(a)["peer_id"] + id_b = whoami(b)["peer_id"] + ensure_direct_link(a, b, id_a, id_b) + force_handshake(a, id_b) + force_handshake(b, id_a) + a.cmd_ok("cache_peer_identity", peer=id_b) + + token = f"durable-outbox-{uuid.uuid4().hex[:8]}" + message_id = f"outbox-{uuid.uuid4().hex}" + wifi_before = take_device_offline(b) + try: + wait_for_peer_absent(a, id_b) + queued: dict | None = None + for _attempt in range(5): + candidate = a.cmd_ok( + "router_private_send", + peer=id_b, + content=f"durable outbox {token}", + msg_id=message_id, + ) + if candidate.get("route") == "QUEUED": + queued = candidate + break + time.sleep(3) + if queued is None: + raise MeshLabError("private message did not enter the durable outbox while recipient was offline") + + # The queued entry must survive a complete sender process death, not just an in-memory + # reconnect. B is restored before A so its receiver is ready for the resumed delivery. + a.force_stop() + bring_device_online(b, wifi_before) + a.wake() + a.launch() + a.cmd_ok("start") + a.cmd_ok("router_resume") + with concurrent.futures.ThreadPoolExecutor(max_workers=2) as pool: + recv = pool.submit(b.cmd_ok, "dm_recv", 180_000, peer=id_a, contains=token) + time.sleep(2) + ensure_direct_link(a, b, id_a, id_b) + force_handshake(a, id_b) + force_handshake(b, id_a) + recv_result = recv.result() + assert recv_result["from"] == id_a, recv_result + return { + "queued_route": queued["route"], + "sender_process_restarted": True, + "received_after_restart": {"msg_id": recv_result["msg_id"], "from_sender": True}, + } + finally: + try: + bring_device_online(b, wifi_before) + except MeshLabError: + pass + + +def _make_mutual_favorites(a: Device, b: Device, id_a: str, id_b: str) -> None: + """Establish a trusted direct A↔B relationship for a courier deposit.""" + ensure_direct_link(a, b, id_a, id_b) + force_handshake(a, id_b) + force_handshake(b, id_a) + a.cmd_ok("favorite_set", peer=id_b, enabled=True) + b.cmd_ok("favorite_set", peer=id_a, enabled=True) + deadline = time.monotonic() + 45 + while time.monotonic() < deadline: + a_status = a.cmd_ok("favorite_status", peer=id_b) + b_status = b.cmd_ok("favorite_status", peer=id_a) + if a_status.get("is_mutual") and b_status.get("is_mutual"): + return + time.sleep(1) + raise MeshLabError("courier depositor and courier did not establish a mutual favorite relationship") + + +def scenario_courier_delivery(a: Device, b: Device, c: Device) -> dict: + """A deposits to B while C is offline; C returns only after A is unavailable.""" + id_a = whoami(a)["peer_id"] + id_b = whoami(b)["peer_id"] + id_c = whoami(c)["peer_id"] + _make_mutual_favorites(a, b, id_a, id_b) + ensure_direct_link(a, c, id_a, id_c) + a.cmd_ok("cache_peer_identity", peer=id_c) + + token = f"courier-delivery-{uuid.uuid4().hex[:8]}" + message_id = f"courier-{uuid.uuid4().hex}" + wifi_c = take_device_offline(c) + wifi_a: bool | None = None + try: + wait_for_peer_absent(a, id_c) + ensure_direct_link(a, b, id_a, id_b) + + queued: dict | None = None + for _attempt in range(5): + candidate = a.cmd_ok( + "router_private_send", + peer=id_c, + content=f"courier delivery {token}", + msg_id=message_id, + ) + if candidate.get("route") == "QUEUED": + queued = candidate + break + time.sleep(3) + if queued is None: + raise MeshLabError("offline recipient message did not enter the courier-capable outbox") + + # A must be unavailable when C returns. This prevents the sender's direct outbox retry + # from masking whether B actually retained and handed over the courier envelope. + wifi_a = take_device_offline(a) + wait_for_peer_absent(b, id_a) + + bring_device_online(c, wifi_c) + with concurrent.futures.ThreadPoolExecutor(max_workers=2) as pool: + recv = pool.submit(c.cmd_ok, "dm_recv", 240_000, peer=id_a, contains=token) + time.sleep(2) + ensure_direct_link(b, c, id_b, id_c) + c.cmd_ok("announce") + recv_result = recv.result() + assert recv_result["from"] == id_a, recv_result + return { + "queued_route": queued["route"], + "sender_offline_before_recipient_return": True, + "received_from_original_sender": True, + "received_message_id": recv_result["msg_id"], + } + finally: + try: + bring_device_online(c, wifi_c) + except MeshLabError: + pass + if wifi_a is not None: + try: + bring_device_online(a, wifi_a) + except MeshLabError: + pass + + +def scenario_courier_contract(a: Device, b: Device) -> dict: + """Run the real courier wire/store contract on device A's debug build.""" + result = a.cmd_ok("courier_contract", 60_000) + expected = { + "wire_prekey_id_preserved": True, + "stored_prekey_id_preserved": True, + "first_reserved_copies": 2, + "second_reserved_copies": 1, + "same_courier_second_reservation_empty": True, + "reverse_order_commits": True, + "cancel_restored_eligibility": True, + "persisted_spray_history": True, + "remaining_copies_after_restart": 1, + } + for field, value in expected.items(): + if result.get(field) != value: + raise MeshLabError(f"courier contract field {field}={result.get(field)!r}, expected {value!r}") + return result + + def _ptt_one_way( sender: Device, receiver: Device, @@ -728,26 +1097,26 @@ def ensure_direct_link(a: Device, b: Device, id_a: str, id_b: str) -> None: wait_for_peer(a, id_b, timeout_s=120) wait_for_peer(b, id_a, timeout_s=120) for device, peer, announcer in ((a, id_b, b), (b, id_a, a)): - connected = False last: dict = {} for _attempt in range(4): last = device.cmd("connect", timeout_ms=45_000, peer=peer) if last.get("status") == "ok" and last.get("direct"): - connected = True - break - # Already acceptable if the mesh formed a direct link on its own. - peers = device.cmd_ok("peers").get("peers", []) - match = next((p for p in peers if p.get("id") == peer), None) - if match and match.get("direct"): - connected = True - break + # A GATT link carries traffic both ways. Requiring a second client connection from + # the other endpoint turns a healthy single direct link into a false test failure, + # especially after one phone has just restarted and has not rebuilt its address map. + return + # Already acceptable if either endpoint formed a direct link on its own. + for observer, observed_peer in ((device, peer), (announcer, whoami(device)["peer_id"])): + peers = observer.cmd_ok("peers").get("peers", []) + match = next((p for p in peers if p.get("id") == observed_peer), None) + if match and match.get("direct"): + return try: announcer.cmd_ok("announce") except MeshLabError: pass time.sleep(4) - if not connected: - raise MeshLabError(f"[{device.alias}] no direct link to {peer}: connect={last}") + raise MeshLabError(f"no direct link formed between {a.alias} and {b.alias}: connect={last}") def force_handshake(device: Device, peer_id: str, attempts: int = 5, per_attempt_s: int = 20) -> dict: @@ -869,6 +1238,11 @@ SCENARIOS = { "dm": scenario_dm, "favorite_verification": scenario_favorite_verification, "broadcast": scenario_broadcast, + "sync_recovery": scenario_sync_recovery, + "sync_auto_recovery": scenario_sync_auto_recovery, + "sync_file_recovery": scenario_sync_file_recovery, + "durable_outbox": scenario_durable_outbox, + "courier_contract": scenario_courier_contract, "ptt_dm": scenario_ptt_dm, "ptt_broadcast": scenario_ptt_broadcast, # Broadcast transfers are receiver-capped at 256 fragments (~120 KB); only @@ -893,6 +1267,11 @@ SCENARIOS = { "identity_reset": scenario_identity_reset, } +# End-to-end peer courier delivery needs distinct sender, courier, and recipient identities. +TRIAD_SCENARIOS = { + "courier_delivery": scenario_courier_delivery, +} + # Scenarios supported when device B is a watch (file scenarios are receive-only: phone sends, # the watch must receive with matching digests). WATCH_SCENARIOS = [ @@ -909,9 +1288,16 @@ WATCH_SCENARIOS = [ ] -def run_scenario(name: str, a: Device, b: Device, out: Path | None) -> dict: +def run_scenario( + name: str, + a: Device, + b: Device, + out: Path | None, + c: Device | None = None, +) -> dict: started = time.time() - evidence: dict[str, object] = {"scenario": name, "devices": [a.alias, b.alias]} + devices = [a, b] + ([c] if c is not None else []) + evidence: dict[str, object] = {"scenario": name, "devices": [device.alias for device in devices]} try: supported = WATCH_SCENARIOS if isinstance(b, WatchDevice) else list(SCENARIOS) if name == "all": @@ -922,9 +1308,20 @@ def run_scenario(name: str, a: Device, b: Device, out: Path | None) -> dict: results[n] = sub.get("results", {"error": sub.get("error", "unknown")}) if sub["status"] != "pass": failures.append(n) + if c is not None: + sub = run_scenario("courier_delivery", a, b, out, c) + results["courier_delivery"] = sub.get("results", {"error": sub.get("error", "unknown")}) + if sub["status"] != "pass": + failures.append("courier_delivery") evidence["results"] = results if failures: raise MeshLabError(f"sub-scenarios failed: {', '.join(failures)}") + elif name in TRIAD_SCENARIOS: + if c is None: + raise MeshLabError(f"scenario '{name}' requires --serial-c") + if isinstance(b, WatchDevice): + raise MeshLabError(f"scenario '{name}' requires three phones") + evidence["results"] = TRIAD_SCENARIOS[name](a, b, c) elif name not in supported: raise MeshLabError(f"scenario '{name}' is not supported on device '{b.alias}'") else: @@ -933,7 +1330,7 @@ def run_scenario(name: str, a: Device, b: Device, out: Path | None) -> dict: except (MeshLabError, AssertionError) as error: evidence["status"] = "fail" evidence["error"] = str(error) - evidence["logcat"] = {d.alias: d.logcat_dump() for d in (a, b)} + evidence["logcat"] = {d.alias: d.logcat_dump() for d in devices} evidence["duration_s"] = round(time.time() - started, 1) if out is not None: out.mkdir(parents=True, exist_ok=True) @@ -950,16 +1347,19 @@ def build_parser() -> argparse.ArgumentParser: setup = commands.add_parser("setup", help="install, grant, launch, nickname, discover") setup.add_argument("--serial-a", required=True) setup.add_argument("--serial-b") + setup.add_argument("--serial-c", help="third phone for a three-party scenario") setup.add_argument("--serial-watch", help="watch serial; used as device B (overrides --serial-b)") setup.add_argument("--apk", type=Path, default=None) setup.add_argument("--watch-apk", type=Path, default=None) setup.add_argument("--nickname-a", default="alice") setup.add_argument("--nickname-b", default="bob") + setup.add_argument("--nickname-c", default="charlie") - scenario = commands.add_parser("scenario", help="run a test scenario on two devices") - scenario.add_argument("name", choices=[*SCENARIOS.keys(), "all"]) + scenario = commands.add_parser("scenario", help="run a test scenario on two phones, or three for courier delivery") + scenario.add_argument("name", choices=[*SCENARIOS.keys(), *TRIAD_SCENARIOS.keys(), "all"]) scenario.add_argument("--serial-a", required=True) scenario.add_argument("--serial-b") + scenario.add_argument("--serial-c", help="third phone; required for courier_delivery") scenario.add_argument("--serial-watch", help="watch serial; used as device B (overrides --serial-b)") scenario.add_argument("--out", type=Path, default=None, help="evidence output directory") @@ -972,30 +1372,36 @@ def build_parser() -> argparse.ArgumentParser: return parser -def _resolve_devices(args: argparse.Namespace) -> tuple[Device, Device]: +def _resolve_devices(args: argparse.Namespace) -> tuple[Device, Device, Device | None]: """Device A is always the phone; device B is a watch when --serial-watch is given.""" a = Device(args.serial_a, "alpha") if getattr(args, "serial_watch", None): - return a, WatchDevice(args.serial_watch) + if getattr(args, "serial_c", None): + raise MeshLabError("--serial-c cannot be combined with --serial-watch") + return a, WatchDevice(args.serial_watch), None if not getattr(args, "serial_b", None): raise MeshLabError("either --serial-b or --serial-watch is required") - return a, Device(args.serial_b, "beta") + c = Device(args.serial_c, "charlie") if getattr(args, "serial_c", None) else None + return a, Device(args.serial_b, "beta"), c def main(argv: list[str] | None = None) -> int: args = build_parser().parse_args(argv) try: if args.command == "setup": - a, b = _resolve_devices(args) + a, b, c = _resolve_devices(args) nickname_b = "watch" if isinstance(b, WatchDevice) and args.nickname_b == "bob" else args.nickname_b - setup_pair( - a, b, args.apk, args.nickname_a, nickname_b, - apk_b=args.watch_apk if isinstance(b, WatchDevice) else None, - ) + if c is not None: + setup_triad(a, b, c, args.apk, args.nickname_a, nickname_b, args.nickname_c) + else: + setup_pair( + a, b, args.apk, args.nickname_a, nickname_b, + apk_b=args.watch_apk if isinstance(b, WatchDevice) else None, + ) print(json.dumps({"status": "ok", "step": "setup"})) elif args.command == "scenario": - a, b = _resolve_devices(args) - evidence = run_scenario(args.name, a, b, args.out) + a, b, c = _resolve_devices(args) + evidence = run_scenario(args.name, a, b, args.out, c) print(json.dumps(evidence, indent=2, default=str)) return 0 if evidence["status"] == "pass" else 1 elif args.command == "cmd":