Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ class BluetoothConnectionManager(
context, connectionScope, connectionTracker, permissionManager, powerManager, componentDelegate, myPeerID
)
private val clientManager = BluetoothGattClientManager(
context, connectionScope, connectionTracker, permissionManager, powerManager, componentDelegate
context, connectionScope, connectionTracker, permissionManager, powerManager, componentDelegate, myPeerID
)

// Service state
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,19 @@ class BluetoothConnectionTracker(
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)
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@ class BluetoothGattClientManager(
private val connectionTracker: BluetoothConnectionTracker,
private val permissionManager: BluetoothPermissionManager,
private val powerManager: PowerManager,
private val delegate: BluetoothConnectionManagerDelegate?
private val delegate: BluetoothConnectionManagerDelegate?,
private val myPeerID: String
) {

companion object {
Expand Down Expand Up @@ -480,6 +481,20 @@ class BluetoothGattClientManager(
connectionTracker.updateDeviceConnection(deviceAddress, updatedConn)
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")
Comment on lines +489 to +493
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Serialize GATT ops before enabling notifications

Android’s GATT stack only allows one outstanding operation at a time, and this path issues writeCharacteristic(identityChar) and then immediately proceeds to write the CCCD. On devices that enforce serialization (common), the subsequent writeDescriptor can return false/never execute, leaving notifications disabled and the connection unable to receive data even though it is marked “connected.” This is especially risky with WRITE_TYPE_NO_RESPONSE, which provides no completion signal to gate the descriptor write. Consider queueing the CCCD write and only enabling notifications after the identity write completes (or use a write type with a completion callback).

Useful? React with 👍 / 👎.

} catch (e: Exception) {
Log.w(TAG, "Client: Failed to write identity: ${e.message}")
}
}

gatt.setCharacteristicNotification(characteristic, true)
val descriptor = characteristic.getDescriptor(AppConstants.Mesh.Gatt.DESCRIPTOR_UUID)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,42 @@ class BluetoothGattServerManager(
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) {
gattServer?.sendResponse(device, requestId, BluetoothGatt.GATT_SUCCESS, 0, null)
}
Expand Down Expand Up @@ -310,6 +346,15 @@ class BluetoothGattServerManager(

val service = BluetoothGattService(AppConstants.Mesh.Gatt.SERVICE_UUID, BluetoothGattService.SERVICE_TYPE_PRIMARY)
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)

Expand Down
1 change: 1 addition & 0 deletions app/src/main/java/com/bitchat/android/util/AppConstants.kt
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ object AppConstants {
object Gatt {
val SERVICE_UUID: UUID = UUID.fromString("F47B5E2D-4A9E-4C5A-9B3F-8E1D2C3A4B5C")
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")
}
}
Expand Down
184 changes: 184 additions & 0 deletions docs/PEERID_GATT_SERVER_CLIENT_SPEC.md
Original file line number Diff line number Diff line change
@@ -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).
Loading