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 fb0af113..b15a6254 100644 --- a/app/src/main/java/com/bitchat/android/crypto/EncryptionService.kt +++ b/app/src/main/java/com/bitchat/android/crypto/EncryptionService.kt @@ -300,9 +300,9 @@ open class EncryptionService(private val context: Context) { /** * Initiate a Noise handshake with a peer */ - fun initiateHandshake(peerID: String): ByteArray? { + fun initiateHandshake(peerID: String, replaceEstablished: Boolean = false): ByteArray? { Log.d(TAG, "🤝 Initiating Noise handshake with $peerID") - return noiseService.initiateHandshake(peerID) + return noiseService.initiateHandshake(peerID, replaceEstablished) } /** diff --git a/app/src/main/java/com/bitchat/android/mesh/AuthenticatedBleLinkPolicy.kt b/app/src/main/java/com/bitchat/android/mesh/AuthenticatedBleLinkPolicy.kt new file mode 100644 index 00000000..f2aa1db2 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/mesh/AuthenticatedBleLinkPolicy.kt @@ -0,0 +1,14 @@ +package com.bitchat.android.mesh + +/** + * Ensures a Noise completion promotes only the BLE connection whose ANNOUNCE started that + * authentication attempt. + */ +internal object AuthenticatedBleLinkPolicy { + data class Claim(val deviceAddress: String, val linkID: String) + + fun matches(claim: Claim?, authenticatedAddress: String?, authenticatedLinkID: String?): Boolean = + claim != null && + claim.deviceAddress == authenticatedAddress && + claim.linkID == authenticatedLinkID +} 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 9bf41c68..58923847 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionManager.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionManager.kt @@ -42,7 +42,12 @@ class BluetoothConnectionManager( // Delegate for component managers to call back to main manager private val componentDelegate = object : BluetoothConnectionManagerDelegate { - override fun onPacketReceived(packet: BitchatPacket, peerID: String, device: BluetoothDevice?) { + override fun onPacketReceived( + packet: BitchatPacket, + peerID: String, + device: BluetoothDevice?, + ingressLinkID: String + ) { Log.d(TAG, "onPacketReceived: Packet received from ${device?.address} ($peerID)") device?.let { bluetoothDevice -> // Get current RSSI for this device and update if available @@ -54,7 +59,7 @@ class BluetoothConnectionManager( if (peerID == myPeerID) return // Ignore messages from self - delegate?.onPacketReceived(packet, peerID, device) + delegate?.onPacketReceived(packet, peerID, device, ingressLinkID) } override fun onDeviceConnected(device: BluetoothDevice) { @@ -63,8 +68,8 @@ class BluetoothConnectionManager( delegate?.onDeviceConnected(device) } - override fun onDeviceDisconnected(device: BluetoothDevice) { - delegate?.onDeviceDisconnected(device) + override fun onDeviceDisconnected(device: BluetoothDevice, linkID: String?) { + delegate?.onDeviceDisconnected(device, linkID) } override fun onRSSIUpdated(deviceAddress: String, rssi: Int) { @@ -88,6 +93,12 @@ class BluetoothConnectionManager( // Public property for address-peer mapping val addressPeerMap get() = connectionTracker.addressPeerMap + fun bindPeerIfCurrent(deviceAddress: String, linkID: String, peerID: String): Boolean = + connectionTracker.bindPeerIfCurrent(deviceAddress, linkID, peerID) + + fun getCurrentLinkID(deviceAddress: String): String? = + connectionTracker.getCurrentLinkID(deviceAddress) + private fun isBleTransportEnabled(): Boolean { return try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().bleEnabled.value @@ -359,6 +370,17 @@ class BluetoothConnectionManager( serverManager.getCharacteristic() ) } + + fun sendPacketToLink(deviceAddress: String, linkID: String, packet: BitchatPacket): Boolean { + if (!isActive || !isBleTransportEnabled()) return false + return packetBroadcaster.sendPacketToLink( + RoutedPacket(packet), + deviceAddress, + linkID, + serverManager.getGattServer(), + serverManager.getCharacteristic() + ) + } // Expose role controls for debug UI @@ -501,8 +523,13 @@ class BluetoothConnectionManager( * Delegate interface for Bluetooth connection manager callbacks */ interface BluetoothConnectionManagerDelegate { - fun onPacketReceived(packet: BitchatPacket, peerID: String, device: BluetoothDevice?) + fun onPacketReceived( + packet: BitchatPacket, + peerID: String, + device: BluetoothDevice?, + ingressLinkID: String + ) fun onDeviceConnected(device: BluetoothDevice) - fun onDeviceDisconnected(device: BluetoothDevice) + fun onDeviceDisconnected(device: BluetoothDevice, linkID: String?) fun onRSSIUpdated(deviceAddress: String, rssi: Int) } 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 f139b9d3..f37d8574 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionTracker.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionTracker.kt @@ -9,6 +9,7 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.launch import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.CopyOnWriteArrayList +import java.util.UUID /** * Tracks all Bluetooth connections and handles cleanup @@ -31,6 +32,7 @@ class BluetoothConnectionTracker( private val firstAnnounceSeen = ConcurrentHashMap() // RSSI tracking from scan results (for devices we discover but may connect as servers) private val scanRSSI = ConcurrentHashMap() + private val peerBindingLock = Any() /** * Consolidated device connection information @@ -42,7 +44,9 @@ class BluetoothConnectionTracker( val rssi: Int = Int.MIN_VALUE, val isClient: Boolean = false, val connectedAt: Long = System.currentTimeMillis(), - val peerID: String? = null + val peerID: String? = null, + /** Unique to this GATT connection, even when Android reuses the device address. */ + val linkID: String = UUID.randomUUID().toString() ) override fun start() { @@ -73,7 +77,11 @@ class BluetoothConnectionTracker( */ fun addDeviceConnection(deviceAddress: String, deviceConn: DeviceConnection) { Log.d(TAG, "Tracker: Adding device connection for $deviceAddress (isClient: ${deviceConn.isClient}") - connectedDevices[deviceAddress] = deviceConn + synchronized(peerBindingLock) { + connectedDevices[deviceAddress] = deviceConn + // A mapping authenticates a GATT connection, not a reusable Bluetooth address. + addressPeerMap.remove(deviceAddress) + } removePendingConnection(deviceAddress) // Mark as awaiting first ANNOUNCE on this connection firstAnnounceSeen[deviceAddress] = false @@ -83,7 +91,20 @@ class BluetoothConnectionTracker( * Update a device connection */ fun updateDeviceConnection(deviceAddress: String, deviceConn: DeviceConnection) { - connectedDevices[deviceAddress] = deviceConn + synchronized(peerBindingLock) { + connectedDevices[deviceAddress] = deviceConn + } + } + + fun updateDeviceConnectionIfCurrent( + deviceAddress: String, + linkID: String, + update: (DeviceConnection) -> DeviceConnection + ): Boolean = synchronized(peerBindingLock) { + val current = connectedDevices[deviceAddress] ?: return@synchronized false + if (current.linkID != linkID) return@synchronized false + connectedDevices[deviceAddress] = update(current) + true } /** @@ -92,6 +113,17 @@ class BluetoothConnectionTracker( fun getDeviceConnection(deviceAddress: String): DeviceConnection? { return connectedDevices[deviceAddress] } + + fun getCurrentLinkID(deviceAddress: String): String? = + connectedDevices[deviceAddress]?.linkID + + fun bindPeerIfCurrent(deviceAddress: String, linkID: String, peerID: String): Boolean = + synchronized(peerBindingLock) { + if (connectedDevices[deviceAddress]?.linkID != linkID) return@synchronized false + addressPeerMap.entries.removeIf { it.value == peerID && it.key != deviceAddress } + addressPeerMap[deviceAddress] = peerID + true + } /** * Get all connected devices @@ -233,13 +265,33 @@ class BluetoothConnectionTracker( * Clean up a specific device connection */ fun cleanupDeviceConnection(deviceAddress: String) { - connectedDevices.remove(deviceAddress)?.let { deviceConn -> + synchronized(peerBindingLock) { + connectedDevices.remove(deviceAddress) subscribedDevices.removeAll { it.address == deviceAddress } addressPeerMap.remove(deviceAddress) + firstAnnounceSeen.remove(deviceAddress) } - firstAnnounceSeen.remove(deviceAddress) Log.d(TAG, "Cleaned up device connection for $deviceAddress") } + + fun cleanupDeviceConnectionIfCurrent( + deviceAddress: String, + expectedLinkID: String + ): Boolean = synchronized(peerBindingLock) { + val current = connectedDevices[deviceAddress] ?: return@synchronized false + if (current.linkID != expectedLinkID) { + return@synchronized false + } + if (connectedDevices.remove(deviceAddress, current)) { + subscribedDevices.removeAll { it.address == deviceAddress } + addressPeerMap.remove(deviceAddress) + firstAnnounceSeen.remove(deviceAddress) + Log.d(TAG, "Cleaned up device connection for $deviceAddress") + true + } else { + false + } + } /** * Clean up all connections diff --git a/app/src/main/java/com/bitchat/android/mesh/BluetoothGattClientManager.kt b/app/src/main/java/com/bitchat/android/mesh/BluetoothGattClientManager.kt index 3885a523..96a351ec 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothGattClientManager.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothGattClientManager.kt @@ -532,6 +532,7 @@ class BluetoothGattClientManager( if (!permissionManager.hasBluetoothPermissions()) return val deviceAddress = device.address + val linkID = UUID.randomUUID().toString() Log.i(TAG, "Connecting to bitchat device: $deviceAddress (peerID: $peerID)") val gattCallback = object : BluetoothGattCallback() { @@ -553,11 +554,11 @@ class BluetoothGattClientManager( } } else { Log.d(TAG, "Client: Cleanly disconnected from $deviceAddress") - connectionTracker.cleanupDeviceConnection(deviceAddress) } + connectionTracker.cleanupDeviceConnectionIfCurrent(deviceAddress, linkID) // Notify higher layers about device disconnection to update direct flags - delegate?.onDeviceDisconnected(gatt.device) + delegate?.onDeviceDisconnected(gatt.device, linkID) connectionScope.launch { delay(500) // CLEANUP_DELAY @@ -583,7 +584,8 @@ class BluetoothGattClientManager( gatt = gatt, rssi = rssi, isClient = true, - peerID = peerID // Store the peerID discovered during scan + peerID = peerID, // Store the peerID discovered during scan + linkID = linkID ) connectionTracker.addDeviceConnection(deviceAddress, deviceConn) @@ -602,9 +604,11 @@ class BluetoothGattClientManager( if (service != null) { val characteristic = service.getCharacteristic(AppConstants.Mesh.Gatt.CHARACTERISTIC_UUID) if (characteristic != null) { - connectionTracker.getDeviceConnection(deviceAddress)?.let { deviceConn -> - val updatedConn = deviceConn.copy(characteristic = characteristic) - connectionTracker.updateDeviceConnection(deviceAddress, updatedConn) + if (connectionTracker.updateDeviceConnectionIfCurrent( + deviceAddress, + linkID + ) { it.copy(characteristic = characteristic) } + ) { Log.d(TAG, "Client: Updated device connection with characteristic for $deviceAddress") } @@ -644,7 +648,7 @@ class BluetoothGattClientManager( if (packet != null) { val peerID = packet.senderID.take(8).toByteArray().joinToString("") { "%02x".format(it) } Log.d(TAG, "Client: Parsed packet type ${packet.type} from $peerID") - delegate?.onPacketReceived(packet, peerID, gatt.device) + delegate?.onPacketReceived(packet, peerID, gatt.device, linkID) } else { Log.w(TAG, "Client: Failed to parse packet from ${gatt.device.address}, size: ${value.size} bytes") Log.w(TAG, "Client: Packet data: ${value.joinToString(" ") { "%02x".format(it) }}") @@ -657,9 +661,8 @@ class BluetoothGattClientManager( Log.d(TAG, "Client: RSSI updated for $deviceAddress: $rssi dBm") // Update the connection tracker with new RSSI value - connectionTracker.getDeviceConnection(deviceAddress)?.let { deviceConn -> - val updatedConn = deviceConn.copy(rssi = rssi) - connectionTracker.updateDeviceConnection(deviceAddress, updatedConn) + connectionTracker.updateDeviceConnectionIfCurrent(deviceAddress, linkID) { + it.copy(rssi = rssi) } } else { Log.w(TAG, "Client: Failed to read RSSI for $deviceAddress, status: $status") 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 0c7aabc3..d9ae8b17 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothGattServerManager.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothGattServerManager.kt @@ -14,6 +14,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.launch import java.util.* +import java.util.concurrent.ConcurrentHashMap /** * Manages GATT server operations, advertising, and server-side connections @@ -43,6 +44,7 @@ class BluetoothGattServerManager( // GATT server for peripheral mode private var gattServer: BluetoothGattServer? = null + private val serverLinkIDs = ConcurrentHashMap() private var characteristic: BluetoothGattCharacteristic? = null private var advertiseCallback: AdvertiseCallback? = null private var advertiseRetryCount = 0 @@ -124,6 +126,7 @@ class BluetoothGattServerManager( // Ensure server is closed if present gattServer?.close() gattServer = null + serverLinkIDs.clear() Log.i(TAG, "GATT server stopped (already inactive)") return } @@ -145,6 +148,7 @@ class BluetoothGattServerManager( // Close GATT server gattServer?.close() gattServer = null + serverLinkIDs.clear() Log.i(TAG, "GATT server stopped") } @@ -178,6 +182,8 @@ class BluetoothGattServerManager( when (newState) { BluetoothProfile.STATE_CONNECTED -> { Log.i(TAG, "Server: Device connected ${device.address}") + val linkID = UUID.randomUUID().toString() + serverLinkIDs[device.address] = linkID // Get best available RSSI (scan RSSI for server connections) val rssi = connectionTracker.getBestRSSI(device.address) ?: Int.MIN_VALUE @@ -185,7 +191,8 @@ class BluetoothGattServerManager( val deviceConn = BluetoothConnectionTracker.DeviceConnection( device = device, rssi = rssi, - isClient = false + isClient = false, + linkID = linkID ) connectionTracker.addDeviceConnection(device.address, deviceConn) @@ -198,9 +205,12 @@ class BluetoothGattServerManager( } BluetoothProfile.STATE_DISCONNECTED -> { Log.i(TAG, "Server: Device disconnected ${device.address}") - connectionTracker.cleanupDeviceConnection(device.address) + val linkID = serverLinkIDs.remove(device.address) + if (linkID != null) { + connectionTracker.cleanupDeviceConnectionIfCurrent(device.address, linkID) + } // Notify delegate about device disconnection so higher layers can update direct flags - delegate?.onDeviceDisconnected(device) + delegate?.onDeviceDisconnected(device, linkID) } } } @@ -236,11 +246,25 @@ class BluetoothGattServerManager( if (characteristic.uuid == AppConstants.Mesh.Gatt.CHARACTERISTIC_UUID) { Log.i(TAG, "Server: Received packet from ${device.address}, size: ${value.size} bytes") + val linkID = serverLinkIDs[device.address] + if (linkID == null) { + Log.w(TAG, "Server: Dropping packet from stale connection ${device.address}") + if (responseNeeded) { + gattServer?.sendResponse( + device, + requestId, + BluetoothGatt.GATT_FAILURE, + 0, + null + ) + } + return + } val packet = BitchatPacket.fromBinaryData(value) if (packet != null) { val peerID = packet.senderID.take(8).toByteArray().joinToString("") { "%02x".format(it) } Log.d(TAG, "Server: Parsed packet type ${packet.type} from $peerID") - delegate?.onPacketReceived(packet, peerID, device) + delegate?.onPacketReceived(packet, peerID, device, linkID) } else { Log.w(TAG, "Server: Failed to parse packet from ${device.address}, size: ${value.size} bytes") Log.w(TAG, "Server: Packet data: ${value.joinToString(" ") { "%02x".format(it) }}") 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 08ca4390..462c2725 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt @@ -21,6 +21,7 @@ import com.bitchat.android.services.VerificationService import com.bitchat.android.service.TransportBridgeService import kotlinx.coroutines.* import java.util.* +import java.util.concurrent.ConcurrentHashMap import kotlin.math.sign import kotlin.random.Random @@ -42,6 +43,7 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic companion object { private const val TAG = "BluetoothMeshService" + private const val BLE_AUTHENTICATION_TIMEOUT_MS = 20_000L private val MAX_TTL: UByte = com.bitchat.android.util.AppConstants.MESSAGE_TTL_HOPS } @@ -127,6 +129,8 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic private var announceJob: Job? = null // Tracks whether this instance has been terminated via stopServices() private var terminated = false + private val provisionalBleClaims = + ConcurrentHashMap() init { Log.i(TAG, "Initializing BluetoothMeshService for peer=$myPeerID") @@ -256,6 +260,7 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic delegate?.didUpdatePeerList(peerIDs) } override fun onPeerRemoved(peerID: String) { + provisionalBleClaims.remove(peerID) authenticatedPeerState.clear(peerID) try { gossipSyncManager.removeAnnouncementForPeer(peerID) } catch (_: Exception) { } // Remove from mesh graph topology to prevent routing through stale peers @@ -285,6 +290,22 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic authenticatedRemoteStaticKey, authenticatedSessionToken ) + val expectedClaim = provisionalBleClaims.remove(peerID) + if (AuthenticatedBleLinkPolicy.matches(expectedClaim, directRelayAddress, ingressLinkID)) { + val authenticatedClaim = checkNotNull(expectedClaim) + if (connectionManager.bindPeerIfCurrent( + authenticatedClaim.deviceAddress, + authenticatedClaim.linkID, + peerID + ) + ) { + Log.i(TAG, "Authenticated BLE link $directRelayAddress as $peerID") + try { peerManager.refreshPeerList() } catch (_: Exception) { } + try { gossipSyncManager.scheduleInitialSyncToPeer(peerID, 1_000) } catch (_: Exception) { } + } else { + Log.w(TAG, "Ignoring Noise completion for stale BLE link $directRelayAddress") + } + } // Send announcement and cached messages after key exchange serviceScope.launch { Log.d(TAG, "Key exchange completed with $peerID; sending follow-ups") @@ -562,17 +583,40 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic val result = messageHandler.handleAnnounceWithResult(routed) if (result !is AnnounceHandlingResult.Accepted) return false - // Map device address -> peerID only after identity binding, signature, freshness, - // and peer replacement policy have all accepted the announce. val deviceAddress = routed.relayAddress val pid = routed.peerID - if (deviceAddress != null && pid != null) { - val isDirect = routed.packet.ttl == com.bitchat.android.util.AppConstants.MESSAGE_TTL_HOPS - if (isDirect) { - connectionManager.addressPeerMap[deviceAddress] = pid - Log.d(TAG, "Mapped device $deviceAddress to peer $pid (TTL=${routed.packet.ttl})") - try { peerManager.refreshPeerList() } catch (_: Exception) { } - try { gossipSyncManager.scheduleInitialSyncToPeer(pid, 1_000) } catch (_: Exception) { } + val linkID = routed.ingressLinkID + val isDirect = routed.packet.ttl == com.bitchat.android.util.AppConstants.MESSAGE_TTL_HOPS + val alreadyAuthenticated = deviceAddress != null && + pid != null && + connectionManager.addressPeerMap[deviceAddress] == pid + if (deviceAddress != null && linkID != null && pid != null && isDirect && !alreadyAuthenticated) { + try { + val claim = AuthenticatedBleLinkPolicy.Claim(deviceAddress, linkID) + registerProvisionalBleClaim(pid, claim) + val handshakeData = encryptionService.initiateHandshake(pid, replaceEstablished = true) + if (handshakeData != null) { + val handshake = signPacketBeforeBroadcast( + BitchatPacket( + version = 1u, + type = MessageType.NOISE_HANDSHAKE.value, + senderID = hexStringToByteArray(myPeerID), + recipientID = hexStringToByteArray(pid), + timestamp = System.currentTimeMillis().toULong(), + payload = handshakeData, + ttl = MAX_TTL + ) + ) + if (!connectionManager.sendPacketToLink(deviceAddress, linkID, handshake)) { + provisionalBleClaims.remove(pid, claim) + Log.w(TAG, "Could not send Noise handshake on BLE link $deviceAddress") + } + } else { + provisionalBleClaims.remove(pid, claim) + } + } catch (e: Exception) { + provisionalBleClaims.remove(pid, AuthenticatedBleLinkPolicy.Claim(deviceAddress, linkID)) + Log.w(TAG, "Could not authenticate provisional BLE claim for $pid: ${e.message}") } } try { gossipSyncManager.onPublicPacketSeen(routed.packet) } catch (_: Exception) { } @@ -634,7 +678,12 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic // BluetoothConnectionManager delegates connectionManager.delegate = object : BluetoothConnectionManagerDelegate { - override fun onPacketReceived(packet: BitchatPacket, peerID: String, device: android.bluetooth.BluetoothDevice?) { + override fun onPacketReceived( + packet: BitchatPacket, + peerID: String, + device: android.bluetooth.BluetoothDevice?, + ingressLinkID: String + ) { // Log incoming for debug graphs (do not double-count anywhere else) try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().logIncoming( @@ -645,7 +694,9 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic myPeerID = myPeerID ) } catch (_: Exception) { } - packetProcessor.processPacket(RoutedPacket(packet, peerID, device?.address)) + packetProcessor.processPacket( + RoutedPacket(packet, peerID, device?.address, ingressLinkID = ingressLinkID) + ) } override fun onDeviceConnected(device: android.bluetooth.BluetoothDevice) { @@ -665,25 +716,20 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic } catch (_: Exception) { } } - override fun onDeviceDisconnected(device: android.bluetooth.BluetoothDevice) { + override fun onDeviceDisconnected( + device: android.bluetooth.BluetoothDevice, + linkID: String? + ) { Log.d(TAG, "Device disconnected: ${device.address}") val addr = device.address - // Remove mapping and, if that was the last direct path for the peer, clear direct flag - val peer = connectionManager.addressPeerMap[addr] - // ConnectionTracker has already removed the address mapping; be defensive either way - connectionManager.addressPeerMap.remove(addr) + clearProvisionalBleClaimsForLink(addr, linkID) // refresh peer list on disconnect. try { peerManager.refreshPeerList() } catch (_: Exception) { } - if (peer != null) { - // Verbose debug: device disconnected - try { - val nick = peerManager.getPeerNickname(peer) ?: "unknown" - com.bitchat.android.ui.debug.DebugSettingsManager.getInstance() - .logPeerDisconnection(peer, nick, addr) - } catch (_: Exception) { } - } + // ConnectionTracker already removes an authenticated mapping only when this exact + // link is still current. Do not remove by reusable address here: this may be a late + // disconnect callback from a replaced GATT connection. } override fun onRSSIUpdated(deviceAddress: String, rssi: Int) { @@ -694,6 +740,26 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic } } } + + private fun registerProvisionalBleClaim( + peerID: String, + claim: AuthenticatedBleLinkPolicy.Claim + ) { + provisionalBleClaims[peerID] = claim + serviceScope.launch { + delay(BLE_AUTHENTICATION_TIMEOUT_MS) + if (provisionalBleClaims.remove(peerID, claim)) { + Log.d(TAG, "Expired provisional BLE authentication claim for $peerID") + } + } + } + + private fun clearProvisionalBleClaimsForLink(deviceAddress: String, linkID: String?) { + if (linkID == null) return + provisionalBleClaims.entries.removeIf { (_, claim) -> + claim.deviceAddress == deviceAddress && claim.linkID == linkID + } + } /** * Start the mesh service 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 7aa987ab..3e1cf721 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothPacketBroadcaster.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothPacketBroadcaster.kt @@ -163,6 +163,28 @@ class BluetoothPacketBroadcaster( } } + fun sendPacketToLink( + routed: RoutedPacket, + deviceAddress: String, + linkID: String, + gattServer: BluetoothGattServer?, + characteristic: BluetoothGattCharacteristic? + ): Boolean = fragmentingSender.send(routed, "BLE link $deviceAddress") { packet -> + val data = packet.packet.toBinaryData( + padding = BLEPacketPaddingPolicy.shouldPadForBLE(packet.packet.type) + ) ?: return@send false + val currentLink = connectionTracker.getDeviceConnection(deviceAddress) + ?.takeIf { it.linkID == linkID } + ?: return@send false + if (currentLink.isClient) { + return@send writeToDeviceConn(currentLink, data) + } + val serverTarget = connectionTracker.getSubscribedDevices() + .firstOrNull { it.address == deviceAddress } + ?: return@send false + notifyDevice(serverTarget, data, gattServer, characteristic) + } + private fun sendSinglePacketToPeer( routed: RoutedPacket, targetPeerID: String, 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 5c3cbd7f..ecb25d45 100644 --- a/app/src/main/java/com/bitchat/android/noise/NoiseEncryptionService.kt +++ b/app/src/main/java/com/bitchat/android/noise/NoiseEncryptionService.kt @@ -188,9 +188,9 @@ class NoiseEncryptionService(private val context: Context) { * Initiate a Noise handshake with a peer * Returns the first handshake message to send */ - fun initiateHandshake(peerID: String): ByteArray? { + fun initiateHandshake(peerID: String, replaceEstablished: Boolean = false): ByteArray? { return try { - sessionManager.initiateHandshake(peerID) + sessionManager.initiateHandshake(peerID, replaceEstablished) } catch (e: Exception) { Log.e(TAG, "Failed to initiate handshake with $peerID: ${e.message}") null diff --git a/app/src/main/java/com/bitchat/android/noise/NoiseSessionManager.kt b/app/src/main/java/com/bitchat/android/noise/NoiseSessionManager.kt index ec3672db..fa577ff3 100644 --- a/app/src/main/java/com/bitchat/android/noise/NoiseSessionManager.kt +++ b/app/src/main/java/com/bitchat/android/noise/NoiseSessionManager.kt @@ -98,7 +98,7 @@ class NoiseSessionManager( * SIMPLIFIED: Initiate handshake - no tie breaker, just start */ @Synchronized - fun initiateHandshake(peerID: String): ByteArray? { + fun initiateHandshake(peerID: String, replaceEstablished: Boolean = false): ByteArray? { Log.d(TAG, "initiateHandshake($peerID)") val now = System.currentTimeMillis() @@ -106,8 +106,20 @@ class NoiseSessionManager( if (existing != null) { when { existing.isEstablished() -> { - Log.d(TAG, "Handshake already established with $peerID, skipping initiate") - return null + if (!replaceEstablished) { + Log.d(TAG, "Handshake already established with $peerID, skipping initiate") + return null + } + val candidate = createSession(peerID, isInitiator = true) + responderCandidates.remove(peerID)?.destroy() + responderCandidates[peerID] = candidate + return try { + candidate.startHandshake() + } catch (e: Exception) { + responderCandidates.remove(peerID, candidate) + candidate.destroy() + throw e + } } existing.isHandshaking() -> { if (!isHandshakeStale(existing, now)) { @@ -167,6 +179,23 @@ class NoiseSessionManager( val existingCandidate = responderCandidates[peerID] if (existingCandidate != null) { activeSession = if (message.size == HANDSHAKE_MESSAGE_1_SIZE) { + if (existingCandidate.isInitiatorRole()) { + val shouldYield = localPeerID > peerID + if (!shouldYield) { + Log.d( + TAG, + "Replacement handshake collision with $peerID; keeping initiator role" + ) + return NoiseHandshakeProcessingResult( + response = null, + establishedNow = false + ) + } + Log.d( + TAG, + "Replacement handshake collision with $peerID; yielding to responder role" + ) + } responderCandidates.remove(peerID, existingCandidate) existingCandidate.destroy() createSession(peerID, isInitiator = false).also { diff --git a/app/src/test/kotlin/com/bitchat/android/mesh/AuthenticatedBleLinkPolicyTest.kt b/app/src/test/kotlin/com/bitchat/android/mesh/AuthenticatedBleLinkPolicyTest.kt new file mode 100644 index 00000000..81ba5f7a --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/mesh/AuthenticatedBleLinkPolicyTest.kt @@ -0,0 +1,40 @@ +package com.bitchat.android.mesh + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class AuthenticatedBleLinkPolicyTest { + private val claim = AuthenticatedBleLinkPolicy.Claim( + deviceAddress = "AA:BB:CC:DD:EE:FF", + linkID = "connection-a" + ) + + @Test + fun `accepts completion from exact claimed connection`() { + assertTrue( + AuthenticatedBleLinkPolicy.matches( + claim, + authenticatedAddress = claim.deviceAddress, + authenticatedLinkID = claim.linkID + ) + ) + } + + @Test + fun `rejects replacement connection reusing device address`() { + assertFalse( + AuthenticatedBleLinkPolicy.matches( + claim, + authenticatedAddress = claim.deviceAddress, + authenticatedLinkID = "connection-b" + ) + ) + } + + @Test + fun `rejects completion on another address or without a claim`() { + assertFalse(AuthenticatedBleLinkPolicy.matches(claim, "11:22:33:44:55:66", claim.linkID)) + assertFalse(AuthenticatedBleLinkPolicy.matches(null, claim.deviceAddress, claim.linkID)) + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/mesh/BluetoothConnectionTrackerLinkIdentityTest.kt b/app/src/test/kotlin/com/bitchat/android/mesh/BluetoothConnectionTrackerLinkIdentityTest.kt new file mode 100644 index 00000000..16d2d286 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/mesh/BluetoothConnectionTrackerLinkIdentityTest.kt @@ -0,0 +1,53 @@ +package com.bitchat.android.mesh + +import android.bluetooth.BluetoothDevice +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Test +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever + +class BluetoothConnectionTrackerLinkIdentityTest { + private val scope = CoroutineScope(Dispatchers.Unconfined + SupervisorJob()) + private val tracker = BluetoothConnectionTracker(scope, mock()) + + @After + fun tearDown() { + scope.cancel() + } + + @Test + fun `stale connection callbacks cannot mutate or remove replacement link`() { + 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") + ) + tracker.addDeviceConnection( + address, + BluetoothConnectionTracker.DeviceConnection(device = device, linkID = "link-b") + ) + + assertFalse( + tracker.updateDeviceConnectionIfCurrent(address, "link-a") { + it.copy(rssi = -10) + } + ) + assertFalse(tracker.cleanupDeviceConnectionIfCurrent(address, "link-a")) + assertEquals("link-b", tracker.getCurrentLinkID(address)) + + assertTrue(tracker.bindPeerIfCurrent(address, "link-b", "0011223344556677")) + assertEquals("0011223344556677", tracker.addressPeerMap[address]) + assertSame(device, tracker.getDeviceConnection(address)?.device) + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/noise/NoiseSessionManagerIdentityBindingTest.kt b/app/src/test/kotlin/com/bitchat/android/noise/NoiseSessionManagerIdentityBindingTest.kt index adee3d33..63f5e3c1 100644 --- a/app/src/test/kotlin/com/bitchat/android/noise/NoiseSessionManagerIdentityBindingTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/noise/NoiseSessionManagerIdentityBindingTest.kt @@ -258,6 +258,78 @@ class NoiseSessionManagerIdentityBindingTest { } } + @Test + fun `fresh initiator replacement preserves active session until authentication completes`() { + val alice = identity() + val bob = identity() + val aliceManager = manager(alice) + val originalBobManager = manager(bob) + + completeHandshake(aliceManager, alice.peerID, originalBobManager, bob.peerID) + val originalSession = aliceManager.getSession(bob.peerID) + + val restartedBobManager = manager(bob) + val message1 = aliceManager.initiateHandshake(bob.peerID, replaceEstablished = true)!! + assertSame(originalSession, aliceManager.getSession(bob.peerID)) + assertTrue(aliceManager.hasEstablishedSession(bob.peerID)) + + val message2 = restartedBobManager.processHandshakeMessage(alice.peerID, message1)!! + val message3 = aliceManager.processHandshakeMessage(bob.peerID, message2)!! + assertNull(restartedBobManager.processHandshakeMessage(alice.peerID, message3)) + + assertNotSame(originalSession, aliceManager.getSession(bob.peerID)) + val plaintext = "fresh link authenticated".toByteArray() + val ciphertext = aliceManager.encrypt(plaintext, bob.peerID) + assertArrayEquals(plaintext, restartedBobManager.decrypt(ciphertext, alice.peerID)) + } + + @Test + fun `simultaneous initiator replacements use peer ID tie break and complete`() { + val alice = identity() + val bob = identity() + val aliceManager = manager(alice) + val bobManager = manager(bob) + completeHandshake(aliceManager, alice.peerID, bobManager, bob.peerID) + + val originalAliceSession = aliceManager.getSession(bob.peerID) + val originalBobSession = bobManager.getSession(alice.peerID) + val aliceMessage1 = aliceManager.initiateHandshake( + bob.peerID, + replaceEstablished = true + )!! + val bobMessage1 = bobManager.initiateHandshake( + alice.peerID, + replaceEstablished = true + )!! + + val aliceCollisionResponse = aliceManager.processHandshakeMessage( + bob.peerID, + bobMessage1 + ) + val bobCollisionResponse = bobManager.processHandshakeMessage( + alice.peerID, + aliceMessage1 + ) + + if (alice.peerID < bob.peerID) { + assertNull(aliceCollisionResponse) + val message2 = bobCollisionResponse!! + val message3 = aliceManager.processHandshakeMessage(bob.peerID, message2)!! + assertNull(bobManager.processHandshakeMessage(alice.peerID, message3)) + } else { + assertNull(bobCollisionResponse) + val message2 = aliceCollisionResponse!! + val message3 = bobManager.processHandshakeMessage(alice.peerID, message2)!! + assertNull(aliceManager.processHandshakeMessage(bob.peerID, message3)) + } + + assertNotSame(originalAliceSession, aliceManager.getSession(bob.peerID)) + assertNotSame(originalBobSession, bobManager.getSession(alice.peerID)) + val plaintext = "collision replacement transport".toByteArray() + val ciphertext = aliceManager.encrypt(plaintext, bob.peerID) + assertArrayEquals(plaintext, bobManager.decrypt(ciphertext, alice.peerID)) + } + @Test fun `peer ID derivation rejects malformed keys and non-wire claims`() { val peer = identity()