mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-07-24 23:45:19 +00:00
feat: implement GATT client identity signaling and connection deduplication
Implements a mechanism for GATT clients to signal their Peer ID to the server immediately after connection, allowing for robust deduplication of redundant connections. Includes: - New IDENTITY_CHARACTERISTIC_UUID in AppConstants - Server-side handling of identity writes and deduplication logic - Client-side active signaling of Peer ID upon service discovery - Updated BluetoothConnectionTracker to map devices to Peer IDs - Comprehensive specification document in docs/PEERID_GATT_SERVER_CLIENT_SPEC.md
This commit is contained in:
@@ -76,7 +76,7 @@ class BluetoothConnectionManager(
|
|||||||
context, connectionScope, connectionTracker, permissionManager, powerManager, componentDelegate, myPeerID
|
context, connectionScope, connectionTracker, permissionManager, powerManager, componentDelegate, myPeerID
|
||||||
)
|
)
|
||||||
private val clientManager = BluetoothGattClientManager(
|
private val clientManager = BluetoothGattClientManager(
|
||||||
context, connectionScope, connectionTracker, permissionManager, powerManager, componentDelegate
|
context, connectionScope, connectionTracker, permissionManager, powerManager, componentDelegate, myPeerID
|
||||||
)
|
)
|
||||||
|
|
||||||
// Service state
|
// Service state
|
||||||
|
|||||||
@@ -167,6 +167,19 @@ class BluetoothConnectionTracker(
|
|||||||
return connectedDevices.containsKey(deviceAddress)
|
return connectedDevices.containsKey(deviceAddress)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update device peer ID binding
|
||||||
|
*/
|
||||||
|
fun setDevicePeerID(deviceAddress: String, peerID: String) {
|
||||||
|
connectedDevices[deviceAddress]?.let { deviceConn ->
|
||||||
|
val updatedConn = deviceConn.copy(peerID = peerID)
|
||||||
|
connectedDevices[deviceAddress] = updatedConn
|
||||||
|
// Also update the address map
|
||||||
|
addressPeerMap[deviceAddress] = peerID
|
||||||
|
Log.d(TAG, "Bound device $deviceAddress to peerID $peerID")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if a peer is already connected (by PeerID)
|
* Check if a peer is already connected (by PeerID)
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -27,7 +27,8 @@ class BluetoothGattClientManager(
|
|||||||
private val connectionTracker: BluetoothConnectionTracker,
|
private val connectionTracker: BluetoothConnectionTracker,
|
||||||
private val permissionManager: BluetoothPermissionManager,
|
private val permissionManager: BluetoothPermissionManager,
|
||||||
private val powerManager: PowerManager,
|
private val powerManager: PowerManager,
|
||||||
private val delegate: BluetoothConnectionManagerDelegate?
|
private val delegate: BluetoothConnectionManagerDelegate?,
|
||||||
|
private val myPeerID: String
|
||||||
) {
|
) {
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
@@ -481,6 +482,20 @@ class BluetoothGattClientManager(
|
|||||||
Log.d(TAG, "Client: Updated device connection with characteristic for $deviceAddress")
|
Log.d(TAG, "Client: Updated device connection with characteristic for $deviceAddress")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// OPTIONAL: Signal our identity to the server (if they support it)
|
||||||
|
val identityChar = service.getCharacteristic(AppConstants.Mesh.Gatt.IDENTITY_CHARACTERISTIC_UUID)
|
||||||
|
if (identityChar != null) {
|
||||||
|
try {
|
||||||
|
val idBytes = myPeerID.chunked(2).map { it.toInt(16).toByte() }.toByteArray().take(8).toByteArray()
|
||||||
|
identityChar.value = idBytes
|
||||||
|
identityChar.writeType = BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE
|
||||||
|
gatt.writeCharacteristic(identityChar)
|
||||||
|
Log.d(TAG, "Client: Signaled identity to server $deviceAddress")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "Client: Failed to write identity: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
gatt.setCharacteristicNotification(characteristic, true)
|
gatt.setCharacteristicNotification(characteristic, true)
|
||||||
val descriptor = characteristic.getDescriptor(AppConstants.Mesh.Gatt.DESCRIPTOR_UUID)
|
val descriptor = characteristic.getDescriptor(AppConstants.Mesh.Gatt.DESCRIPTOR_UUID)
|
||||||
if (descriptor != null) {
|
if (descriptor != null) {
|
||||||
|
|||||||
@@ -231,6 +231,42 @@ class BluetoothGattServerManager(
|
|||||||
Log.w(TAG, "Server: Packet data: ${value.joinToString(" ") { "%02x".format(it) }}")
|
Log.w(TAG, "Server: Packet data: ${value.joinToString(" ") { "%02x".format(it) }}")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (responseNeeded) {
|
||||||
|
gattServer?.sendResponse(device, requestId, BluetoothGatt.GATT_SUCCESS, 0, null)
|
||||||
|
}
|
||||||
|
} else if (characteristic.uuid == AppConstants.Mesh.Gatt.IDENTITY_CHARACTERISTIC_UUID) {
|
||||||
|
val peerID = value.joinToString("") { "%02x".format(it) }
|
||||||
|
Log.i(TAG, "Server: Received Identity signal from ${device.address}: $peerID")
|
||||||
|
|
||||||
|
if (value.size >= 8) {
|
||||||
|
// 1. Update tracker with the declared identity
|
||||||
|
connectionTracker.setDevicePeerID(device.address, peerID)
|
||||||
|
|
||||||
|
// 2. Check for duplicates (same peerID, different MAC)
|
||||||
|
// Note: If we just set it above, we look for *others*
|
||||||
|
val duplicate = connectionTracker.getConnectedDevices().values.firstOrNull {
|
||||||
|
it.peerID == peerID && it.device.address != device.address
|
||||||
|
}
|
||||||
|
|
||||||
|
if (duplicate != null) {
|
||||||
|
Log.w(TAG, "Server: Deduplication - Peer $peerID is already connected via ${duplicate.device.address}. Rejecting new connection ${device.address}.")
|
||||||
|
|
||||||
|
// Send success response first to be polite before killing the connection?
|
||||||
|
// Or fail it?
|
||||||
|
if (responseNeeded) {
|
||||||
|
gattServer?.sendResponse(device, requestId, BluetoothGatt.GATT_SUCCESS, 0, null)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disconnect the NEW connection (this one)
|
||||||
|
disconnectDevice(device)
|
||||||
|
return
|
||||||
|
} else {
|
||||||
|
Log.d(TAG, "Server: Identity accepted for $peerID at ${device.address}")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Log.w(TAG, "Server: Invalid Identity length from ${device.address}: ${value.size}")
|
||||||
|
}
|
||||||
|
|
||||||
if (responseNeeded) {
|
if (responseNeeded) {
|
||||||
gattServer?.sendResponse(device, requestId, BluetoothGatt.GATT_SUCCESS, 0, null)
|
gattServer?.sendResponse(device, requestId, BluetoothGatt.GATT_SUCCESS, 0, null)
|
||||||
}
|
}
|
||||||
@@ -311,6 +347,15 @@ class BluetoothGattServerManager(
|
|||||||
val service = BluetoothGattService(AppConstants.Mesh.Gatt.SERVICE_UUID, BluetoothGattService.SERVICE_TYPE_PRIMARY)
|
val service = BluetoothGattService(AppConstants.Mesh.Gatt.SERVICE_UUID, BluetoothGattService.SERVICE_TYPE_PRIMARY)
|
||||||
service.addCharacteristic(characteristic)
|
service.addCharacteristic(characteristic)
|
||||||
|
|
||||||
|
// Create identity characteristic for direct client ID signaling
|
||||||
|
val identityCharacteristic = BluetoothGattCharacteristic(
|
||||||
|
AppConstants.Mesh.Gatt.IDENTITY_CHARACTERISTIC_UUID,
|
||||||
|
BluetoothGattCharacteristic.PROPERTY_WRITE or
|
||||||
|
BluetoothGattCharacteristic.PROPERTY_WRITE_NO_RESPONSE,
|
||||||
|
BluetoothGattCharacteristic.PERMISSION_WRITE
|
||||||
|
)
|
||||||
|
service.addCharacteristic(identityCharacteristic)
|
||||||
|
|
||||||
gattServer?.addService(service)
|
gattServer?.addService(service)
|
||||||
|
|
||||||
Log.i(TAG, "GATT server setup complete")
|
Log.i(TAG, "GATT server setup complete")
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ object AppConstants {
|
|||||||
object Gatt {
|
object Gatt {
|
||||||
val SERVICE_UUID: UUID = UUID.fromString("F47B5E2D-4A9E-4C5A-9B3F-8E1D2C3A4B5C")
|
val SERVICE_UUID: UUID = UUID.fromString("F47B5E2D-4A9E-4C5A-9B3F-8E1D2C3A4B5C")
|
||||||
val CHARACTERISTIC_UUID: UUID = UUID.fromString("A1B2C3D4-E5F6-4A5B-8C9D-0E1F2A3B4C5D")
|
val CHARACTERISTIC_UUID: UUID = UUID.fromString("A1B2C3D4-E5F6-4A5B-8C9D-0E1F2A3B4C5D")
|
||||||
|
val IDENTITY_CHARACTERISTIC_UUID: UUID = UUID.fromString("A1B2C3D4-E5F6-4A5B-8C9D-0E1F2A3B4C5E")
|
||||||
val DESCRIPTOR_UUID: UUID = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb")
|
val DESCRIPTOR_UUID: UUID = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,184 @@
|
|||||||
|
# Bitchat Mesh: Direct Client Identity Signaling & Connection Deduplication Spec
|
||||||
|
|
||||||
|
## 1. Overview
|
||||||
|
This specification details the mechanism for Bitchat nodes to signal their persistent **Peer Identity** over Bluetooth Low Energy (BLE) to prevent duplicate connections.
|
||||||
|
|
||||||
|
In a privacy-preserving mesh network, devices frequently rotate their MAC addresses. This creates a problem where two nodes might establish multiple redundant connections to each other (thinking they are new devices), wasting limited connection slots and battery power.
|
||||||
|
|
||||||
|
This spec defines a dual-strategy approach:
|
||||||
|
1. **Passive Signaling:** Inclusion of Peer ID in BLE Scan Responses (Server-side).
|
||||||
|
2. **Active Signaling:** A dedicated GATT "Identity Characteristic" for clients to write their ID immediately upon connection.
|
||||||
|
|
||||||
|
**Target Audience:** Android & iOS Engineering Teams.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Protocol Constants
|
||||||
|
|
||||||
|
### 2.1 UUIDs
|
||||||
|
All Bitchat BLE operations use the following 128-bit UUIDs.
|
||||||
|
|
||||||
|
| Name | UUID | Usage |
|
||||||
|
|------|------|-------|
|
||||||
|
| **Service UUID** | `F47B5E2D-4A9E-4C5A-9B3F-8E1D2C3A4B5C` | The main Bitchat Mesh Service. |
|
||||||
|
| **Data Characteristic** | `A1B2C3D4-E5F6-4A5B-8C9D-0E1F2A3B4C5D` | Main channel for `BitchatPacket` transfer (Notify/Write). |
|
||||||
|
| **Identity Characteristic** | `A1B2C3D4-E5F6-4A5B-8C9D-0E1F2A3B4C5E` | **(NEW)** For clients to signal identity (Write/WriteNr). |
|
||||||
|
|
||||||
|
### 2.2 Data Formats
|
||||||
|
* **Peer ID Truncation:** For BLE signaling, the full 64-char hex Peer ID is truncated to the **first 8 bytes** (16 hex characters).
|
||||||
|
* **Byte Order:** Big-Endian (Network Byte Order).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Passive Signaling (Advertisements)
|
||||||
|
*Already implemented on Android and iOS.*
|
||||||
|
|
||||||
|
When acting as a Peripheral (Server), the node **MUST** include its truncated Peer ID in the **Scan Response** packet.
|
||||||
|
|
||||||
|
* **Data Type:** Service Data (0x16)
|
||||||
|
* **UUID:** `F47B5E2D-4A9E-4C5A-9B3F-8E1D2C3A4B5C` (16-bit or 128-bit representation depending on space)
|
||||||
|
* **Payload:** First 8 bytes of Peer ID.
|
||||||
|
|
||||||
|
**Behavior:**
|
||||||
|
* Scanners (Clients) can map the ephemeral MAC address to this Peer ID *before* connecting.
|
||||||
|
* If the Scanner is already connected to this Peer ID (via a different MAC), it **MUST NOT** initiate a new connection.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Active Signaling (Identity Characteristic)
|
||||||
|
*New implementation requirement.*
|
||||||
|
|
||||||
|
When acting as a Central (Client), the node cannot advertise its ID to the Server if the Server is not scanning. Therefore, the Client **MUST** write its identity to the Server immediately after connection establishment.
|
||||||
|
|
||||||
|
### 4.1 Client Implementation Spec
|
||||||
|
1. **Connect** to the Peripheral.
|
||||||
|
2. **Discover Services**.
|
||||||
|
3. Check for the existence of `Identity Characteristic` (`...C5E`).
|
||||||
|
4. **Action:** If present, write the local truncated Peer ID (8 bytes) to this characteristic.
|
||||||
|
* **Write Type:** `WRITE_TYPE_NO_RESPONSE` (preferred for speed) or `WRITE_TYPE_DEFAULT`.
|
||||||
|
5. **Timing:** This MUST be the **first** action after service discovery, before enabling notifications on the Data Characteristic.
|
||||||
|
|
||||||
|
#### Android Reference (Client)
|
||||||
|
```kotlin
|
||||||
|
// In onServicesDiscovered()
|
||||||
|
val identityChar = service.getCharacteristic(IDENTITY_CHARACTERISTIC_UUID)
|
||||||
|
if (identityChar != null) {
|
||||||
|
// 1. Prepare 8-byte ID
|
||||||
|
val idBytes = myPeerID.chunked(2).map { it.toInt(16).toByte() }
|
||||||
|
.toByteArray().take(8).toByteArray()
|
||||||
|
|
||||||
|
// 2. Write to characteristic
|
||||||
|
identityChar.value = idBytes
|
||||||
|
identityChar.writeType = BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE
|
||||||
|
gatt.writeCharacteristic(identityChar)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 Server Implementation Spec
|
||||||
|
1. **Add Characteristic:** The GATT Server MUST include the `Identity Characteristic` in its service definition.
|
||||||
|
* **Properties:** `Write` | `Write No Response`
|
||||||
|
* **Permissions:** `Write`
|
||||||
|
2. **Handle Write Request:** When a write request is received on this UUID:
|
||||||
|
* **Extract** the 8-byte Peer ID.
|
||||||
|
* **Bind** the identity to the current connection handle (MAC address).
|
||||||
|
* **Execute Deduplication Policy** (see Section 5).
|
||||||
|
|
||||||
|
#### Android Reference (Server)
|
||||||
|
```kotlin
|
||||||
|
// In onCharacteristicWriteRequest()
|
||||||
|
if (characteristic.uuid == IDENTITY_CHARACTERISTIC_UUID) {
|
||||||
|
val peerID = value.joinToString("") { "%02x".format(it) }
|
||||||
|
|
||||||
|
// 1. Update internal mapping (MAC -> PeerID)
|
||||||
|
connectionTracker.setDevicePeerID(device.address, peerID)
|
||||||
|
|
||||||
|
// 2. Deduplication Check
|
||||||
|
val duplicate = connectionTracker.getConnectedDevices().values.firstOrNull {
|
||||||
|
it.peerID == peerID && it.device.address != device.address
|
||||||
|
}
|
||||||
|
|
||||||
|
if (duplicate != null) {
|
||||||
|
// Peer is already connected via a different MAC
|
||||||
|
Log.w(TAG, "Deduplication: Rejecting new connection from $peerID")
|
||||||
|
|
||||||
|
// Respond success to complete the transaction gracefully
|
||||||
|
if (responseNeeded) {
|
||||||
|
gattServer.sendResponse(device, requestId, GATT_SUCCESS, 0, null)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Terminate the NEW connection
|
||||||
|
gattServer.cancelConnection(device)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Deduplication Policy
|
||||||
|
|
||||||
|
The goal is to allow only **one** logical connection between any two peers, regardless of direction (Client/Server) or MAC address rotation.
|
||||||
|
|
||||||
|
### 5.1 Rules
|
||||||
|
1. **Peer ID Primacy:** The persistent Peer ID is the source of truth, not the MAC address.
|
||||||
|
2. **First-Come-First-Served:** The *existing* stable connection is preferred. A new incoming connection attempting to claim the same Peer ID will be rejected.
|
||||||
|
3. **Cross-Role Deduplication:**
|
||||||
|
* If Node A is connected to Node B (A=Client, B=Server).
|
||||||
|
* And Node B tries to connect to Node A (B=Client, A=Server).
|
||||||
|
* If both sides identify correctly, one link is redundant.
|
||||||
|
* *Current Policy:* Bitchat currently treats dual-direction links as acceptable but suboptimal. However, strict deduplication via **Active Signaling** will treat the second link as a duplicate if the application layer maps them to the same ID.
|
||||||
|
* **Strict Mode (Recommended):** If `PeerID_X` is connected as a client, do NOT accept `PeerID_X` as a server.
|
||||||
|
|
||||||
|
### 5.2 Conflict Resolution Flow
|
||||||
|
1. **New Connection (Incoming):** Device `AA:BB:CC` connects.
|
||||||
|
2. **Identity Signal:** Device writes ID `12345678`.
|
||||||
|
3. **Lookup:** Server checks list of *other* active connections.
|
||||||
|
* Is `12345678` connected via `DD:EE:FF`?
|
||||||
|
4. **Decision:**
|
||||||
|
* **Yes:** Disconnect `AA:BB:CC` immediately. (Keep the old, stable link).
|
||||||
|
* **No:** Accept `AA:BB:CC` and update the map.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. iOS Implementation Notes
|
||||||
|
|
||||||
|
### Swift Implementation Guidance
|
||||||
|
The iOS `CoreBluetooth` implementation should mirror the logic above.
|
||||||
|
|
||||||
|
**CBPeripheralManagerDelegate:**
|
||||||
|
In `didReceiveWrite requests`:
|
||||||
|
```swift
|
||||||
|
func peripheralManager(_ peripheral: CBPeripheralManager, didReceiveWrite requests: [CBATTRequest]) {
|
||||||
|
for request in requests {
|
||||||
|
if request.characteristic.uuid == CBUUID(string: "A1B2C3D4-E5F6-4A5B-8C9D-0E1F2A3B4C5E") {
|
||||||
|
let peerData = request.value
|
||||||
|
// 1. Parse PeerID
|
||||||
|
// 2. Check Connection Manager for existing PeerID
|
||||||
|
// 3. If duplicate, cancel connection:
|
||||||
|
// peripheralManager.respond(to: request, withResult: .success)
|
||||||
|
// // Wait briefly or disconnect immediately?
|
||||||
|
// // CBCentralManager usually handles disconnects, but as a Peripheral,
|
||||||
|
// // you simply stop processing. To force disconnect, you might need
|
||||||
|
// // to let the Central handle the rejection or implement app-layer timeout.
|
||||||
|
// // NOTE: iOS Peripheral cannot force-disconnect a Central easily.
|
||||||
|
// // Alternative: Send an app-layer "Disconnect" packet or error response.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note on iOS Peripheral Disconnects:**
|
||||||
|
Unlike Android, iOS `CBPeripheralManager` does not have a direct `cancelConnection` for a specific central.
|
||||||
|
* **Strategy:** If a duplicate is detected, the iOS Peripheral should respond with a custom GATT Error (if possible) or simply ignore further data requests, causing the Central (if Bitchat) to timeout.
|
||||||
|
* **Preferred Strategy:** The Android/iOS **Client** (Central) is the one that typically manages the connection lifecycle. If the Server detects a duplicate, it could write a specific "Go Away" code to the characteristic or simply refuse to process further packets.
|
||||||
|
* **Protocol Refinement:** If the Server responds with `GATT_SUCCESS`, the Client assumes it's accepted. If the Server responds with an error (e.g., `CBATTError.insufficientResources` or a custom code), the Client should disconnect itself.
|
||||||
|
|
||||||
|
**Refined Server Logic (Cross-Platform):**
|
||||||
|
If the Server detects a duplicate:
|
||||||
|
1. Respond to the Write Request with `GATT_SUCCESS` (to satisfy the protocol).
|
||||||
|
2. Internally flag this `CBCentral` as "Rejected".
|
||||||
|
3. Ignore any subsequent data writes from this Central.
|
||||||
|
4. (Optional) If the Central subscribes to the Data Characteristic, send a single "Disconnect Command" packet and then stop notifying.
|
||||||
|
|
||||||
|
### Android Compliance
|
||||||
|
The Android implementation provided in the Reference section uses `gattServer.cancelConnection(device)`, which effectively terminates the link. iOS devices acting as Clients will see this as a disconnection and should handle it gracefully (auto-retry logic should respect the fact that it might be a purposeful disconnect, but standard backoff applies).
|
||||||
Reference in New Issue
Block a user