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
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,9 @@ A single node runs a genuinely simultaneous **dual-role BLE stack** on NimBLE. I

- **Full BitChat handshake.** Implements the Noise XX pattern (`Noise_XX_25519_ChaChaPoly_SHA256`) via the bundled `noise_ref` (Noise-C) reference library, with Ed25519 identity binding like the mobile apps. Each node persists a Curve25519 static keypair and an Ed25519 signing keypair in NVS; its 8-byte peer ID is the first 8 bytes of SHA-256 over its Noise static public key.

- **Signed identity announces.** Identity rides in a signed `ANNOUNCE` (type `0x01`) carrying TLVs for nickname, Noise static key, and Ed25519 signing key, plus two Bitle-private TLVs: firmware version (`0xB0`) and role/authority flags (`0xB1`). A legacy `0x13` identity-announce is still emitted best-effort for older clients. Inbound announces are hard-rejected unless the sender ID equals SHA-256(announced Noise key)[0:8].
- **Signed identity announces with neighbor gossip.** Identity rides in a signed `ANNOUNCE` (type `0x01`) carrying TLVs for nickname, Noise static key, and Ed25519 signing key, the upstream `DIRECT_NEIGHBORS` gossip TLV (`0x04`, up to 10 verified direct peers, so phones can fold the node into their mesh graph and source-route through it), plus two Bitle-private TLVs: firmware version (`0xB0`) and role/authority flags (`0xB1`). A legacy `0x13` identity-announce is still emitted best-effort for older clients. Inbound announces are hard-rejected unless the sender ID equals SHA-256(announced Noise key)[0:8].

- **Dual-role BLE mesh relay.** Packets are encoded/decoded with the BitChat binary format and relayed to every other subscribed link. Relay is TTL-based (packets with `ttl <= 1` are dropped, otherwise the TTL byte is decremented before rebroadcast) and de-duplicated with an FNV-1a fingerprint over the packet bytes (skipping the TTL byte) kept in a 64-entry ring. Own echoes, `REQUEST_SYNC`, packets addressed to this node, and undirected Noise handshakes are never relayed. Phone-fragmented packets are reassembled in a small bounded pool (2 slots, up to 4 parts × 501 bytes, 15 s timeout); anything larger is forwarded relay-only. Max handled BLE packet size is 520 bytes. A 30 s subscribe watchdog drops links that connect but never enable notifications, and a short deny/cool-down list prevents immediately re-dialing a just-dropped peer.
- **Dual-role BLE mesh relay with source routing.** Packets are encoded/decoded with the BitChat binary format (v1 and v2) and forwarded like the upstream relay: TTL 0 stops forwarding, otherwise the TTL byte is decremented first. A v2 packet carrying a source route (flag `0x08`) is unicast toward the next hop when this node is listed in the route — peer IDs resolve to links through a direct-peer table — with duplicate-hop loop rejection, the signed route left byte-intact, and flood fallback when the next hop is not directly connected. Unrouted packets flood to every link except the ingress link and the original sender's own link. Flooding is de-duplicated with an FNV-1a fingerprint over the packet bytes (skipping the TTL byte) kept in a 64-entry ring. Own echoes, `REQUEST_SYNC`, packets addressed to this node, and undirected Noise handshakes are never relayed. A peer counts as **directly connected** exactly as upstream: a signature-verified announce from it arrived still at its origin TTL (7) on a live link. The node also learns the mesh graph from gossip TLVs and attaches source routes (version 2) to its own directed packets whose recipient is only reachable through the mesh, computed as the shortest path over mutually-confirmed edges. Phone-fragmented packets are reassembled in a small bounded pool (2 slots, up to 4 parts × 501 bytes, 15 s timeout); anything larger is forwarded relay-only. Max handled BLE packet size is 520 bytes (587 for routed v2). A 30 s subscribe watchdog drops links that connect but never enable notifications, and a short deny/cool-down list prevents immediately re-dialing a just-dropped peer.

- **LoRa long-range trunk (ESP32-S3 nodes).** When an SX1262 is detected at boot, the node brings up a 915 MHz LoRa backbone between nodes — a second radio the phones never touch. The trunk registers as one more link in the transport-agnostic link registry, so the mesh relays BLE↔LoRa with no special cases: a message crosses the trunk and comes back down to BLE at the far end. See the [LoRa backhaul](#lora-backhaul) section for the details (framing, ARQ, spreading factor, range).

Expand Down Expand Up @@ -70,6 +70,7 @@ Radio: **Semtech SX1262** (Seeed Wio-SX1262 + XIAO ESP32-S3), +22 dBm, 902–928
├── bitchat_ble.{c,h} # dual-role BLE transport (peripheral + central)
├── bitle_link.{c,h} # transport-agnostic link registry (BLE + LoRa)
├── bitle_mesh.{c,h} # transport-agnostic dispatch, dedup, fragments, relay
├── bitle_route.{c,h} # direct-peer table, neighbor graph, source-route BFS
├── bitle_lora.{c,h} # LoRa trunk: framing, ARQ, admission, padding strip
├── sx1262.{c,h} # SX1262 LoRa radio driver (ESP-IDF native)
├── noise_handshake.{c,h} # Noise XX, announce TLVs, message dispatch
Expand Down
1 change: 1 addition & 0 deletions main/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ idf_component_register(
"bitle_hash.c"
"bitle_link.c"
"bitle_mesh.c"
"bitle_route.c"
"bitle_lora.c"
"sx1262.c"
"bitle_ota.c"
Expand Down
8 changes: 6 additions & 2 deletions main/bitle_link.c
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
#include "freertos/FreeRTOS.h"
#include "freertos/semphr.h"

#include "bitle_route.h"

static const char *TAG = "bitle_link";

typedef struct {
Expand Down Expand Up @@ -71,6 +73,8 @@ void bitle_link_unregister(uint16_t handle)
memset(e, 0, sizeof(*e));
}
xSemaphoreGive(s_lock);
/* Any peer-ID -> link mapping through this handle is now dangling. */
bitle_route_link_down(handle);
}

bool bitle_link_ready(uint16_t handle)
Expand All @@ -94,7 +98,7 @@ esp_err_t bitle_link_send(uint16_t handle, const uint8_t *data, uint16_t len)
return fn(handle, data, len) == 0 ? ESP_OK : ESP_FAIL;
}

int bitle_link_broadcast(uint16_t exclude_handle, const uint8_t *data, uint16_t len)
int bitle_link_broadcast(uint16_t exclude_a, uint16_t exclude_b, const uint8_t *data, uint16_t len)
{
/* Snapshot under the lock, send outside it. */
struct {
Expand All @@ -105,7 +109,7 @@ int bitle_link_broadcast(uint16_t exclude_handle, const uint8_t *data, uint16_t

xSemaphoreTake(s_lock, portMAX_DELAY);
for (size_t i = 0; i < BITLE_LINK_MAX; ++i) {
if (s_links[i].in_use && s_links[i].handle != exclude_handle) {
if (s_links[i].in_use && s_links[i].handle != exclude_a && s_links[i].handle != exclude_b) {
targets[n].handle = s_links[i].handle;
targets[n].fn = s_links[i].send_fn;
n++;
Expand Down
9 changes: 6 additions & 3 deletions main/bitle_link.h
Original file line number Diff line number Diff line change
Expand Up @@ -51,9 +51,12 @@ bool bitle_link_ready(uint16_t handle);

esp_err_t bitle_link_send(uint16_t handle, const uint8_t *data, uint16_t len);

/* Sends to every registered link except exclude_handle (BITLE_LINK_NONE to
* send to all). Returns the number of links the send succeeded on. */
int bitle_link_broadcast(uint16_t exclude_handle, const uint8_t *data, uint16_t len);
/* Sends to every registered link except the two exclusions (BITLE_LINK_NONE
* for "no exclusion"). The relay passes the ingress link and the link the
* original sender is directly connected on, so a copy never goes back
* toward the origin (upstream BluetoothPacketBroadcaster stage C). Returns
* the number of links the send succeeded on. */
int bitle_link_broadcast(uint16_t exclude_a, uint16_t exclude_b, const uint8_t *data, uint16_t len);

#ifdef __cplusplus
}
Expand Down
59 changes: 45 additions & 14 deletions main/bitle_lora.c
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,17 @@ static const char *TAG = "bitle_lora";
* The LoRa trunk is bandwidth-precious (default SF10/BW125 ~= 1 kbps shared,
* whole-packet airtimes from hundreds of ms to seconds), so not everything
* the mesh would relay over BLE belongs on it. Encoded BitChat header
* offsets (see packet_codec): [1]=type, [2]=ttl, [14..21]=sender id. */
* offsets (see packet_codec): [0]=version, [1]=type, [11]=flags, sender id
* at [14..21] in v1 packets and [16..23] in v2 (4-byte payload length). */
#define PKT_TYPE_OFF 1
#define PKT_SENDER_OFF 14
#define PKT_FLAGS_OFF 11
#define PKT_MIN_LEN 22

static size_t pkt_sender_off(uint8_t version)
{
return version >= 2 ? 16 : 14;
}

/* Per-origin throttle for identity/announce floods: an announce carries no
* time-critical content, so one per origin per interval is plenty for
* discovery while a chatty phone cannot monopolize the channel. */
Expand Down Expand Up @@ -80,7 +86,8 @@ static const char *TAG = "bitle_lora";
* on a marginal link far more reliably than long ones. */
#define TRUNK_CHUNK_MAX (SX1262_MAX_PAYLOAD - TRUNK_HDR_LEN)
#define TRUNK_CHUNK_TX 120
#define TRUNK_MAX_FRAGS ((BITCHAT_BLE_MAX_PACKET_SIZE + TRUNK_CHUNK_TX - 1) / TRUNK_CHUNK_TX)
/* Source-routed (v2) packets are slightly larger than the v1 BLE budget. */
#define TRUNK_MAX_FRAGS ((BITLE_PACKET_MAX_ROUTED + TRUNK_CHUNK_TX - 1) / TRUNK_CHUNK_TX)

/* Stop-and-wait ARQ: each ack-requested frame is retransmitted until
* acked, ARQ_TRIES sends total. Announces are broadcast discovery and
Expand Down Expand Up @@ -187,6 +194,10 @@ static bool trunk_admit(const uint8_t *data, uint16_t len)
return false;
}
uint8_t type = data[PKT_TYPE_OFF];
size_t sender_off = pkt_sender_off(data[0]);
if (len < sender_off + 8) {
return false;
}
bool ota = (type >= 0xA0 && type <= 0xA3);
bool announce = (type == BITCHAT_MSG_ANNOUNCE || type == BITCHAT_MSG_NOISE_IDENTITY_ANNOUNCE);
/* Message-class traffic is user-driven, rare, and time-critical: a Noise
Expand Down Expand Up @@ -240,7 +251,7 @@ static bool trunk_admit(const uint8_t *data, uint16_t len)
ESP_LOGD(TAG, "airtime budget low; deferring announce");
return false;
}
if (announce_throttled(data + PKT_SENDER_OFF, now)) {
if (announce_throttled(data + sender_off, now)) {
taskEXIT_CRITICAL(&s_gov_mux);
return false;
}
Expand Down Expand Up @@ -280,22 +291,42 @@ static void IRAM_ATTR dio1_isr(void *arg)
/* True length of the self-describing BitChat packet, dropping any trailing
* MessagePadding (phones pad handshakes/DMs to 256 B for BLE traffic-analysis
* resistance — a pure BLE-MTU artifact that just bloats scarce LoRa airtime).
* Header: version|type|ttl|ts(8)|flags|payloadLen(2)|sender(8)|[recipient(8) if
* flags&0x01]|payload|[sig(64) if flags&0x02]. Padding is appended AFTER the
* signature, so trimming to true length never touches signed/encrypted bytes;
* receivers read payloadLen and already accept unpadded packets. Returns len
* unchanged if the header does not parse or claims more than we received. */
* Layout: version|type|ttl|ts(8)|flags|payloadLen(2 in v1, 4 in v2)|sender(8)|
* [recipient(8) if flags&0x01]|[route count(1)+N*8 if v2 && flags&0x08]|
* payload|[sig(64) if flags&0x02]. Padding is appended AFTER the signature,
* so trimming to true length never touches signed/encrypted bytes; receivers
* read payloadLen and already accept unpadded packets. Returns len unchanged
* if the header does not parse or claims more than we received. */
static uint16_t trunk_true_len(const uint8_t *data, uint16_t len)
{
if (len < 14) {
return len;
}
uint8_t flags = data[11];
uint16_t payload_len = ((uint16_t)data[12] << 8) | data[13];
uint32_t real = 22u + payload_len; /* header(22) + payload */
uint8_t version = data[0];
uint8_t flags = data[PKT_FLAGS_OFF];
uint32_t payload_len;
uint32_t real;
if (version >= 2) {
if (len < 16) {
return len;
}
payload_len = ((uint32_t)data[12] << 24) | ((uint32_t)data[13] << 16) |
((uint32_t)data[14] << 8) | data[15];
real = 16 + 8; /* v2 header + sender */
} else {
payload_len = ((uint16_t)data[12] << 8) | data[13];
real = 14 + 8; /* v1 header + sender */
}
if (flags & 0x01) {
real += 8; /* recipient id */
}
if (version >= 2 && (flags & 0x08)) {
if (real >= len) {
return len;
}
real += 1 + (uint32_t)data[real] * 8; /* route count + hops */
}
real += payload_len;
if (flags & 0x02) {
real += 64; /* Ed25519 signature */
}
Expand All @@ -307,7 +338,7 @@ static uint16_t trunk_true_len(const uint8_t *data, uint16_t len)
static int lora_link_send(uint16_t handle, const uint8_t *data, uint16_t len)
{
(void)handle;
if (!s_active || len == 0 || len > BITCHAT_BLE_MAX_PACKET_SIZE) {
if (!s_active || len == 0 || len > BITLE_PACKET_MAX_ROUTED) {
return -1;
}
/* Trim BLE padding before it costs LoRa airtime. */
Expand Down Expand Up @@ -491,7 +522,7 @@ static void rx_frame(const uint8_t *f, uint16_t len, int16_t rssi, int8_t snr)
return;
}

static uint8_t packet[BITCHAT_BLE_MAX_PACKET_SIZE];
static uint8_t packet[BITLE_PACKET_MAX_ROUTED];
uint16_t plen = 0;
for (uint8_t i = 0; i < total; ++i) {
if (plen + slot->part_len[i] > sizeof(packet)) {
Expand Down
98 changes: 89 additions & 9 deletions main/bitle_mesh.c
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
#include "bitchat_time.h"
#include "bitle_link.h"
#include "bitle_ota.h"
#include "bitle_route.h"
#include "bitle_sync.h"
#include "noise_handshake.h"
#include "packet_codec.h"
Expand Down Expand Up @@ -233,8 +234,15 @@ static void dispatch_packet(uint16_t link_handle, const bitchat_packet_t *packet
}

/* --- Mesh relay -----------------------------------------------------------
* Forwards packets between links: TTL-decremented, deduplicated raw
* re-broadcast of everything not addressed to (or sent by) this node. */
* Mirrors upstream PacketRelayManager.handlePacketRelay: packets addressed
* to (or sent by) this node are never relayed, TTL 0 stops forwarding, and
* the TTL is decremented before any forwarding. A v2 source route (flag
* 0x08) gets targeted next-hop forwarding — we forward only when we appear
* in the route, toward the next hop (or the final recipient when we are the
* last hop), with the signed route left byte-intact; when the next hop is
* not directly connected we fall back to flooding. Unrouted packets flood
* to every link except the ingress link and the original sender's own link.
* REQUEST_SYNC and undirected handshakes stay link-local (Bitle hardening). */

#define RELAY_CACHE_SIZE 64

Expand All @@ -244,7 +252,8 @@ static size_t s_relay_seen_next;
static uint64_t relay_fingerprint(const uint8_t *data, size_t len)
{
/* FNV-1a over the packet bytes, skipping the TTL byte (offset 2), which
* changes at every hop and must not defeat deduplication. */
* changes at every hop and must not defeat deduplication. The route is
* signed and forwarded intact, so it needs no such treatment. */
uint64_t hash = 1469598103934665603ULL;
for (size_t i = 0; i < len; ++i) {
if (i == 2) {
Expand All @@ -268,27 +277,89 @@ static bool relay_seen_before(uint64_t fingerprint)
return false;
}

/* Source-route forwarding (upstream: route present, we are a listed hop).
* Returns true when the packet was unicast toward its next hop; false means
* "fall back to flooding". */
static bool relay_along_route(uint16_t src_link, uint8_t *buffer, uint16_t len, const bitchat_packet_t *packet)
{
(void)src_link;
/* Duplicate hops mean a routing loop; upstream drops the packet. */
for (uint8_t i = 0; i < packet->route_count; ++i) {
for (uint8_t j = i + 1; j < packet->route_count; ++j) {
if (memcmp(packet->route[i], packet->route[j], 8) == 0) {
ESP_LOGW(TAG, "Route with duplicate hops dropped");
return true; /* handled: dropped */
}
}
}
const uint8_t *me = noise_get_local_peer_id();
int index = -1;
for (uint8_t i = 0; i < packet->route_count; ++i) {
if (memcmp(packet->route[i], me, 8) == 0) {
index = i;
break;
}
}
if (index < 0) {
return false; /* not our hop: the flood path takes it */
}
const uint8_t *next;
if (index + 1 < packet->route_count) {
next = packet->route[index + 1];
} else if (packet->has_recipient) {
next = packet->recipient_id; /* last intermediate: deliver to the recipient */
} else {
return false;
}
uint16_t link = bitle_route_link_for(next);
if (link == BITLE_LINK_NONE) {
ESP_LOGW(TAG, "Route next hop %02X%02X.. not directly connected; flooding",
next[0], next[1]);
return false;
}
if (bitle_link_send(link, buffer, len) != ESP_OK) {
return false;
}
ESP_LOGI(TAG, "Route-relay type=0x%02X ttl=%u -> %02X%02X%02X%02X.. link=%u",
packet->type, buffer[2], next[0], next[1], next[2], next[3], link);
return true;
}

static void relay_packet(uint16_t src_link, uint8_t *buffer, uint16_t len, const bitchat_packet_t *packet)
{
if (packet->ttl <= 1) {
return;
if (is_local_recipient(packet)) {
return; /* addressed to us; nothing to forward */
}
if (memcmp(packet->sender_id, noise_get_local_peer_id(), sizeof(packet->sender_id)) == 0) {
return; /* our own packet echoed back */
}
if (packet->ttl == 0) {
return; /* expired */
}
if (packet->type == BITCHAT_MSG_REQUEST_SYNC) {
return; /* link-local by protocol */
}
if (is_local_recipient(packet)) {
return; /* addressed to us; nothing to forward */
}
if (!packet->has_recipient && packet->type == BITCHAT_MSG_NOISE_HANDSHAKE) {
return; /* undirected handshakes are link-local */
}

buffer[2] = packet->ttl - 1;

int forwarded = bitle_link_broadcast(src_link, buffer, len);
if (packet->has_route && packet->route_count > 0 && !packet->route_truncated) {
if (relay_along_route(src_link, buffer, len, packet)) {
return;
}
}

/* Flood: every link except the one it arrived on and the sender's own
* point-to-point link. A broadcast-medium mapping (LoRa trunk) must NOT
* suppress forwarding: the packet may have reached us over another path
* and the trunk's other listeners still need it. */
uint16_t sender_link = bitle_route_link_for(packet->sender_id);
if (sender_link != BITLE_LINK_NONE && bitle_link_is_broadcast(sender_link)) {
sender_link = BITLE_LINK_NONE;
}
int forwarded = bitle_link_broadcast(src_link, sender_link, buffer, len);
if (forwarded > 0) {
ESP_LOGI(TAG, "Relayed type=0x%02X ttl=%u to %d link(s)", packet->type, buffer[2], forwarded);
}
Expand All @@ -315,6 +386,15 @@ bool bitle_mesh_inbound(uint16_t link_handle, uint8_t *buffer, uint16_t len)
* link, and every reply then exits the wrong interface. Retries are
* never byte-identical (fresh timestamps/nonces), so they pass. */
if (relay_seen_before(relay_fingerprint(buffer, len))) {
/* Upstream re-admits announces still at their origin TTL past dedup
* (SecurityManager): the same announce races through several paths
* at once, and if a relayed copy won, the direct copy — the only
* proof of who is directly connected — must still be processed.
* The duplicate is dispatched locally only: never re-relayed,
* never re-ingested into the sync store. */
if (packet.type == BITCHAT_MSG_ANNOUNCE && packet.ttl == BITLE_ORIGIN_TTL) {
dispatch_packet(link_handle, &packet);
}
xSemaphoreGive(s_lock);
ESP_LOGD(TAG, "duplicate packet dropped (type=0x%02X)", packet.type);
bitchat_packet_free(&packet);
Expand Down
Loading