From 6e53bef71ea2c1c08c84b3af479ea65dc41ba8a9 Mon Sep 17 00:00:00 2001 From: Taksh Date: Tue, 25 Aug 2026 07:39:23 +0530 Subject: [PATCH 1/3] fix(ble): require a verified ANNOUNCE before granting broadcast notifications A peer could subscribe to the full mesh feed by writing the CCCD alone. Gate subscription on a signature-valid announce from that address and fail closed with GATT_INSUFFICIENT_AUTHORIZATION until one arrives. --- .../mesh/BluetoothConnectionManager.kt | 4 ++++ .../mesh/BluetoothGattServerManager.kt | 20 +++++++++++++++++++ .../android/mesh/BluetoothMeshService.kt | 4 ++++ .../mesh/GattNotificationEligibility.kt | 11 ++++++++++ .../mesh/GattNotificationEligibilityTest.kt | 17 ++++++++++++++++ 5 files changed, 56 insertions(+) create mode 100644 app/src/main/java/com/bitchat/android/mesh/GattNotificationEligibility.kt create mode 100644 app/src/test/kotlin/com/bitchat/android/mesh/GattNotificationEligibilityTest.kt 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..69f15294 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionManager.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionManager.kt @@ -101,6 +101,10 @@ class BluetoothConnectionManager( // Public property for address-peer mapping val addressPeerMap get() = connectionTracker.addressPeerMap + fun noteVerifiedAnnounceReceived(deviceAddress: String) { + connectionTracker.noteAnnounceReceived(deviceAddress) + } + fun observePeerIfCurrent(deviceAddress: String, linkID: String, peerID: String): Boolean = connectionTracker.observePeerIfCurrent(deviceAddress, linkID, peerID) diff --git a/app/src/main/java/com/bitchat/android/mesh/BluetoothGattServerManager.kt b/app/src/main/java/com/bitchat/android/mesh/BluetoothGattServerManager.kt index 038cd960..ab021b43 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothGattServerManager.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothGattServerManager.kt @@ -283,6 +283,26 @@ class BluetoothGattServerManager( } if (BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE.contentEquals(value)) { + if (!GattNotificationEligibility.maySubscribe( + connectionTracker.hasSeenFirstAnnounce(device.address) + ) + ) { + Log.w( + TAG, + "Rejecting broadcast subscription from ${device.address} before verified ANNOUNCE" + ) + if (responseNeeded) { + gattServer?.sendResponse( + device, + requestId, + BluetoothGatt.GATT_INSUFFICIENT_AUTHORIZATION, + 0, + null + ) + } + return + } + connectionTracker.addSubscribedDevice(device) connectionScope.launch { 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..2b9ab5ab 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt @@ -579,6 +579,10 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic val result = messageHandler.handleAnnounceWithResult(routed) if (result !is AnnounceHandlingResult.Accepted) return false + routed.relayAddress?.let { deviceAddress -> + connectionManager.noteVerifiedAnnounceReceived(deviceAddress) + } + DirectLinkAnnouncementPolicy.observationFor(routed, MAX_TTL)?.let { observation -> if (connectionManager.observePeerIfCurrent( observation.relayAddress, diff --git a/app/src/main/java/com/bitchat/android/mesh/GattNotificationEligibility.kt b/app/src/main/java/com/bitchat/android/mesh/GattNotificationEligibility.kt new file mode 100644 index 00000000..768a0e4f --- /dev/null +++ b/app/src/main/java/com/bitchat/android/mesh/GattNotificationEligibility.kt @@ -0,0 +1,11 @@ +package com.bitchat.android.mesh + +/** + * Whether a BLE central may subscribe to the mesh broadcast notification feed. + * + * Granting notifications before the peer has sent a verified ANNOUNCE would let + * a passive listener harvest the full mesh feed with no participation (#901). + */ +internal object GattNotificationEligibility { + fun maySubscribe(hasVerifiedAnnounce: Boolean): Boolean = hasVerifiedAnnounce +} diff --git a/app/src/test/kotlin/com/bitchat/android/mesh/GattNotificationEligibilityTest.kt b/app/src/test/kotlin/com/bitchat/android/mesh/GattNotificationEligibilityTest.kt new file mode 100644 index 00000000..d295c3fd --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/mesh/GattNotificationEligibilityTest.kt @@ -0,0 +1,17 @@ +package com.bitchat.android.mesh + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class GattNotificationEligibilityTest { + @Test + fun `subscription denied before a verified announce`() { + assertFalse(GattNotificationEligibility.maySubscribe(hasVerifiedAnnounce = false)) + } + + @Test + fun `subscription allowed after a verified announce`() { + assertTrue(GattNotificationEligibility.maySubscribe(hasVerifiedAnnounce = true)) + } +} From 78615b79519f10c613f8cf85362f15bed632c4b6 Mon Sep 17 00:00:00 2001 From: Taksh Date: Tue, 25 Aug 2026 07:52:33 +0530 Subject: [PATCH 2/3] fix(ble): defer the broadcast feed until a verified ANNOUNCE, don't reject the CCCD Android clients write the CCCD during discovery, before they announce, and never retry. Rejecting that write left a legitimate peer unsubscribed even after the announce landed. --- .../mesh/BluetoothConnectionTracker.kt | 44 +++++++++++++++- .../mesh/BluetoothGattServerManager.kt | 22 ++------ .../mesh/GattNotificationEligibility.kt | 20 +++++-- ...othConnectionTrackerLinkObservationTest.kt | 52 +++++++++++++++++++ .../mesh/GattNotificationEligibilityTest.kt | 17 +++--- 5 files changed, 124 insertions(+), 31 deletions(-) diff --git a/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionTracker.kt b/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionTracker.kt index 94feb68d..f67cc918 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionTracker.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionTracker.kt @@ -30,6 +30,9 @@ class BluetoothConnectionTracker( val addressPeerMap = ConcurrentHashMap() // Track whether we have seen the first ANNOUNCE on a given device connection private val firstAnnounceSeen = ConcurrentHashMap() + // CCCD writes that arrived before a verified ANNOUNCE. Grant the feed later + // rather than rejecting the descriptor — Android clients do not retry it. + private val pendingBroadcastSubscriptions = ConcurrentHashMap() // RSSI tracking from scan results (for devices we discover but may connect as servers) private val scanRSSI = ConcurrentHashMap() private val connectionStateLock = Any() @@ -85,6 +88,7 @@ class BluetoothConnectionTracker( removePendingConnection(deviceAddress) // Mark as awaiting first ANNOUNCE on this connection firstAnnounceSeen[deviceAddress] = false + pendingBroadcastSubscriptions.remove(deviceAddress) } /** @@ -276,6 +280,7 @@ class BluetoothConnectionTracker( subscribedDevices.removeAll { it.address == deviceAddress } addressPeerMap.remove(deviceAddress) firstAnnounceSeen.remove(deviceAddress) + pendingBroadcastSubscriptions.remove(deviceAddress) } Log.d(TAG, "Cleaned up device connection for $deviceAddress") } @@ -292,6 +297,7 @@ class BluetoothConnectionTracker( subscribedDevices.removeAll { it.address == deviceAddress } addressPeerMap.remove(deviceAddress) firstAnnounceSeen.remove(deviceAddress) + pendingBroadcastSubscriptions.remove(deviceAddress) Log.d(TAG, "Cleaned up device connection for $deviceAddress") true } else { @@ -330,13 +336,47 @@ class BluetoothConnectionTracker( pendingConnections.clear() scanRSSI.clear() firstAnnounceSeen.clear() + pendingBroadcastSubscriptions.clear() } /** - * Mark that we have received the first ANNOUNCE over this device connection. + * Record a CCCD enable. Grants the broadcast feed immediately when a verified + * ANNOUNCE already arrived on this connection; otherwise defers until it does. + * + * @return true if the feed was granted now + */ + fun requestBroadcastSubscription(device: BluetoothDevice): Boolean { + synchronized(connectionStateLock) { + val action = GattNotificationEligibility.action( + firstAnnounceSeen[device.address] == true + ) + if (action == GattSubscriptionAction.GRANT) { + pendingBroadcastSubscriptions.remove(device.address) + addSubscribedDeviceIfAbsent(device) + return true + } + pendingBroadcastSubscriptions[device.address] = device + return false + } + } + + /** + * Mark that we have received the first ANNOUNCE over this device connection + * and grant any CCCD write that was waiting on it. */ fun noteAnnounceReceived(deviceAddress: String) { - firstAnnounceSeen[deviceAddress] = true + synchronized(connectionStateLock) { + firstAnnounceSeen[deviceAddress] = true + pendingBroadcastSubscriptions.remove(deviceAddress)?.let { device -> + addSubscribedDeviceIfAbsent(device) + } + } + } + + private fun addSubscribedDeviceIfAbsent(device: BluetoothDevice) { + if (subscribedDevices.none { it.address == device.address }) { + subscribedDevices.add(device) + } } /** diff --git a/app/src/main/java/com/bitchat/android/mesh/BluetoothGattServerManager.kt b/app/src/main/java/com/bitchat/android/mesh/BluetoothGattServerManager.kt index ab021b43..7d69a257 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothGattServerManager.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothGattServerManager.kt @@ -283,28 +283,14 @@ class BluetoothGattServerManager( } if (BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE.contentEquals(value)) { - if (!GattNotificationEligibility.maySubscribe( - connectionTracker.hasSeenFirstAnnounce(device.address) - ) - ) { - Log.w( + val granted = connectionTracker.requestBroadcastSubscription(device) + if (!granted) { + Log.i( TAG, - "Rejecting broadcast subscription from ${device.address} before verified ANNOUNCE" + "Deferring broadcast feed for ${device.address} until verified ANNOUNCE" ) - if (responseNeeded) { - gattServer?.sendResponse( - device, - requestId, - BluetoothGatt.GATT_INSUFFICIENT_AUTHORIZATION, - 0, - null - ) - } - return } - connectionTracker.addSubscribedDevice(device) - connectionScope.launch { delay(100) if (isActive) { // Check if still active diff --git a/app/src/main/java/com/bitchat/android/mesh/GattNotificationEligibility.kt b/app/src/main/java/com/bitchat/android/mesh/GattNotificationEligibility.kt index 768a0e4f..24327d4a 100644 --- a/app/src/main/java/com/bitchat/android/mesh/GattNotificationEligibility.kt +++ b/app/src/main/java/com/bitchat/android/mesh/GattNotificationEligibility.kt @@ -1,11 +1,21 @@ package com.bitchat.android.mesh /** - * Whether a BLE central may subscribe to the mesh broadcast notification feed. + * Whether a BLE central may receive the mesh broadcast notification feed. * - * Granting notifications before the peer has sent a verified ANNOUNCE would let - * a passive listener harvest the full mesh feed with no participation (#901). + * Granting the feed before the peer has sent a verified ANNOUNCE would let a + * passive listener harvest it with no participation (#901). Android clients + * write the CCCD during service discovery, before they send that ANNOUNCE, and + * they do not retry the descriptor write — so a GATT reject here would leave a + * legitimate peer unsubscribed even after they announce. Defer instead: accept + * the CCCD write, withhold packets, then grant once the ANNOUNCE verifies. */ -internal object GattNotificationEligibility { - fun maySubscribe(hasVerifiedAnnounce: Boolean): Boolean = hasVerifiedAnnounce +internal enum class GattSubscriptionAction { + GRANT, + DEFER, +} + +internal object GattNotificationEligibility { + fun action(hasVerifiedAnnounce: Boolean): GattSubscriptionAction = + if (hasVerifiedAnnounce) GattSubscriptionAction.GRANT else GattSubscriptionAction.DEFER } diff --git a/app/src/test/kotlin/com/bitchat/android/mesh/BluetoothConnectionTrackerLinkObservationTest.kt b/app/src/test/kotlin/com/bitchat/android/mesh/BluetoothConnectionTrackerLinkObservationTest.kt index bde80cdc..928262a2 100644 --- a/app/src/test/kotlin/com/bitchat/android/mesh/BluetoothConnectionTrackerLinkObservationTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/mesh/BluetoothConnectionTrackerLinkObservationTest.kt @@ -79,6 +79,58 @@ class BluetoothConnectionTrackerLinkObservationTest { assertTrue(tracker.addressPeerMap.containsValue(PEER_ID)) } + @Test + fun `cccd before announce withholds the feed then grants it`() { + val address = "AA:BB:CC:DD:EE:FF" + val device = mock() + whenever(device.address).thenReturn(address) + tracker.addDeviceConnection( + address, + BluetoothConnectionTracker.DeviceConnection(device = device, linkID = "link-a") + ) + + assertFalse(tracker.requestBroadcastSubscription(device)) + assertTrue(tracker.getSubscribedDevices().none { it.address == address }) + + tracker.noteAnnounceReceived(address) + assertTrue(tracker.getSubscribedDevices().any { it.address == address }) + } + + @Test + fun `announce before cccd grants the feed on the descriptor write`() { + val address = "AA:BB:CC:DD:EE:00" + val device = mock() + whenever(device.address).thenReturn(address) + tracker.addDeviceConnection( + address, + BluetoothConnectionTracker.DeviceConnection(device = device, linkID = "link-b") + ) + + tracker.noteAnnounceReceived(address) + assertTrue(tracker.requestBroadcastSubscription(device)) + assertTrue(tracker.getSubscribedDevices().any { it.address == address }) + } + + @Test + fun `disconnect drops a deferred broadcast subscription`() { + val address = "AA:BB:CC:DD:EE:01" + val device = mock() + whenever(device.address).thenReturn(address) + tracker.addDeviceConnection( + address, + BluetoothConnectionTracker.DeviceConnection(device = device, linkID = "link-c") + ) + tracker.requestBroadcastSubscription(device) + tracker.cleanupDeviceConnection(address) + + tracker.addDeviceConnection( + address, + BluetoothConnectionTracker.DeviceConnection(device = device, linkID = "link-d") + ) + tracker.noteAnnounceReceived(address) + assertTrue(tracker.getSubscribedDevices().none { it.address == address }) + } + private companion object { const val PEER_ID = "0011223344556677" } diff --git a/app/src/test/kotlin/com/bitchat/android/mesh/GattNotificationEligibilityTest.kt b/app/src/test/kotlin/com/bitchat/android/mesh/GattNotificationEligibilityTest.kt index d295c3fd..61c05054 100644 --- a/app/src/test/kotlin/com/bitchat/android/mesh/GattNotificationEligibilityTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/mesh/GattNotificationEligibilityTest.kt @@ -1,17 +1,22 @@ package com.bitchat.android.mesh -import org.junit.Assert.assertFalse -import org.junit.Assert.assertTrue +import org.junit.Assert.assertEquals import org.junit.Test class GattNotificationEligibilityTest { @Test - fun `subscription denied before a verified announce`() { - assertFalse(GattNotificationEligibility.maySubscribe(hasVerifiedAnnounce = false)) + fun `subscription is deferred before a verified announce`() { + assertEquals( + GattSubscriptionAction.DEFER, + GattNotificationEligibility.action(hasVerifiedAnnounce = false) + ) } @Test - fun `subscription allowed after a verified announce`() { - assertTrue(GattNotificationEligibility.maySubscribe(hasVerifiedAnnounce = true)) + fun `subscription is granted after a verified announce`() { + assertEquals( + GattSubscriptionAction.GRANT, + GattNotificationEligibility.action(hasVerifiedAnnounce = true) + ) } }