diff --git a/README.md b/README.md index b127a1a..5ff38bd 100644 --- a/README.md +++ b/README.md @@ -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). @@ -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 diff --git a/main/CMakeLists.txt b/main/CMakeLists.txt index 3097755..66a5042 100644 --- a/main/CMakeLists.txt +++ b/main/CMakeLists.txt @@ -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" diff --git a/main/bitle_link.c b/main/bitle_link.c index 4860691..888f395 100644 --- a/main/bitle_link.c +++ b/main/bitle_link.c @@ -6,6 +6,8 @@ #include "freertos/FreeRTOS.h" #include "freertos/semphr.h" +#include "bitle_route.h" + static const char *TAG = "bitle_link"; typedef struct { @@ -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) @@ -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 { @@ -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++; diff --git a/main/bitle_link.h b/main/bitle_link.h index 1689853..9db193c 100644 --- a/main/bitle_link.h +++ b/main/bitle_link.h @@ -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 } diff --git a/main/bitle_lora.c b/main/bitle_lora.c index 3a0c1a9..f7befea 100644 --- a/main/bitle_lora.c +++ b/main/bitle_lora.c @@ -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. */ @@ -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 @@ -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 @@ -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; } @@ -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 */ } @@ -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. */ @@ -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)) { diff --git a/main/bitle_mesh.c b/main/bitle_mesh.c index 893f151..0447076 100644 --- a/main/bitle_mesh.c +++ b/main/bitle_mesh.c @@ -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" @@ -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 @@ -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) { @@ -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); } @@ -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); diff --git a/main/bitle_mesh.h b/main/bitle_mesh.h index a7e9064..eb65c1d 100644 --- a/main/bitle_mesh.h +++ b/main/bitle_mesh.h @@ -2,9 +2,12 @@ #define BITLE_MESH_H /* Transport-agnostic mesh core: packet dispatch, fragment reassembly, - * dedup, and TTL relay. Transports (BLE and the LoRa trunk) feed every + * dedup, and relay. Transports (BLE and the LoRa trunk) feed every * complete inbound packet here; relaying goes back out through the - * bitle_link registry to every other ready link, regardless of medium. + * bitle_link registry. Forwarding mirrors the upstream BitChat relay: + * v2 source routes get targeted next-hop forwarding (peer IDs resolved + * to links via bitle_route, with flood fallback), everything else floods + * to every other ready link, regardless of medium. * * bitle_mesh_inbound is safe to call from multiple tasks: the whole * inbound path (dispatch, fragment pool, dedup ring, sync ingest, relay) diff --git a/main/bitle_route.c b/main/bitle_route.c new file mode 100644 index 0000000..f6ed5f1 --- /dev/null +++ b/main/bitle_route.c @@ -0,0 +1,396 @@ +#include "bitle_route.h" + +#include + +#include "esp_log.h" +#include "esp_timer.h" +#include "freertos/FreeRTOS.h" +#include "freertos/semphr.h" + +#include "bitle_link.h" + +static const char *TAG = "bitle_route"; + +/* BLE allows at most BITLE_LINK_MAX simultaneous direct peers, but broadcast + * media (the LoRa trunk) fronts further neighbors, so the table is wider. */ +#define DIRECT_MAX 16 + +/* Topology learned from gossip TLVs; sized for the small meshes these nodes + * serve, LRU-evicted under pressure. */ +#define GRAPH_MAX 24 + +/* Neighbors heard over broadcast media never get a link-down event, so their + * direct mappings lapse unless refreshed by periodic announces (LoRa beacons + * run every 60 s; upstream's stale-peer timeout is 180 s). */ +#define DIRECT_BROADCAST_MAX_AGE_MS (180 * 1000ULL) + +typedef struct { + bool in_use; + uint8_t peer_id[8]; + uint16_t link; + uint64_t last_ms; +} direct_entry_t; + +typedef struct { + bool in_use; + uint8_t peer_id[8]; + uint64_t timestamp_ms; /* announce's own (attacker-controlled) timestamp */ + uint64_t updated_ms; /* local monotonic, for LRU eviction only */ + uint8_t count; + uint8_t neighbors[BITLE_ROUTE_MAX_NEIGHBORS][8]; +} graph_entry_t; + +static SemaphoreHandle_t s_lock; +static direct_entry_t s_direct[DIRECT_MAX]; +static graph_entry_t s_graph[GRAPH_MAX]; + +esp_err_t bitle_route_init(void) +{ + s_lock = xSemaphoreCreateMutex(); + return s_lock ? ESP_OK : ESP_ERR_NO_MEM; +} + +static uint64_t now_ms(void) +{ + return esp_timer_get_time() / 1000ULL; +} + +static bool id_eq(const uint8_t a[8], const uint8_t b[8]) +{ + return memcmp(a, b, 8) == 0; +} + +/* Point-to-point mappings die with their link; broadcast-media mappings + * lapse without refreshes. Must be called with s_lock held. */ +static bool direct_entry_live_locked(direct_entry_t *e) +{ + if (!e->in_use) { + return false; + } + if (bitle_link_is_broadcast(e->link) && + now_ms() - e->last_ms > DIRECT_BROADCAST_MAX_AGE_MS) { + e->in_use = false; + return false; + } + return true; +} + +void bitle_route_note_direct(const uint8_t peer_id[8], uint16_t link) +{ + if (!s_lock) { + return; + } + xSemaphoreTake(s_lock, portMAX_DELAY); + direct_entry_t *slot = NULL; + direct_entry_t *oldest = &s_direct[0]; + for (size_t i = 0; i < DIRECT_MAX; ++i) { + direct_entry_t *e = &s_direct[i]; + direct_entry_live_locked(e); + if (e->in_use && id_eq(e->peer_id, peer_id)) { + slot = e; + break; + } + if (!e->in_use && !slot) { + slot = e; + } + if (e->last_ms < oldest->last_ms) { + oldest = e; + } + } + if (!slot) { + slot = oldest; + } + slot->in_use = true; + memcpy(slot->peer_id, peer_id, 8); + slot->link = link; + slot->last_ms = now_ms(); + xSemaphoreGive(s_lock); +} + +void bitle_route_link_down(uint16_t link) +{ + if (!s_lock) { + return; + } + xSemaphoreTake(s_lock, portMAX_DELAY); + for (size_t i = 0; i < DIRECT_MAX; ++i) { + if (s_direct[i].in_use && s_direct[i].link == link) { + s_direct[i].in_use = false; + } + } + xSemaphoreGive(s_lock); +} + +uint16_t bitle_route_link_for(const uint8_t peer_id[8]) +{ + if (!s_lock) { + return BITLE_LINK_NONE; + } + uint16_t link = BITLE_LINK_NONE; + xSemaphoreTake(s_lock, portMAX_DELAY); + for (size_t i = 0; i < DIRECT_MAX; ++i) { + direct_entry_t *e = &s_direct[i]; + if (direct_entry_live_locked(e) && id_eq(e->peer_id, peer_id)) { + link = e->link; + break; + } + } + xSemaphoreGive(s_lock); + return link; +} + +bool bitle_route_is_direct(const uint8_t peer_id[8]) +{ + return bitle_route_link_for(peer_id) != BITLE_LINK_NONE; +} + +int bitle_route_direct_peers(uint8_t out[][8], int max) +{ + if (!s_lock || !out || max <= 0) { + return 0; + } + int n = 0; + xSemaphoreTake(s_lock, portMAX_DELAY); + for (size_t i = 0; i < DIRECT_MAX && n < max; ++i) { + direct_entry_t *e = &s_direct[i]; + if (!direct_entry_live_locked(e)) { + continue; + } + bool dup = false; + for (int j = 0; j < n; ++j) { + if (id_eq(out[j], e->peer_id)) { + dup = true; + break; + } + } + if (!dup) { + memcpy(out[n++], e->peer_id, 8); + } + } + xSemaphoreGive(s_lock); + return n; +} + +void bitle_route_note_neighbors(const uint8_t peer_id[8], const uint8_t neighbors[][8], + int count, uint64_t timestamp_ms) +{ + if (!s_lock || count < 0) { + return; + } + if (count > BITLE_ROUTE_MAX_NEIGHBORS) { + count = BITLE_ROUTE_MAX_NEIGHBORS; + } + xSemaphoreTake(s_lock, portMAX_DELAY); + graph_entry_t *entry = NULL; + graph_entry_t *oldest = &s_graph[0]; + for (size_t i = 0; i < GRAPH_MAX; ++i) { + graph_entry_t *g = &s_graph[i]; + if (g->in_use && id_eq(g->peer_id, peer_id)) { + entry = g; + break; + } + if (!g->in_use && !entry) { + entry = g; + } + if (g->updated_ms < oldest->updated_ms) { + oldest = g; + } + } + if (entry && entry->in_use && timestamp_ms <= entry->timestamp_ms) { + /* Only strictly newer claims replace what we already know. */ + xSemaphoreGive(s_lock); + return; + } + if (!entry) { + entry = oldest; + } + memset(entry->neighbors, 0, sizeof(entry->neighbors)); + entry->in_use = true; + memcpy(entry->peer_id, peer_id, 8); + entry->timestamp_ms = timestamp_ms; + entry->updated_ms = now_ms(); + entry->count = 0; + for (int i = 0; i < count; ++i) { + if (id_eq(neighbors[i], peer_id)) { + continue; /* self-loop claims carry no topology */ + } + bool dup = false; + for (int j = 0; j < entry->count; ++j) { + if (id_eq(entry->neighbors[j], neighbors[i])) { + dup = true; + break; + } + } + if (!dup && entry->count < BITLE_ROUTE_MAX_NEIGHBORS) { + memcpy(entry->neighbors[entry->count++], neighbors[i], 8); + } + } + xSemaphoreGive(s_lock); +} + +/* Neighbor set of one BFS node: our own neighborhood is the direct-peer + * table (which only holds verified direct links); everyone else's is their + * gossiped claim. Must be called with s_lock held. */ +static int neighbors_of_locked(const uint8_t peer_id[8], const uint8_t src[8], + uint8_t out[][8], int max) +{ + int n = 0; + if (id_eq(peer_id, src)) { + for (size_t i = 0; i < DIRECT_MAX && n < max; ++i) { + direct_entry_t *e = &s_direct[i]; + if (direct_entry_live_locked(e)) { + memcpy(out[n++], e->peer_id, 8); + } + } + return n; + } + for (size_t i = 0; i < GRAPH_MAX; ++i) { + graph_entry_t *g = &s_graph[i]; + if (g->in_use && id_eq(g->peer_id, peer_id)) { + n = g->count < max ? g->count : max; + memcpy(out, g->neighbors, (size_t)n * 8); + return n; + } + } + return 0; +} + +/* A routable edge exists only when both sides announced each other + * (upstream MeshGraphService "confirmed edge"). Caller holds s_lock. */ +static bool edge_confirmed_locked(const uint8_t a[8], const uint8_t b[8], const uint8_t src[8]) +{ + uint8_t tmp[BITLE_ROUTE_MAX_NEIGHBORS + DIRECT_MAX][8]; + int na = neighbors_of_locked(a, src, tmp, sizeof(tmp) / sizeof(tmp[0])); + bool a_lists_b = false; + for (int i = 0; i < na; ++i) { + if (id_eq(tmp[i], b)) { + a_lists_b = true; + break; + } + } + if (!a_lists_b) { + return false; + } + int nb = neighbors_of_locked(b, src, tmp, sizeof(tmp) / sizeof(tmp[0])); + for (int i = 0; i < nb; ++i) { + if (id_eq(tmp[i], a)) { + return true; + } + } + return false; +} + +#define BFS_MAX_NODES (1 + DIRECT_MAX + GRAPH_MAX + 1) + +int bitle_route_compute(const uint8_t src[8], const uint8_t dest[8], + uint8_t out_hops[][8], int max_hops) +{ + if (!s_lock || !src || !dest || !out_hops || max_hops <= 0) { + return -1; + } + xSemaphoreTake(s_lock, portMAX_DELAY); + + /* A currently-connected direct neighbor needs no route. */ + for (size_t i = 0; i < DIRECT_MAX; ++i) { + if (direct_entry_live_locked(&s_direct[i]) && id_eq(s_direct[i].peer_id, dest)) { + xSemaphoreGive(s_lock); + return 0; + } + } + + uint8_t ids[BFS_MAX_NODES][8]; + int n_ids = 0; + memcpy(ids[n_ids++], src, 8); + for (size_t i = 0; i < DIRECT_MAX && n_ids < BFS_MAX_NODES; ++i) { + if (!direct_entry_live_locked(&s_direct[i])) { + continue; + } + bool seen = id_eq(s_direct[i].peer_id, src); + for (int j = 0; j < n_ids && !seen; ++j) { + seen = id_eq(ids[j], s_direct[i].peer_id); + } + if (!seen) { + memcpy(ids[n_ids++], s_direct[i].peer_id, 8); + } + } + for (size_t i = 0; i < GRAPH_MAX && n_ids < BFS_MAX_NODES; ++i) { + if (!s_graph[i].in_use) { + continue; + } + bool seen = false; + for (int j = 0; j < n_ids && !seen; ++j) { + seen = id_eq(ids[j], s_graph[i].peer_id); + } + if (!seen) { + memcpy(ids[n_ids++], s_graph[i].peer_id, 8); + } + } + int dest_idx = -1; + for (int i = 0; i < n_ids; ++i) { + if (id_eq(ids[i], dest)) { + dest_idx = i; + break; + } + } + if (dest_idx < 0) { + /* The destination never announced its neighborhood, so no confirmed + * edge can terminate there. */ + xSemaphoreGive(s_lock); + return -1; + } + + int8_t parent[BFS_MAX_NODES]; + uint8_t queue[BFS_MAX_NODES]; + memset(parent, -1, sizeof(parent)); + parent[0] = 0; + size_t head = 0, tail = 0; + queue[tail++] = 0; + while (head < tail && parent[dest_idx] < 0) { + uint8_t cur = queue[head++]; + uint8_t nbr[BITLE_ROUTE_MAX_NEIGHBORS + DIRECT_MAX][8]; + int n = neighbors_of_locked(ids[cur], src, nbr, sizeof(nbr) / sizeof(nbr[0])); + for (int i = 0; i < n; ++i) { + int ni = -1; + for (int j = 0; j < n_ids; ++j) { + if (id_eq(ids[j], nbr[i])) { + ni = j; + break; + } + } + if (ni < 0 || parent[ni] >= 0) { + continue; + } + if (!edge_confirmed_locked(ids[cur], ids[ni], src)) { + continue; + } + parent[ni] = (int8_t)cur; + queue[tail++] = (uint8_t)ni; + if (ni == dest_idx) { + break; + } + } + } + + int result = -1; + if (parent[dest_idx] >= 0) { + /* Walk dest -> src, then reverse into hop order. */ + uint8_t path[BFS_MAX_NODES]; + int path_len = 0; + for (int at = dest_idx; at != 0 && path_len < BFS_MAX_NODES; at = parent[at]) { + path[path_len++] = (uint8_t)at; + } + /* path holds [dest, ..., first hop] (src excluded); intermediates + * are everything but dest, emitted sender-to-recipient order. */ + int intermediates = path_len - 1; + if (intermediates >= 1 && intermediates <= max_hops) { + for (int i = 0; i < intermediates; ++i) { + memcpy(out_hops[i], ids[path[path_len - 1 - i]], 8); + } + result = intermediates; + } else if (intermediates > max_hops) { + ESP_LOGW(TAG, "confirmed path too long (%d hops); not routing", intermediates); + } + } + xSemaphoreGive(s_lock); + return result; +} diff --git a/main/bitle_route.h b/main/bitle_route.h new file mode 100644 index 0000000..6fa61ce --- /dev/null +++ b/main/bitle_route.h @@ -0,0 +1,68 @@ +#ifndef BITLE_ROUTE_H +#define BITLE_ROUTE_H + +/* Peer-ID based routing state, mirroring the upstream BitChat mesh graph + * (bitchat-android services/meshgraph + BluetoothConnectionTracker): + * + * - Direct-peer table: which link a peer is directly connected on, learned + * exclusively from signature-verified ANNOUNCEs still at their origin TTL + * (the upstream DirectLinkAnnouncementPolicy rule). This is the only + * peer-ID -> link mapping; routed forwarding and the "is this peer + * directly connected" query both resolve through it. + * - Neighbor graph: every verified ANNOUNCE's DIRECT_NEIGHBORS gossip TLV + * (0x04), timestamp-gated so only strictly newer claims replace older + * ones. An edge is routable only when mutually announced (confirmed), + * exactly like upstream MeshGraphService. + * - Source-route computation: unit-weight shortest path (BFS) over + * confirmed edges, intermediates only, matching upstream RoutePlanner. + */ + +#include "esp_err.h" +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/* Upstream caps a DIRECT_NEIGHBORS TLV at 10 peer IDs. */ +#define BITLE_ROUTE_MAX_NEIGHBORS 10 + +esp_err_t bitle_route_init(void); + +/* Records peer_id as directly connected on the given link. Call only for + * signature-verified announces that arrived at their origin TTL. Refreshes + * the entry's liveness timestamp. */ +void bitle_route_note_direct(const uint8_t peer_id[8], uint16_t link); + +/* Drops every direct-peer mapping that pointed at a link which went away. */ +void bitle_route_link_down(uint16_t link); + +/* Link the peer is directly connected on, or BITLE_LINK_NONE. Mappings on + * broadcast media (LoRa) lapse when not refreshed by periodic announces; + * point-to-point mappings live until their link goes down. */ +uint16_t bitle_route_link_for(const uint8_t peer_id[8]); + +/* True when the peer currently has a live direct connection to us. */ +bool bitle_route_is_direct(const uint8_t peer_id[8]); + +/* Our verified direct peers, for the ANNOUNCE gossip TLV. Returns count. */ +int bitle_route_direct_peers(uint8_t out[][8], int max); + +/* Stores a verified peer's announced neighbor set; timestamp_ms is the + * announce's own timestamp — only strictly newer claims replace older ones. */ +void bitle_route_note_neighbors(const uint8_t peer_id[8], const uint8_t neighbors[][8], + int count, uint64_t timestamp_ms); + +/* Shortest confirmed path from src to dest. Returns the number of + * intermediate hops written to out_hops (>= 1: attach as a source route), + * 0 when dest is a direct neighbor (no route needed), or -1 when no + * confirmed path exists. */ +int bitle_route_compute(const uint8_t src[8], const uint8_t dest[8], + uint8_t out_hops[][8], int max_hops); + +#ifdef __cplusplus +} +#endif + +#endif // BITLE_ROUTE_H diff --git a/main/main.c b/main/main.c index 109fe2c..4a354ce 100644 --- a/main/main.c +++ b/main/main.c @@ -13,6 +13,7 @@ #include "bitle_lora.h" #include "bitle_mesh.h" #include "bitle_ota.h" +#include "bitle_route.h" #include "bitle_sync.h" #include "noise_handshake.h" #include "packet_codec.h" @@ -71,6 +72,7 @@ void app_main(void) abort(); } + ESP_ERROR_CHECK(bitle_route_init()); ESP_ERROR_CHECK(bitle_link_init()); ESP_ERROR_CHECK(bitle_mesh_init()); /* Radio-optional: probes for an SX1262 and brings the LoRa trunk up diff --git a/main/noise_handshake.c b/main/noise_handshake.c index 072f72b..1723a02 100644 --- a/main/noise_handshake.c +++ b/main/noise_handshake.c @@ -18,6 +18,7 @@ #include "bitchat_time.h" #include "bitle_courier.h" #include "bitle_ota.h" +#include "bitle_route.h" #include "bitle_sync.h" #include "nickname_manager.h" #include "packet_codec.h" @@ -48,6 +49,10 @@ #define NOISE_PACKET_TTL 7 #define NOISE_MAX_ENCRYPTED_PAYLOAD 320 #define ANNOUNCE_INTERVAL_MS (10 * 1000ULL) +/* Upstream rejects announces more than 10 min off our clock; applied here + * only where staleness matters for security (the direct-peer mapping), so a + * node without a synced clock is never locked out of time recovery. */ +#define ANNOUNCE_SKEW_MS (10 * 60 * 1000ULL) #define BITLE_AUTO_REPLY_TEXT \ "This is an automated reply. Bitle is a relay node that extends the " \ @@ -611,7 +616,9 @@ static bool build_announce_payload(uint8_t *buffer, size_t buffer_len, size_t *o nickname_len = 255; } - size_t required = 2 + nickname_len + 2 + sizeof(s_static_public) + 2 + sizeof(s_ed25519_public); + /* Worst case adds a full DIRECT_NEIGHBORS TLV below. */ + size_t required = 2 + nickname_len + 2 + sizeof(s_static_public) + 2 + sizeof(s_ed25519_public) + + 2 + 4 + 2 + 1 + 2 + 8 * BITLE_ROUTE_MAX_NEIGHBORS; if (buffer_len < required) { ESP_LOGW(TAG, "ANNOUNCE payload buffer too small"); return false; @@ -652,6 +659,22 @@ static bool build_announce_payload(uint8_t *buffer, size_t buffer_len, size_t *o buffer[offset++] = 1; buffer[offset++] = 0x01 | (bitchat_time_is_authoritative() ? 0x02 : 0x00); + /* Upstream DIRECT_NEIGHBORS gossip TLV (0x04): our verified direct + * peers as N*8 peer-ID bytes, no count byte, capped at 10. Phones fold + * these into their mesh graph and can then source-route packets through + * us (docs/ANNOUNCEMENT_GOSSIP.md). Being inside the signed announce, + * the neighbor claim is authenticated like the keys above it. */ + uint8_t peers[BITLE_ROUTE_MAX_NEIGHBORS][8]; + int peer_count = bitle_route_direct_peers(peers, BITLE_ROUTE_MAX_NEIGHBORS); + if (peer_count > 0) { + buffer[offset++] = 0x04; + buffer[offset++] = (uint8_t)(peer_count * 8); + for (int i = 0; i < peer_count; ++i) { + memcpy(buffer + offset, peers[i], 8); + offset += 8; + } + } + *out_len = offset; return true; } @@ -662,10 +685,18 @@ static bool build_canonical_packet(const bitchat_packet_t *packet, uint8_t *out_ return false; } - uint8_t header[BITCHAT_BLE_MAX_PACKET_SIZE]; + /* Mirrors upstream BinaryProtocol.encodeForSigning: the full wire + * encoding minus the signature, with TTL forced to zero so relays may + * decrement it. The source route (v2, flag 0x08) is part of the signed + * bytes, so it must be reproduced here exactly; the signature, compress + * and Bitle-private RSR flags stay excluded as before. */ + bool routed = packet->has_route && packet->route_count > 0; + bool v2 = packet->version >= 2 || routed; + + uint8_t header[BITLE_PACKET_MAX_ROUTED]; size_t header_len = 0; - header[header_len++] = packet->version; + header[header_len++] = v2 ? 2 : 1; header[header_len++] = packet->type; header[header_len++] = 0; // TTL forced to zero @@ -677,8 +708,15 @@ static bool build_canonical_packet(const bitchat_packet_t *packet, uint8_t *out_ if (packet->has_recipient) { flags |= 0x01; } + if (routed) { + flags |= 0x08; + } header[header_len++] = flags; + if (v2) { + header[header_len++] = (packet->payload_len >> 24) & 0xFF; + header[header_len++] = (packet->payload_len >> 16) & 0xFF; + } header[header_len++] = (packet->payload_len >> 8) & 0xFF; header[header_len++] = packet->payload_len & 0xFF; @@ -690,6 +728,14 @@ static bool build_canonical_packet(const bitchat_packet_t *packet, uint8_t *out_ header_len += sizeof(packet->recipient_id); } + if (routed) { + header[header_len++] = packet->route_count; + for (uint8_t i = 0; i < packet->route_count; ++i) { + memcpy(header + header_len, packet->route[i], sizeof(packet->route[i])); + header_len += sizeof(packet->route[i]); + } + } + if (header_len + packet->payload_len > max_len) { return false; } @@ -707,7 +753,7 @@ static bool build_canonical_packet(const bitchat_packet_t *packet, uint8_t *out_ static bool sign_packet(bitchat_packet_t *packet) { - uint8_t buffer[BITCHAT_BLE_MAX_PACKET_SIZE]; + uint8_t buffer[BITLE_PACKET_MAX_ROUTED]; size_t canonical_len = 0; if (!build_canonical_packet(packet, buffer, &canonical_len, sizeof(buffer))) { ESP_LOGW(TAG, "Canonical encode failed for type=0x%02X", packet->type); @@ -789,7 +835,7 @@ bool noise_announce_link(uint16_t link_handle) if (!bitchat_time_is_valid()) { return false; } - uint8_t announce_payload[128]; + uint8_t announce_payload[NOISE_MESSAGE_MAX]; size_t announce_len = 0; if (!build_announce_payload(announce_payload, sizeof(announce_payload), &announce_len)) { return false; @@ -824,7 +870,7 @@ static bool send_announce(noise_session_t *session) return false; } - uint8_t announce_payload[128]; + uint8_t announce_payload[NOISE_MESSAGE_MAX]; size_t announce_len = 0; if (!build_announce_payload(announce_payload, sizeof(announce_payload), &announce_len)) { ESP_LOGW(TAG, "Failed to build ANNOUNCE payload"); @@ -1003,6 +1049,36 @@ static bool parse_announce_tlv(const uint8_t *payload, size_t len, noise_identit return have_nick && have_noise && have_sign; } +/* Extracts the upstream DIRECT_NEIGHBORS gossip TLV (0x04): N*8 peer-ID + * bytes, no count byte — N = len/8, trailing partial bytes ignored. Returns + * the entry count (0 when the TLV is absent, which upstream treats as an + * explicit "I have no direct neighbors"). Callers run this only after the + * payload passed parse_announce_tlv, so TLV framing is already known good. */ +static int parse_announce_neighbors(const uint8_t *payload, size_t len, + uint8_t out[][8], int max) +{ + size_t offset = 0; + while (offset + 2 <= len) { + uint8_t tlv_type = payload[offset++]; + uint8_t tlv_len = payload[offset++]; + if (offset + tlv_len > len) { + return 0; + } + if (tlv_type == 0x04) { + int n = tlv_len / 8; + if (n > max) { + n = max; + } + for (int i = 0; i < n; ++i) { + memcpy(out[i], payload + offset + (size_t)i * 8, 8); + } + return n; + } + offset += tlv_len; + } + return 0; +} + /* Returns false when the sender ID is not derived from the announced Noise * key (spoofed announce, hard reject). Sets *out_sig_ok when the packet * signature verifies against the announced signing key. */ @@ -1025,7 +1101,7 @@ static bool verify_announce_event(const noise_event_t *evt, const noise_identity packet.payload = (uint8_t *)evt->payload; packet.payload_len = evt->payload_len; - uint8_t canonical[BITCHAT_BLE_MAX_PACKET_SIZE]; + uint8_t canonical[BITLE_PACKET_MAX_ROUTED]; size_t canonical_len = 0; if (build_canonical_packet(&packet, canonical, &canonical_len, sizeof(canonical))) { *out_sig_ok = ed25519_sign_open(canonical, canonical_len, @@ -1463,6 +1539,29 @@ static void process_announce_event(const noise_event_t *evt) * (direct), flood one copy (relayed owner), or spray carriers. */ bitle_courier_peer_announced(conn_handle, evt->peer_id, ident.noise_key, true, is_direct, bitchat_time_now_ms()); + /* Mesh graph: every verified announce refreshes the sender's + * neighborhood (relayed ones too — that is how topology past our + * direct peers is learned). A direct one additionally pins the + * peer to this link, which is what makes it a usable next hop + * for source-routed forwarding. Mirrors upstream: direct iff the + * announce is verified AND still at its origin TTL. */ + uint8_t neighbors[BITLE_ROUTE_MAX_NEIGHBORS][8]; + int neighbor_count = parse_announce_neighbors(evt->payload, evt->payload_len, + neighbors, BITLE_ROUTE_MAX_NEIGHBORS); + bitle_route_note_neighbors(evt->peer_id, neighbors, neighbor_count, + evt->timestamp_ms); + if (is_direct) { + /* A non-decremented re-broadcast of an old announce (the + * dedup re-admit path makes such copies visible) must not + * pin this peer ID to the attacker's link. */ + uint64_t now_ms = bitchat_time_now_ms(); + bool fresh = !bitchat_time_is_valid() || + (evt->timestamp_ms + ANNOUNCE_SKEW_MS > now_ms && + evt->timestamp_ms < now_ms + ANNOUNCE_SKEW_MS); + if (fresh) { + bitle_route_note_direct(evt->peer_id, conn_handle); + } + } /* Authoritative clock source: a phone (no 0xB1) is the time * authority and may correct even a wrong synced clock; an * authoritative Bitle propagates real time hop-by-hop. Only @@ -1727,6 +1826,43 @@ esp_err_t noise_send_raw(uint16_t conn_handle, bitchat_message_type_t type, cons return encode_and_send(conn_handle, type, recipient, payload, payload_len, false); } +/* Attaches a source route to a directed packet whose recipient is only + * reachable through the mesh, mirroring upstream applyRouteIfAvailable: + * confirmed-edge shortest path, intermediates only, version bumped to 2 (the + * route is then covered by the packet signature). Direct recipients, + * broadcast, link-local (ttl 0) traffic and the Bitle-private OTA types are + * left alone. */ +static void maybe_attach_route(bitchat_packet_t *packet) +{ + if (!packet->has_recipient || packet->ttl == 0) { + return; + } + if (packet->type >= BITLE_MSG_OTA_MANIFEST) { + return; /* 0xA0..0xA3: node-to-node on direct links by design */ + } + bool broadcast = true; + for (size_t i = 0; i < sizeof(packet->recipient_id); ++i) { + if (packet->recipient_id[i] != 0xFF) { + broadcast = false; + break; + } + } + if (broadcast) { + return; + } + if (bitle_route_is_direct(packet->recipient_id)) { + return; /* direct neighbors get no route, like upstream */ + } + uint8_t hops[BITLE_ROUTE_MAX_HOPS][8]; + int hop_count = bitle_route_compute(s_peer_id, packet->recipient_id, hops, BITLE_ROUTE_MAX_HOPS); + if (hop_count >= 1) { + memcpy(packet->route, hops, (size_t)hop_count * 8); + packet->route_count = (uint8_t)hop_count; + packet->has_route = true; + packet->version = 2; + } +} + esp_err_t noise_send_packet(uint16_t conn_handle, bitchat_message_type_t type, const uint8_t recipient[8], const uint8_t *payload, size_t payload_len, uint8_t ttl, bool sign) { bitchat_packet_t packet; @@ -1745,15 +1881,25 @@ esp_err_t noise_send_packet(uint16_t conn_handle, bitchat_message_type_t type, c } packet.payload = (uint8_t *)payload; packet.payload_len = payload_len; + maybe_attach_route(&packet); if (sign && !sign_packet(&packet)) { return ESP_FAIL; } - uint8_t buffer[BITCHAT_BLE_MAX_PACKET_SIZE]; + uint8_t buffer[BITLE_PACKET_MAX_ROUTED]; size_t encoded_len = sizeof(buffer); if (!bitchat_packet_encode(&packet, buffer, &encoded_len, sizeof(buffer))) { return ESP_FAIL; } - return bitle_link_send(conn_handle, buffer, (uint16_t)encoded_len); + /* With a route, transmit only toward the first hop (upstream broadcaster + * stage A); the caller's link is the fallback when it vanished. */ + uint16_t out_link = conn_handle; + if (packet.has_route && packet.route_count > 0) { + uint16_t first = bitle_route_link_for(packet.route[0]); + if (first != BITLE_LINK_NONE) { + out_link = first; + } + } + return bitle_link_send(out_link, buffer, (uint16_t)encoded_len); } bool noise_get_peer_identity(uint16_t conn_handle, uint8_t noise_key[32], uint8_t sign_key[32], bool *verified) @@ -1783,7 +1929,7 @@ bool noise_verify_packet_signature(const bitchat_packet_t *packet, const uint8_t if (!packet || !packet->has_signature) { return false; } - uint8_t canonical[BITCHAT_BLE_MAX_PACKET_SIZE]; + uint8_t canonical[BITLE_PACKET_MAX_ROUTED]; size_t canonical_len = 0; if (!build_canonical_packet(packet, canonical, &canonical_len, sizeof(canonical))) { return false; diff --git a/main/noise_handshake.h b/main/noise_handshake.h index 38fa9f6..8e553ac 100644 --- a/main/noise_handshake.h +++ b/main/noise_handshake.h @@ -6,6 +6,8 @@ #include #include +#include "bitchat_ble.h" + #ifdef __cplusplus extern "C" { #endif @@ -49,6 +51,17 @@ typedef enum { BITCHAT_NOISE_PAYLOAD_VERIFY_RESPONSE = 0x11, } bitchat_noise_payload_type_t; +/* Upstream source routing (docs/SOURCE_ROUTING.md): a v2 packet may carry a + * route — an ordered list of intermediate 8-byte peer IDs, sender and final + * recipient excluded. Relays listed in it forward to the next hop; everyone + * else floods. Longer routes decode as truncated and are flooded, never + * dropped. */ +#define BITLE_ROUTE_MAX_HOPS 8 + +/* Largest packet this node originates: the v1 BLE budget plus the v2 + * length-field growth and a full-size source route. */ +#define BITLE_PACKET_MAX_ROUTED (BITCHAT_BLE_MAX_PACKET_SIZE + 2 + 1 + 8 * BITLE_ROUTE_MAX_HOPS) + typedef struct { uint8_t version; uint8_t type; @@ -59,6 +72,10 @@ typedef struct { bool has_recipient; bool is_compressed; bool is_rsr; /* flag 0x10: solicited sync response replay */ + bool has_route; /* v2 flag 0x08: source route present */ + bool route_truncated; /* wire route longer than BITLE_ROUTE_MAX_HOPS */ + uint8_t route_count; /* valid entries in route */ + uint8_t route[BITLE_ROUTE_MAX_HOPS][8]; uint8_t *payload; uint16_t payload_len; uint8_t signature[64]; diff --git a/main/packet_codec.c b/main/packet_codec.c index 9698f46..1746eec 100644 --- a/main/packet_codec.c +++ b/main/packet_codec.c @@ -66,6 +66,13 @@ static uint64_t read_u64_be(const uint8_t *data, size_t len, size_t *offset, boo return value; } +/* Header flag bits (upstream BinaryProtocol.Flags; 0x10 is Bitle-private). */ +#define FLAG_HAS_RECIPIENT 0x01 +#define FLAG_HAS_SIGNATURE 0x02 +#define FLAG_IS_COMPRESSED 0x04 +#define FLAG_HAS_ROUTE 0x08 +#define FLAG_IS_RSR 0x10 + bool bitchat_packet_decode(const uint8_t *data, size_t len, bitchat_packet_t *out_packet) { if (!data || !out_packet || len == 0) { @@ -80,9 +87,22 @@ bool bitchat_packet_decode(const uint8_t *data, size_t len, bitchat_packet_t *ou out_packet->ttl = read_u8(data, len, &offset, &ok); out_packet->timestamp_ms = read_u64_be(data, len, &offset, &ok); uint8_t flags = read_u8(data, len, &offset, &ok); - uint16_t payload_len = read_u16_be(data, len, &offset, &ok); - if (!ok || out_packet->version != 1) { + /* v1: 2-byte payload length; v2 (source routing): 4-byte payload length. */ + uint32_t payload_len = 0; + if (ok && out_packet->version == 1) { + payload_len = read_u16_be(data, len, &offset, &ok); + } else if (ok && out_packet->version == 2) { + if (offset + 4 > len) { + ok = false; + } else { + payload_len = ((uint32_t)data[offset] << 24) | ((uint32_t)data[offset + 1] << 16) | + ((uint32_t)data[offset + 2] << 8) | data[offset + 3]; + offset += 4; + } + } + + if (!ok || (out_packet->version != 1 && out_packet->version != 2)) { ESP_LOGW(TAG, "Invalid packet header"); return false; } @@ -96,10 +116,10 @@ bool bitchat_packet_decode(const uint8_t *data, size_t len, bitchat_packet_t *ou /* zlib-compressed payload (flag 0x04): we cannot inflate it locally, but * the header and raw bytes stay usable for time sync and relaying. */ - out_packet->is_compressed = (flags & 0x04) != 0; - out_packet->is_rsr = (flags & 0x10) != 0; + out_packet->is_compressed = (flags & FLAG_IS_COMPRESSED) != 0; + out_packet->is_rsr = (flags & FLAG_IS_RSR) != 0; - if (flags & 0x01) { + if (flags & FLAG_HAS_RECIPIENT) { out_packet->has_recipient = true; if (offset + sizeof(out_packet->recipient_id) > len) { ESP_LOGW(TAG, "Missing recipient id bytes"); @@ -109,11 +129,40 @@ bool bitchat_packet_decode(const uint8_t *data, size_t len, bitchat_packet_t *ou offset += 8; } - if (offset + payload_len > len) { + /* Source route (v2+, flag 0x08): 1-byte hop count + N*8 peer IDs, after + * recipient, before payload. Longer routes than we can store are kept + * marked-truncated so the relay floods instead of mis-forwarding. */ + if (out_packet->version >= 2 && (flags & FLAG_HAS_ROUTE)) { + if (offset + 1 > len) { + ESP_LOGW(TAG, "Missing route count byte"); + return false; + } + uint8_t count = data[offset++]; + if (count > 0) { + if (offset + (size_t)count * 8 > len) { + ESP_LOGW(TAG, "Route bytes exceed buffer"); + return false; + } + out_packet->has_route = true; + out_packet->route_count = count < BITLE_ROUTE_MAX_HOPS ? count : BITLE_ROUTE_MAX_HOPS; + out_packet->route_truncated = count > BITLE_ROUTE_MAX_HOPS; + for (uint8_t i = 0; i < out_packet->route_count; ++i) { + memcpy(out_packet->route[i], data + offset + (size_t)i * 8, 8); + } + offset += (size_t)count * 8; + } + } + + if (payload_len > len - offset) { ESP_LOGW(TAG, "Payload length exceeds buffer"); return false; } + if (payload_len > UINT16_MAX) { + ESP_LOGW(TAG, "Payload too large"); + return false; + } + if (payload_len > 0) { out_packet->payload = heap_caps_malloc(payload_len, MALLOC_CAP_8BIT); if (!out_packet->payload) { @@ -122,28 +171,34 @@ bool bitchat_packet_decode(const uint8_t *data, size_t len, bitchat_packet_t *ou } memcpy(out_packet->payload, data + offset, payload_len); } - out_packet->payload_len = payload_len; + out_packet->payload_len = (uint16_t)payload_len; offset += payload_len; - /* Compressed payload (v1): [2-byte original size BE][raw deflate]. On - * success the packet becomes a normal one; on failure it stays marked - * compressed and is handled relay-only. */ - if (out_packet->is_compressed && payload_len > 2) { - uint16_t original_len = ((uint16_t)out_packet->payload[0] << 8) | out_packet->payload[1]; - if (original_len > 0) { - uint8_t *inflated = inflate_payload(out_packet->payload + 2, payload_len - 2, original_len); + /* Compressed payload: [original size BE][raw deflate], 2-byte size in v1, + * 4-byte in v2. On success the packet becomes a normal one; on failure it + * stays marked compressed and is handled relay-only. */ + size_t size_field = out_packet->version >= 2 ? 4 : 2; + if (out_packet->is_compressed && payload_len > size_field) { + uint32_t original_len = 0; + for (size_t i = 0; i < size_field; ++i) { + original_len = (original_len << 8) | out_packet->payload[i]; + } + if (original_len > 0 && original_len <= UINT16_MAX) { + uint8_t *inflated = inflate_payload(out_packet->payload + size_field, + payload_len - size_field, original_len); if (inflated) { heap_caps_free(out_packet->payload); out_packet->payload = inflated; - out_packet->payload_len = original_len; + out_packet->payload_len = (uint16_t)original_len; out_packet->is_compressed = false; } else { - ESP_LOGW(TAG, "Failed to inflate payload (%u -> %u)", payload_len, original_len); + ESP_LOGW(TAG, "Failed to inflate payload (%lu -> %lu)", + (unsigned long)payload_len, (unsigned long)original_len); } } } - if (flags & 0x02) { + if (flags & FLAG_HAS_SIGNATURE) { out_packet->has_signature = true; if (offset + 64 > len) { ESP_LOGW(TAG, "Missing signature bytes"); @@ -164,14 +219,22 @@ bool bitchat_packet_encode(const bitchat_packet_t *packet, uint8_t *out_buf, siz } size_t offset = 0; - size_t header_len = 1 /*version*/ + 1 /*type*/ + 1 /*ttl*/ + 8 /*timestamp*/ + 1 /*flags*/ + 2 /*payload len*/ + 8 /*sender*/ - + (packet->has_recipient ? 8 : 0); + /* A route forces version 2, like upstream applyRouteIfAvailable. */ + bool routed = packet->has_route && packet->route_count > 0; + uint8_t version = routed && packet->version < 2 ? 2 : packet->version; + bool v2 = version >= 2; + uint8_t route_count = routed && v2 ? packet->route_count : 0; + + size_t header_len = 1 /*version*/ + 1 /*type*/ + 1 /*ttl*/ + 8 /*timestamp*/ + 1 /*flags*/ + + (v2 ? 4 : 2) /*payload len*/ + 8 /*sender*/ + + (packet->has_recipient ? 8 : 0) + + (route_count ? 1 + (size_t)route_count * 8 : 0); size_t total_len = header_len + packet->payload_len + (packet->has_signature ? 64 : 0); if (total_len > max_len) { return false; } - out_buf[offset++] = packet->version; + out_buf[offset++] = version; out_buf[offset++] = packet->type; out_buf[offset++] = packet->ttl; @@ -181,16 +244,23 @@ bool bitchat_packet_encode(const bitchat_packet_t *packet, uint8_t *out_buf, siz uint8_t flags = 0; if (packet->has_recipient) { - flags |= 0x01; + flags |= FLAG_HAS_RECIPIENT; } if (packet->has_signature) { - flags |= 0x02; + flags |= FLAG_HAS_SIGNATURE; + } + if (route_count) { + flags |= FLAG_HAS_ROUTE; } if (packet->is_rsr) { - flags |= 0x10; + flags |= FLAG_IS_RSR; } out_buf[offset++] = flags; + if (v2) { + out_buf[offset++] = (packet->payload_len >> 24) & 0xFF; + out_buf[offset++] = (packet->payload_len >> 16) & 0xFF; + } out_buf[offset++] = (packet->payload_len >> 8) & 0xFF; out_buf[offset++] = packet->payload_len & 0xFF; @@ -202,6 +272,14 @@ bool bitchat_packet_encode(const bitchat_packet_t *packet, uint8_t *out_buf, siz offset += 8; } + if (route_count) { + out_buf[offset++] = route_count; + for (uint8_t i = 0; i < route_count; ++i) { + memcpy(out_buf + offset, packet->route[i], 8); + offset += 8; + } + } + if (packet->payload_len > 0) { if (!packet->payload) { return false; @@ -279,6 +357,47 @@ bool packet_codec_self_test(void) memcmp(decoded.signature, packet.signature, sizeof(packet.signature)) == 0; bitchat_packet_free(&decoded); + if (!ok) { + return false; + } + + /* v2 source-route round trip: route survives encode/decode byte-exact. */ + bitchat_packet_t routed = {0}; + routed.version = 2; + routed.type = BITCHAT_MSG_NOISE_ENCRYPTED; + routed.ttl = 5; + routed.timestamp_ms = packet.timestamp_ms; + memcpy(routed.sender_id, packet.sender_id, sizeof(routed.sender_id)); + memcpy(routed.recipient_id, recipient, sizeof(routed.recipient_id)); + routed.has_recipient = true; + routed.has_route = true; + routed.route_count = 2; + memcpy(routed.route[0], (uint8_t[]){0x30,0x31,0x32,0x33,0x34,0x35,0x36,0x37}, 8); + memcpy(routed.route[1], (uint8_t[]){0x40,0x41,0x42,0x43,0x44,0x45,0x46,0x47}, 8); + routed.payload = (uint8_t *)payload; + routed.payload_len = sizeof(payload); + memcpy(routed.signature, signature, sizeof(routed.signature)); + routed.has_signature = true; + + encoded_len = sizeof(encoded); + if (!bitchat_packet_encode(&routed, encoded, &encoded_len, sizeof(encoded))) { + return false; + } + bitchat_packet_t decoded_routed; + if (!bitchat_packet_decode(encoded, encoded_len, &decoded_routed)) { + return false; + } + ok = decoded_routed.version == 2 && + decoded_routed.has_route && !decoded_routed.route_truncated && + decoded_routed.route_count == 2 && + memcmp(decoded_routed.route[0], routed.route[0], 8) == 0 && + memcmp(decoded_routed.route[1], routed.route[1], 8) == 0 && + decoded_routed.ttl == routed.ttl && + decoded_routed.payload_len == routed.payload_len && + memcmp(decoded_routed.payload, routed.payload, routed.payload_len) == 0 && + decoded_routed.has_signature && + memcmp(decoded_routed.signature, routed.signature, 64) == 0; + bitchat_packet_free(&decoded_routed); return ok; }