mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-08-15 06:56:30 +00:00
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:
parent
624776667a
commit
ffb89e9098
@ -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)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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) }}")
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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))
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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()
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user