Authenticate BLE links before peer binding (#749)

* Authenticate BLE links before peer binding

* Fix BLE link authentication races

---------

Co-authored-by: callebtc <93376500+callebtc@users.noreply.github.com>
This commit is contained in:
a1denvalu3 2026-07-26 14:14:35 +02:00 committed by GitHub
parent 624776667a
commit ffb89e9098
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 458 additions and 56 deletions

View File

@ -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)
}
/**

View File

@ -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
}

View File

@ -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)
}

View File

@ -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<String, Boolean>()
// RSSI tracking from scan results (for devices we discover but may connect as servers)
private val scanRSSI = ConcurrentHashMap<String, Int>()
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

View File

@ -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")

View File

@ -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<String, String>()
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) }}")

View File

@ -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<String, AuthenticatedBleLinkPolicy.Claim>()
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

View File

@ -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,

View File

@ -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

View File

@ -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 {

View File

@ -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))
}
}

View File

@ -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<BluetoothDevice>()
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)
}
}

View File

@ -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()