Skip to content
31 changes: 29 additions & 2 deletions src/ble_reticulum/BLEInterface.py
Original file line number Diff line number Diff line change
Expand Up @@ -1134,6 +1134,20 @@ def _handle_identity_handshake(self, address: str, data: bytes) -> bool:
central_identity = bytes(data)
identity_hash = self._compute_identity_hash(central_identity)

# Check for duplicate identity (same identity already connected at different MAC)
# This prevents duplicate connections during MAC rotation overlap
if self._check_duplicate_identity(address, central_identity):
RNS.log(
f"{self} duplicate identity rejected for {address} in peripheral mode (MAC rotation)",
RNS.LOG_WARNING
)
# Disconnect this connection - it's a duplicate
try:
self.driver.disconnect(address)
except Exception as e:
RNS.log(f"{self} failed to disconnect duplicate {address}: {e}", RNS.LOG_DEBUG)
return True # Consumed the handshake, rejected connection

self.address_to_identity[address] = central_identity
self.identity_to_address[identity_hash] = address

Expand Down Expand Up @@ -1779,11 +1793,18 @@ def _compute_identity_hash(self, peer_identity):
"""
Convert 16-byte identity to 16-character hex string for interface tracking.

Uses truncated 64-bit hash for map keys (spawned_interfaces, identity_to_address).
Collision risk: birthday problem collision at ~2^32 (~4 billion) identities.
For BLE mesh networks with <100 simultaneous peers, this is astronomically safe.

Note: Fragmenter keys use full 32-char hex via _get_fragmenter_key() for
maximum precision in packet reassembly.

Args:
peer_identity: 16-byte peer identity (already a hash from BLE handshake)

Returns:
str: First 16 hex chars of identity (8 bytes)
str: First 16 hex chars of identity (8 bytes = 64 bits)
"""
# peer_identity is already the identity hash from BLE handshake
# Just convert to hex, don't re-hash (that would corrupt the identity!)
Expand Down Expand Up @@ -1872,6 +1893,12 @@ def _handle_ble_data(self, peer_address, data):
if not peer_identity:
# Try identity cache - peer may have "disconnected" from Python's view
# but Android/driver layer maintains the GATT connection
#
# POTENTIAL RACE CONDITION: If MAC rotation occurred and data arrives from
# the OLD address before onAddressChanged callback fires, we could restore
# a stale mapping here. This is a very narrow window since onAddressChanged
# is invoked synchronously from Kotlin during deduplication. The cache entry
# for the old address gets cleaned up in _address_changed_callback().
cached = self._identity_cache.get(peer_address)
if cached and (time.time() - cached[1]) < self._identity_cache_ttl:
peer_identity = cached[0]
Expand Down Expand Up @@ -1995,7 +2022,7 @@ def handle_peripheral_data(self, data, sender_address):
try:
# Store central's identity
central_identity = bytes(data)
central_identity_hash = RNS.Identity.full_hash(central_identity)[:16].hex()[:16]
central_identity_hash = self._compute_identity_hash(central_identity)

self.address_to_identity[sender_address] = central_identity
self.identity_to_address[central_identity_hash] = sender_address
Expand Down
17 changes: 15 additions & 2 deletions src/ble_reticulum/linux_bluetooth_driver.py
Original file line number Diff line number Diff line change
Expand Up @@ -1204,7 +1204,14 @@ def disconnected_callback(client_obj):
if self.on_error:
self.on_error("warning", f"Connection timeout to {address}", None)
except Exception as e:
self._log(f"Connection failed to {address}: {e}", "ERROR")
error_str = str(e)
is_duplicate_identity = "Duplicate identity" in error_str

# Log with appropriate level
if is_duplicate_identity:
self._log(f"Duplicate identity rejected for {address}: {e}", "WARNING")
else:
self._log(f"Connection failed to {address}: {e}", "ERROR")

# Clean up BlueZ state by explicitly disconnecting client
try:
Expand All @@ -1224,7 +1231,13 @@ def disconnected_callback(client_obj):
self._log(f"Error removing BlueZ device {address} after failure: {removal_e}", "DEBUG")

if self.on_error:
self.on_error("error", f"Connection failed to {address}: {e}", e)
if is_duplicate_identity:
# Use safe message format that doesn't trigger blacklist
# The blacklist regex matches "Connection failed to" and "Connection timeout to"
# Using "Duplicate identity rejected for" avoids the blacklist
self.on_error("info", f"Duplicate identity rejected for {address} (MAC rotation)", e)
else:
self.on_error("error", f"Connection failed to {address}: {e}", e)
finally:
# Backup cleanup (primary cleanup is via Future callback in connect())
# This provides defense-in-depth in case the callback doesn't execute
Expand Down
16 changes: 11 additions & 5 deletions tests/mock_ble_driver.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ def __init__(self, local_address: str = "11:22:33:44:55:66"):

# Callbacks (assigned by consumer)
self.on_device_discovered: Optional[Callable[[BLEDevice], None]] = None
self.on_device_connected: Optional[Callable[[str], None]] = None
self.on_device_connected: Optional[Callable[[str, Optional[bytes]], None]] = None # address, peer_identity
self.on_device_disconnected: Optional[Callable[[str], None]] = None
self.on_data_received: Optional[Callable[[str, bytes], None]] = None
self.on_mtu_negotiated: Optional[Callable[[str, int], None]] = None
Expand Down Expand Up @@ -160,16 +160,21 @@ def connect(self, address: str):
if address in self._connected_peers:
return # Already connected

# Get peer identity if linked driver is set
peer_identity = None
if self._linked_driver and self._linked_driver.local_address == address:
peer_identity = self._linked_driver._identity

# Simulate connection with default MTU
self._connected_peers[address] = {
"role": "central",
"mtu": 185, # Default MTU
"identity": None
"identity": peer_identity
}

# Trigger callback
# Trigger callback with peer identity (central mode receives identity during connection)
if self.on_device_connected:
self.on_device_connected(address)
self.on_device_connected(address, peer_identity)

# Trigger MTU negotiation callback
if self.on_mtu_negotiated:
Expand All @@ -193,8 +198,9 @@ def _accept_connection(self, address: str):
"identity": None
}

# Peripheral role: identity is None because we receive it via handshake later
if self.on_device_connected:
self.on_device_connected(address)
self.on_device_connected(address, None)

if self.on_mtu_negotiated:
self.on_mtu_negotiated(address, 185)
Expand Down
3 changes: 2 additions & 1 deletion tests/test_ble_peer_interface.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,8 @@ def create_mock_peer_interface(peer_address="AA:BB:CC:DD:EE:FF", peer_name="Test
parent.reassemblers = {peer_address: BLEReassembler() if BLEReassembler else Mock()}
parent.frag_lock = threading.RLock() # Use threading lock for mock
parent.peer_lock = threading.RLock() # Use threading lock for mock
parent.loop = asyncio.get_event_loop()
# Create a new event loop for testing (Python 3.10+ requires this)
parent.loop = asyncio.new_event_loop()
parent.gatt_server = Mock()
parent.gatt_server.send_notification = AsyncMock(return_value=True)

Expand Down
Loading