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:
callebtc
2026-01-16 04:14:23 +07:00
parent e3056d70c0
commit 6c7483956e
6 changed files with 260 additions and 2 deletions
@@ -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 {
@@ -480,6 +481,20 @@ class BluetoothGattClientManager(
connectionTracker.updateDeviceConnection(deviceAddress, updatedConn) connectionTracker.updateDeviceConnection(deviceAddress, updatedConn)
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)
@@ -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)
} }
@@ -310,6 +346,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)
@@ -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")
} }
} }
+184
View File
@@ -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).