diff --git a/docs/cli_commands.md b/docs/cli_commands.md index 5df0c5df2..bb7fab9c6 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -1191,6 +1191,10 @@ capacity. Remote use follows the existing administrator-command permissions. **Default:** `869.618,62.5,8,5` +`get radio` returns exactly four comma-separated fields for compatibility with +existing Companion apps. On dual-radio builds, use `get radio2` and +`get radio.timing` for profile and timing diagnostics. + **Note:** Requires reboot to apply. If RXPS is enabled and the saved minimum level/preamble cannot safely cover the new radio timing, the command reply reports the effective level and preamble, or `RXPS continuous-fast` when no diff --git a/docs/companion_contact_cache.md b/docs/companion_contact_cache.md index 9ba586f84..54b7aadc9 100644 --- a/docs/companion_contact_cache.md +++ b/docs/companion_contact_cache.md @@ -1,6 +1,7 @@ -# Full Companion contact caches +# Companion contact caches -Full Companions on ESP32 and nRF52 without PSRAM now keep 16 outgoing paths +Full Companions on ESP32 and nRF52 without PSRAM, plus the high-contact RAK4631 +USB/BLE profiles, keep 16 outgoing paths and 16 shared secrets in RAM. Every contact remains in the contact table; selecting a different contact loads its saved path as needed. This recovers about 25.4 KiB of internal RAM in the 350-contact ESP32 qualification builds. @@ -9,8 +10,10 @@ See the [qualification results](companion_contact_cache_results.md) for the six-board RAM comparison, physical tests, and shared-secret timings. PSRAM boards retain their existing complete contact table in external RAM. -Legacy USB/BLE/WiFi Companion profiles also retain their inline paths and -secrets. The build flag `MESH_CONTACT_CACHE=0` or `1` overrides this policy for +Most legacy USB/BLE/WiFi Companion profiles retain inline paths and secrets. +The high-contact RAK4631 USB/BLE profiles also enable the same cache, +keeping all 350 contacts while making room for the shared 256-frame offline/DM +pool. The build flag `MESH_CONTACT_CACHE=0` or `1` overrides this policy for qualification; it is not a runtime feature switch. ## Paths and persistence diff --git a/docs/companion_offline_queue.md b/docs/companion_offline_queue.md index e003a0d1e..2a480d7e9 100644 --- a/docs/companion_offline_queue.md +++ b/docs/companion_offline_queue.md @@ -11,10 +11,12 @@ A reboot clears it. | --- | ---: | | ESP32 with configured PSRAM | 512 | | ESP32 without PSRAM | 256 | -| nRF52840 | 256 | +| nRF52840 with at most 300 contact slots | 256 | +| nRF52840 with more than 300 contacts and the contact cache (non-mOTA-shared queue) | 256 total: up to 15 held DMs use slots until accepted | +| nRF52840 with more than 300 contacts but no contact cache | 208 ordinary frames plus a separate 15-DM queue | | nRF52 Full Companion with the memory correction | 256 normally; 128 while mOTA owns shared storage | | RP2040 | 256 | -| STM32 | 16 | +| STM32 | 16 (one-key DM holding is omitted on flash-constrained STM32WL builds) | | Known constrained classic ESP32 target override | 128 | | Meshadventurer Full Companion | 16 | | Constrained Full ESP32 fallback | 16 | @@ -44,7 +46,15 @@ Full profiles require at least 72 KiB of heap space at link time; OLED and headless profiles use their own [memory budgets](firmware_memory_budget.md). Standard, logging, MQTT, and Cascade build overlays retain the selected target -capacity; they do not silently shrink the queue. +capacity. On cache-enabled high-contact nRF52 Companions, including the RAK4631 +USB and BLE profiles, ordinary messages and consent-pending DMs share one +256-frame RAM pool. With no held DMs, all 256 slots are available to ordinary +messages; with 15 held DMs, up to 241 are. The app sees only ordinary messages +until a sender is accepted. The oldest held DM rolls off when a sixteenth +decryptable DM arrives. The shared pool is still volatile and clears on reboot. +Uncached high-contact profiles keep the conservative 208-frame default and +separate held queue. An explicit target `OFFLINE_QUEUE_SIZE` still takes +precedence. Each queue slot currently costs 177 bytes. A 256-frame queue reserves 45,312 bytes, while a 512-frame queue reserves 90,624 bytes. There is no 256-frame diff --git a/docs/companion_protocol.md b/docs/companion_protocol.md index b28e1ddfb..2c293cf2b 100644 --- a/docs/companion_protocol.md +++ b/docs/companion_protocol.md @@ -242,6 +242,51 @@ catalog; bytes `0x2C`-`0x31` are parked and `0x35` is unused. The sections below detail the most common frames. Refer to the source named above for command bodies that are not expanded here. +### Private text when only the sender knows the recipient + +Clients continue to send an ordinary `CMD_SEND_TXT_MSG` (`0x02`) with +`TXT_TYPE_PLAIN`; no client protocol change is needed. For a chat contact +without a recent confirmed DM, Companion firmware first sends a signed, +encrypted [one-key introduction](payloads.md#companion-one-key-dm-introduction) +that includes its full public key, then queues the normal private text after +the introduction. It sends the introduction again on an application retry, +which recovers if the recipient removed the sender contact. An ACK confirms +that the recipient can decrypt normal DMs, so subsequent first attempts omit +the extra packet until the sender reboots or the small recent-ACK cache is +replaced. + +The receiving Companion verifies the introduction signature. By default, +`dm.one_key` is `off`: the sender is offered to the app as a synthetic +`PUSH_CODE_NEW_ADVERT` (`0x8A`) but is not added as a contact. The app/user +accepts through the normal add-contact command. Up to 15 verified, decryptable +plain DMs are held in RAM and delivered after acceptance; the oldest rolls +off first. The queue is lost on reboot. Cache-enabled high-contact nRF52 +Companions share a 256-frame pool between ordinary offline messages and up to +15 held DMs, so ordinary capacity is 256 when none are held and 241 when all +15 are held. The held frames are not exposed by ordinary message sync before +acceptance. Uncached high-contact nRF52 builds retain 208 ordinary frames and +a separate held queue. The device sends a signed `DMR1` +refusal, and the sender pushes `0x91` plus the full rejecting key to a +connected app, then stops further introductions for that peer until reboot. +Directly attached clients may use `get dm.one_key` or `set dm.one_key on|off`; +`get dm.held` reports the number of held DMs. `on` auto-accepts verified senders. +The flash-constrained STM32WL Companion builds omit one-key DM support to +preserve their existing filesystem boundary; these commands report +`Error: one-key DMs unsupported on this build` there. Ordinary contact-based +private messages remain available. +A recipient running older firmware still +needs the sender contact before it can decrypt a normal DM. This flow uses +a second radio packet for the initial DM; `RESP_CODE_SENT` includes that +scheduling delay in its timeout. + +For a two-radio regression test, run `tools/hil/one_key_dm.py` with the USB +serial paths for `--sender` and `--recipient`. `--reset-contact` removes the +sender from recipient contacts, and `--zero-hop` sets the sender's route to +zero hop when the devices are nearby. The test checks the synthetic advert, +refusal, manual acceptance, held-message delivery, and matching ACK. +`--invalid-signature-first` also checks that a decryptable introduction with +an invalid Ed25519 signature does not add a contact. + `CMD_RUN_CLI_COMMAND` is followed by the local CLI text without a terminating NUL. The device returns `RESP_CODE_CLI_REPLY` (`0x1D`) followed by the reply text. This is separate from sending a remote on-air CLI command with @@ -948,13 +993,14 @@ Byte values are authoritative; names are aliases. When reading firmware source, | `0x87` | `PUSH_CODE_STATUS_RESPONSE` | Server status response. | | `0x88` | `PUSH_CODE_LOG_RX_DATA` | Radio receive log data. | | `0x89` | `PUSH_CODE_TRACE_DATA` | Completed trace data. | -| `0x8A` | `PUSH_CODE_NEW_ADVERT` | Newly stored contact advertisement. | +| `0x8A` | `PUSH_CODE_NEW_ADVERT` | New advert; a verified one-key DM may use this as an addable synthetic advert without storing the contact. | | `0x8B` | `PUSH_CODE_TELEMETRY_RESPONSE` | Telemetry response. | | `0x8C` | `PUSH_CODE_BINARY_RESPONSE` | Binary request response. | | `0x8D` | `PUSH_CODE_PATH_DISCOVERY_RESPONSE` | Path-discovery response. | | `0x8E` | `PUSH_CODE_CONTROL_DATA` | Control/discovery data. | | `0x8F` | `PUSH_CODE_CONTACT_DELETED` | Oldest contact was deleted while making room. | | `0x90` | `PUSH_CODE_CONTACTS_FULL` | Contact storage is full. | +| `0x91` | `PUSH_CODE_ONE_KEY_DM_REJECTED` | One-key DM refused; bytes 1–32 contain the refusing contact's full public key. | ### Parsing Responses diff --git a/docs/payloads.md b/docs/payloads.md index 1db21eaff..8649f401f 100644 --- a/docs/payloads.md +++ b/docs/payloads.md @@ -231,6 +231,36 @@ same-timestamp mismatches are rejected. | cipher MAC | 2 | MAC for encrypted data in next field | | ciphertext | rest of payload | encrypted message, see below for details | +### Companion one-key DM introduction + +A Companion may send this request immediately before a normal private text +packet when it has not received an ACK from that chat contact, and on each +application retry. It lets the recipient learn the sender's full public key +without a broadcast advert. The ciphertext contains: + +| Field | Size (bytes) | Description | +|---|---:|---| +| tag | 4 | unique sender timestamp | +| marker | 4 | ASCII `DMK1` | +| name | 1–32 | sender name followed by NUL | +| signature | 64 | Ed25519 signature by the sender | + +The signed bytes are the recipient's full public key followed by the tag, +marker, and NUL-terminated name. The recipient verifies that signature against +the 32-byte sender key in the anonymous request header before offering a chat +contact for manual acceptance, or auto-adding it if `dm.one_key` is enabled. +A receiver that does not implement `DMK1` ignores the introduction; +the following normal text packet still works when it already has the sender's +key. The recipient's manual advert auto-add setting does not suppress the +synthetic advert for a verified, addressed `DMK1` introduction. The sender's +full public key remains +visible in the radio packet header, as with other anonymous requests. + +With `dm.one_key` off, the recipient replies with an encrypted `DMR1` response: +the original 4-byte tag, ASCII `DMR1`, then its 64-byte Ed25519 signature +over the sender's full key followed by that tag and marker. Only a matching +pending introduction and verified signature stops repeat introductions. + ### Room server login | Field | Size (bytes) | Description | diff --git a/examples/companion_radio/DataStore.cpp b/examples/companion_radio/DataStore.cpp index c999da944..dac7b0572 100644 --- a/examples/companion_radio/DataStore.cpp +++ b/examples/companion_radio/DataStore.cpp @@ -1039,6 +1039,13 @@ bool DataStore::loadPrefsInt(const char *filename, #endif #if defined(RP2040_PLATFORM) && defined(ENABLE_WIFI_INTERFACE) 327, +#endif + 231, // One-key DM consent policy, appended after flood retry controls +#ifdef TBEAM_1W + 238, +#endif +#if defined(RP2040_PLATFORM) && defined(ENABLE_WIFI_INTERFACE) + 328, #endif }; const uint32_t prefs_size = file.size(); @@ -1179,6 +1186,8 @@ bool DataStore::loadPrefsInt(const char *filename, sizeof(loaded_prefs.flood_retry_group_max_path)); readOptionalField(&loaded_prefs.flood_retry_advert_enabled, sizeof(loaded_prefs.flood_retry_advert_enabled)); + readOptionalField(&loaded_prefs.one_key_dm_enabled, + sizeof(loaded_prefs.one_key_dm_enabled)); // Any bytes left over form only part of a historically appended field. // Preserve the file and defaults rather than treating that tail as EOF. @@ -1337,6 +1346,8 @@ bool DataStore::savePrefs(const CompanionNodePrefs& _prefs, double node_lat, dou sizeof(_prefs.flood_retry_group_max_path)) == sizeof(_prefs.flood_retry_group_max_path); success = success && file.write((uint8_t *)&_prefs.flood_retry_advert_enabled, sizeof(_prefs.flood_retry_advert_enabled)) == sizeof(_prefs.flood_retry_advert_enabled); + success = success && file.write((uint8_t *)&_prefs.one_key_dm_enabled, + sizeof(_prefs.one_key_dm_enabled)) == sizeof(_prefs.one_key_dm_enabled); #if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) || defined(ESP32_PLATFORM) || defined(RP2040_PLATFORM) success = file.commit(success); diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index 37b3dd030..66fece1fa 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -217,6 +217,14 @@ static constexpr uint8_t DEFAULT_FEM_RX_GAIN = 1; #define CONTACT_PAGE_WRITE_GAP 100 #define EXPECTED_ACK_RETRY_RECHECK_MILLIS 1000 +// The encrypted body of an ANON_REQ starts with its usual four-byte tag. +// A distinct marker after that tag prevents a contact introduction from being +// mistaken for a login or another anonymous request. +#if MESH_ENABLE_ONE_KEY_DM +static constexpr uint8_t ONE_KEY_INTRO_MARKER[] = {'D', 'M', 'K', '1'}; +static constexpr uint8_t ONE_KEY_REJECT_MARKER[] = {'D', 'M', 'R', '1'}; +#endif + static bool save_filter(const ContactInfo& c); #ifndef DEFAULT_MULTI_ACKS @@ -291,6 +299,7 @@ static mesh::Packet* emergency_client_repeat_packet; #define PUSH_CODE_CONTROL_DATA 0x8E // v8+ #define PUSH_CODE_CONTACT_DELETED 0x8F // used to notify client app of deleted contact when overwriting oldest #define PUSH_CODE_CONTACTS_FULL 0x90 // used to notify client app that contacts storage is full +#define PUSH_CODE_ONE_KEY_DM_REJECTED 0x91 // full sender key follows #define ERR_CODE_UNSUPPORTED_CMD 1 #define ERR_CODE_NOT_FOUND 2 @@ -452,11 +461,27 @@ int MyMesh::getOfflineQueueCapacity() const { MyMesh::Frame& MyMesh::offlineQueueFrameAt(int logical_index) { #if defined(OTA_SHARED_COMPANION_QUEUE) return offline_queue.at((offline_queue_head + logical_index) % getOfflineQueueCapacity()); +#elif ONE_KEY_DM_SHARED_OFFLINE_QUEUE + return offline_queue[logical_index]; #else return offline_queue[(offline_queue_head + logical_index) % getOfflineQueueCapacity()]; #endif } +#if ONE_KEY_DM_SHARED_OFFLINE_QUEUE +MyMesh::Frame& MyMesh::heldDMFrameAt(uint8_t index) { + return offline_queue[getOfflineQueueCapacity() - 1 - index]; +} + +void MyMesh::removeHeldOneKeyDM(uint8_t index) { + for (uint8_t j = index + 1; j < held_dm_count; ++j) { + held_dms[j - 1] = held_dms[j]; + heldDMFrameAt(j - 1) = heldDMFrameAt(j); + } + --held_dm_count; +} +#endif + #if defined(OTA_SHARED_COMPANION_QUEUE) mesh::ota::OtaContext* MyMesh::acquireOfflineQueueForOta(void* owner) { MyMesh* mesh = static_cast(owner); @@ -511,7 +536,10 @@ void MyMesh::initializeOfflineQueue() { } bool MyMesh::addToOfflineQueue(const uint8_t frame[], int len) { - const int capacity = getOfflineQueueCapacity(); + int capacity = getOfflineQueueCapacity(); +#if ONE_KEY_DM_SHARED_OFFLINE_QUEUE + capacity -= held_dm_count; +#endif if (!frame || len <= 0 || len > MAX_FRAME_SIZE || capacity <= 0) { MESH_DEBUG_PRINTLN("WARN: invalid offline queue frame length: %d", len); return false; @@ -557,11 +585,17 @@ int MyMesh::getFromOfflineQueue(uint8_t frame[]) { memcpy(frame, head.buf, len); offline_queue_len--; +#if ONE_KEY_DM_SHARED_OFFLINE_QUEUE + for (int i = 0; i < offline_queue_len; ++i) { + offlineQueueFrameAt(i) = offlineQueueFrameAt(i + 1); + } +#else if (offline_queue_len == 0) { offline_queue_head = 0; } else { offline_queue_head = (offline_queue_head + 1) % getOfflineQueueCapacity(); } +#endif #ifdef DISPLAY_CLASS if (_ui) _ui->syncMessageQueue(offline_queue_len, 0); #endif @@ -990,6 +1024,9 @@ ContactInfo* MyMesh::processAck(const uint8_t *data) { // NOTE: the same ACK can be received multiple times! ContactInfo* contact = expected_ack_table[i].contact; +#if MESH_ENABLE_ONE_KEY_DM + if (contact != NULL && contact->type == ADV_TYPE_CHAT) rememberOneKeyAck(*contact); +#endif clearExpectedAck(expected_ack_table[i]); expireExpectedAcks(); return contact; @@ -998,6 +1035,375 @@ ContactInfo* MyMesh::processAck(const uint8_t *data) { return checkConnectionsAck(data); } +#if MESH_ENABLE_ONE_KEY_DM +bool MyMesh::hasOneKeyAck(const ContactInfo& contact) const { + for (uint8_t i = 0; i < one_key_peer_count; ++i) { + if (one_key_peers[i].status == 1 && + memcmp(one_key_peers[i].pub_key, contact.id.pub_key, PUB_KEY_SIZE) == 0) return true; + } + return false; +} + +bool MyMesh::hasOneKeyReject(const ContactInfo& contact) const { + for (uint8_t i = 0; i < one_key_peer_count; ++i) { + if (one_key_peers[i].status == 2 && + memcmp(one_key_peers[i].pub_key, contact.id.pub_key, PUB_KEY_SIZE) == 0) return true; + } + return false; +} + +void MyMesh::rememberOneKeyIntro(const ContactInfo& contact, uint32_t tag) { + for (uint8_t i = 0; i < one_key_peer_count; ++i) { + if (memcmp(one_key_peers[i].pub_key, contact.id.pub_key, PUB_KEY_SIZE) == 0) { + one_key_peers[i].intro_tag = tag; + one_key_peers[i].status = 0; + return; + } + } + OneKeyPeerState& peer = one_key_peers[one_key_peer_next]; + memcpy(peer.pub_key, contact.id.pub_key, PUB_KEY_SIZE); + peer.intro_tag = tag; + peer.status = 0; + if (one_key_peer_count < ONE_KEY_PEERS) ++one_key_peer_count; + one_key_peer_next = (one_key_peer_next + 1) % ONE_KEY_PEERS; +} + +void MyMesh::rememberOneKeyAck(const ContactInfo& contact) { + if (hasOneKeyAck(contact)) return; + for (uint8_t i = 0; i < one_key_peer_count; ++i) { + if (memcmp(one_key_peers[i].pub_key, contact.id.pub_key, PUB_KEY_SIZE) == 0) { + one_key_peers[i].status = 1; + return; + } + } + OneKeyPeerState& peer = one_key_peers[one_key_peer_next]; + memcpy(peer.pub_key, contact.id.pub_key, PUB_KEY_SIZE); + peer.intro_tag = 0; + peer.status = 1; + if (one_key_peer_count < ONE_KEY_PEERS) ++one_key_peer_count; + one_key_peer_next = (one_key_peer_next + 1) % ONE_KEY_PEERS; +} + +bool MyMesh::rememberOneKeyReject(const ContactInfo& contact, uint32_t tag) { + for (uint8_t i = 0; i < one_key_peer_count; ++i) { + OneKeyPeerState& peer = one_key_peers[i]; + if (peer.status == 0 && peer.intro_tag == tag && + memcmp(peer.pub_key, contact.id.pub_key, PUB_KEY_SIZE) == 0) { + peer.status = 2; + return true; + } + } + return false; +} + +uint32_t MyMesh::sendOneKeyIntroduction(const ContactInfo& contact) { + // ANON_REQ carries the sender's full key in its outer header. It therefore + // reaches a recipient that cannot yet decrypt a normal text packet, while + // old Companion firmware simply ignores this unfamiliar request body. + uint8_t body[4 + sizeof(ONE_KEY_INTRO_MARKER) + 32 + SIGNATURE_SIZE]; + const uint32_t tag = getRTCClock()->getCurrentTimeUnique(); + memcpy(body, &tag, sizeof(tag)); + memcpy(body + 4, ONE_KEY_INTRO_MARKER, sizeof(ONE_KEY_INTRO_MARKER)); + char fallback_name[20]; + snprintf(fallback_name, sizeof(fallback_name), "Unknown %02X%02X%02X", + self_id.pub_key[0], self_id.pub_key[1], self_id.pub_key[2]); + const char* name = _prefs.node_name[0] ? _prefs.node_name : fallback_name; + const size_t name_len = strnlen(name, 31); + memcpy(body + 4 + sizeof(ONE_KEY_INTRO_MARKER), name, name_len); + body[4 + sizeof(ONE_KEY_INTRO_MARKER) + name_len] = 0; + const size_t signed_body_len = 4 + sizeof(ONE_KEY_INTRO_MARKER) + name_len + 1; + uint8_t signed_message[PUB_KEY_SIZE + 4 + sizeof(ONE_KEY_INTRO_MARKER) + 32]; + memcpy(signed_message, contact.id.pub_key, PUB_KEY_SIZE); + memcpy(signed_message + PUB_KEY_SIZE, body, signed_body_len); + self_id.sign(body + signed_body_len, signed_message, + PUB_KEY_SIZE + signed_body_len); + + mesh::Packet* intro = createAnonDatagram( + PAYLOAD_TYPE_ANON_REQ, self_id, contact.id, + contact.getSharedSecret(self_id), body, + signed_body_len + SIGNATURE_SIZE); + if (intro == NULL) return 0; + const uint32_t airtime = getTransmitAirtime(intro); + uint32_t delay; + bool sent; + if (contact.out_path_len == OUT_PATH_UNKNOWN) { + delay = calcFloodTimeoutMillisFor(airtime) / 2; + sent = sendFloodScoped(contact, intro); + } else { + const uint32_t hops = (contact.out_path_len & 63) + 1; + delay = (airtime * 3 + 500) * hops; + sent = sendDirect(intro, contact.getPath(), contact.out_path_len); + } + // Scheduling the text after the introduction also lets the ordinary text + // path work on older recipients which already know this sender. + if (sent) rememberOneKeyIntro(contact, tag); + return sent ? delay : 0; +} + +void MyMesh::onAnonDataRecv(mesh::Packet* packet, const uint8_t* secret, + const mesh::Identity& sender, uint8_t* data, + size_t len) { + if (packet->getPayloadType() != PAYLOAD_TYPE_ANON_REQ + || len < 4 + sizeof(ONE_KEY_INTRO_MARKER) + 2 + SIGNATURE_SIZE + || memcmp(data + 4, ONE_KEY_INTRO_MARKER, + sizeof(ONE_KEY_INTRO_MARKER)) != 0) return; + + const char* name = reinterpret_cast(data + 4 + sizeof(ONE_KEY_INTRO_MARKER)); + const size_t available = min(size_t(32), len - 4 - sizeof(ONE_KEY_INTRO_MARKER) - SIGNATURE_SIZE); + const char* end = static_cast(memchr(name, 0, available)); + if (end == NULL || end == name || size_t(end - name) >= sizeof(ContactInfo::name)) return; + const size_t signed_body_len = 4 + sizeof(ONE_KEY_INTRO_MARKER) + size_t(end - name) + 1; + if (signed_body_len + SIGNATURE_SIZE > len) return; + uint8_t signed_message[PUB_KEY_SIZE + 4 + sizeof(ONE_KEY_INTRO_MARKER) + 32]; + memcpy(signed_message, self_id.pub_key, PUB_KEY_SIZE); + memcpy(signed_message + PUB_KEY_SIZE, data, signed_body_len); + if (!sender.verify(data + signed_body_len, signed_message, + PUB_KEY_SIZE + signed_body_len)) return; + if (lookupPersistentContactByPubKey(sender.pub_key, PUB_KEY_SIZE) != NULL) return; + + ContactInfo contact = {}; + memcpy(contact.id.pub_key, sender.pub_key, PUB_KEY_SIZE); + StrHelper::strncpy(contact.name, name, sizeof(contact.name)); + contact.type = ADV_TYPE_CHAT; + contact.out_path_len = OUT_PATH_UNKNOWN; + contact.lastmod = getRTCClock()->getCurrentTime(); + if (_prefs.one_key_dm_enabled != 1) { + rememberVerifiedPendingSender(sender.pub_key); + // Synthetic advert for the attached app's normal manual-add flow. It is + // never added to the radio peer table until the user accepts it. + onDiscoveredContact(contact, true, packet->path_len, packet->path); + + uint8_t refusal[4 + sizeof(ONE_KEY_REJECT_MARKER) + SIGNATURE_SIZE]; + memcpy(refusal, data, 4); + memcpy(refusal + 4, ONE_KEY_REJECT_MARKER, sizeof(ONE_KEY_REJECT_MARKER)); + uint8_t signed_refusal[PUB_KEY_SIZE + 4 + sizeof(ONE_KEY_REJECT_MARKER)]; + memcpy(signed_refusal, sender.pub_key, PUB_KEY_SIZE); + memcpy(signed_refusal + PUB_KEY_SIZE, refusal, 4 + sizeof(ONE_KEY_REJECT_MARKER)); + self_id.sign(refusal + 4 + sizeof(ONE_KEY_REJECT_MARKER), + signed_refusal, sizeof(signed_refusal)); + mesh::Packet* reply = createDatagram(PAYLOAD_TYPE_RESPONSE, sender, secret, + refusal, sizeof(refusal)); + if (reply != NULL) sendFloodScoped(contact, reply, 300); + return; + } + if (!canMutateContacts()) return; + if (!addContact(contact)) { + onContactsFull(); + return; + } + ContactInfo* added = lookupPersistentContactByPubKey(sender.pub_key, PUB_KEY_SIZE); + if (added == NULL || !scheduleContactWrite(*added)) { + if (added != NULL) removeContact(*added); + return; + } + // A prior anonymous service request may occupy a transient slot for this + // key. Remove it so regular text lookup finds the durable contact first. + ContactInfo* transient = lookupTransientContactByPubKey(sender.pub_key, PUB_KEY_SIZE); + if (transient != NULL) clearTransientContact(*transient); + onDiscoveredContact(*added, true, OUT_PATH_UNKNOWN, NULL); +} + +void MyMesh::rememberVerifiedPendingSender(const uint8_t* pub_key) { + for (uint8_t i = 0; i < verified_pending_count; ++i) { + if (memcmp(verified_pending_keys[i], pub_key, PUB_KEY_SIZE) == 0) return; + } + if (verified_pending_count == MAX_HELD_ONE_KEY_DMS) { + for (uint8_t i = 1; i < verified_pending_count; ++i) { + memcpy(verified_pending_keys[i - 1], verified_pending_keys[i], PUB_KEY_SIZE); + } + --verified_pending_count; + } + memcpy(verified_pending_keys[verified_pending_count++], pub_key, PUB_KEY_SIZE); +} + +void MyMesh::forgetVerifiedPendingSender(const uint8_t* pub_key) { + for (uint8_t i = 0; i < verified_pending_count; ++i) { + if (memcmp(verified_pending_keys[i], pub_key, PUB_KEY_SIZE) == 0) { + for (uint8_t j = i + 1; j < verified_pending_count; ++j) { + memcpy(verified_pending_keys[j - 1], verified_pending_keys[j], PUB_KEY_SIZE); + } + --verified_pending_count; + return; + } + } +} + +void MyMesh::makeOneKeyDMId(uint8_t id[ONE_KEY_DM_ID_SIZE], uint32_t timestamp, + const char* text) { + mesh::Utils::sha256(id, ONE_KEY_DM_ID_SIZE, + reinterpret_cast(×tamp), sizeof(timestamp), + reinterpret_cast(text), strlen(text)); +} + +bool MyMesh::wasDeliveredOneKeyDM(const uint8_t* pub_key, + const uint8_t id[ONE_KEY_DM_ID_SIZE]) const { + for (uint8_t i = 0; i < delivered_dm_count; ++i) { + if (memcmp(delivered_dms[i].sender_key, pub_key, PUB_KEY_SIZE) == 0 && + memcmp(delivered_dms[i].id, id, ONE_KEY_DM_ID_SIZE) == 0) return true; + } + return false; +} + +void MyMesh::rememberDeliveredOneKeyDM(const uint8_t* pub_key, + const uint8_t id[ONE_KEY_DM_ID_SIZE]) { + if (wasDeliveredOneKeyDM(pub_key, id)) return; + DeliveredOneKeyDM& entry = delivered_dms[delivered_dm_next]; + memcpy(entry.sender_key, pub_key, PUB_KEY_SIZE); + memcpy(entry.id, id, ONE_KEY_DM_ID_SIZE); + if (delivered_dm_count < MAX_HELD_ONE_KEY_DMS) ++delivered_dm_count; + delivered_dm_next = (delivered_dm_next + 1) % MAX_HELD_ONE_KEY_DMS; +} + +bool MyMesh::onAddressedTextPacket(mesh::Packet* packet, uint8_t src_hash, + const uint8_t* mac_and_data, size_t len) { + for (uint8_t i = 0; i < verified_pending_count; ++i) { + const uint8_t* pub_key = verified_pending_keys[i]; + if (pub_key[0] != src_hash) continue; + if (lookupPersistentContactByPubKey(pub_key, PUB_KEY_SIZE) != NULL) continue; + + uint8_t secret[PUB_KEY_SIZE]; + self_id.calcSharedSecret(secret, pub_key); + uint8_t data[MAX_PACKET_PAYLOAD + 1]; + const int decoded_len = mesh::Utils::MACThenDecrypt(secret, data, mac_and_data, len); + if (decoded_len <= 5 || decoded_len > MAX_PACKET_PAYLOAD) continue; + data[decoded_len] = 0; + if ((data[4] >> 2) != TXT_TYPE_PLAIN) continue; + const char* text = reinterpret_cast(&data[5]); + if (strlen(text) > MAX_TEXT_LEN) continue; + + uint32_t timestamp; + memcpy(×tamp, data, sizeof(timestamp)); + uint8_t id[ONE_KEY_DM_ID_SIZE]; + makeOneKeyDMId(id, timestamp, text); + for (uint8_t j = 0; j < held_dm_count; ++j) { + if (memcmp(held_dms[j].sender_key, pub_key, PUB_KEY_SIZE) == 0 && + memcmp(held_dms[j].id, id, ONE_KEY_DM_ID_SIZE) == 0) return true; + } + + // Only verified and decryptable messages count toward the FIFO of 15. +#if ONE_KEY_DM_SHARED_OFFLINE_QUEUE + if (held_dm_count == MAX_HELD_ONE_KEY_DMS) removeHeldOneKeyDM(0); + if (offline_queue_len + held_dm_count >= getOfflineQueueCapacity()) { + // Preserve private messages already waiting for the app. As with the + // ordinary queue, an old channel message may be displaced instead. + int pos = 0; + while (pos < offline_queue_len && !offlineQueueFrameAt(pos).isChannelMsg()) ++pos; + if (pos == offline_queue_len) return false; + for (int j = pos; j < offline_queue_len - 1; ++j) { + offlineQueueFrameAt(j) = offlineQueueFrameAt(j + 1); + } + --offline_queue_len; +#ifdef DISPLAY_CLASS + if (_ui) _ui->syncMessageQueue(offline_queue_len, pos); +#endif + } + HeldOneKeyDM& entry = held_dms[held_dm_count]; + memcpy(entry.sender_key, pub_key, PUB_KEY_SIZE); + memcpy(entry.id, id, ONE_KEY_DM_ID_SIZE); + Frame& held = heldDMFrameAt(held_dm_count++); + memcpy(held.buf, ×tamp, sizeof(timestamp)); + mesh::Utils::sha256(&held.buf[4], 4, data, 5 + strlen(text), + pub_key, PUB_KEY_SIZE); + held.buf[8] = packet->isRouteFlood() ? packet->path_len : 0xFF; + held.buf[9] = packet->_snr; + const uint8_t text_len = strlen(text); + held.buf[10] = text_len; + memcpy(&held.buf[11], text, text_len + 1); + held.len = 12 + text_len; +#else + if (held_dm_count == MAX_HELD_ONE_KEY_DMS) { + for (uint8_t j = 1; j < held_dm_count; ++j) held_dms[j - 1] = held_dms[j]; + --held_dm_count; + } + HeldOneKeyDM& entry = held_dms[held_dm_count++]; + memcpy(entry.sender_key, pub_key, PUB_KEY_SIZE); + memcpy(entry.id, id, ONE_KEY_DM_ID_SIZE); + entry.packet = *packet; +#endif + return true; + } + return false; +} + +void MyMesh::releaseHeldOneKeyDMs() { + for (uint8_t i = 0; i < verified_pending_count; ) { + if (lookupPersistentContactByPubKey(verified_pending_keys[i], PUB_KEY_SIZE) != NULL) { + uint8_t key[PUB_KEY_SIZE]; + memcpy(key, verified_pending_keys[i], PUB_KEY_SIZE); + forgetVerifiedPendingSender(key); + } else { + ++i; + } + } + + for (uint8_t i = 0; i < held_dm_count; ) { + HeldOneKeyDM& entry = held_dms[i]; + ContactInfo* contact = lookupPersistentContactByPubKey(entry.sender_key, PUB_KEY_SIZE); + if (contact == NULL) { ++i; continue; } +#if ONE_KEY_DM_SHARED_OFFLINE_QUEUE + // The held frame itself occupies the slot needed for ordinary delivery. + // Copy its small payload before removing it from the tail of the pool. + Frame& held = heldDMFrameAt(i); + uint32_t timestamp; + memcpy(×tamp, held.buf, sizeof(timestamp)); + uint8_t ack_hash[4]; + memcpy(ack_hash, &held.buf[4], sizeof(ack_hash)); + uint8_t path_len = held.buf[8]; + int8_t snr = static_cast(held.buf[9]); + uint8_t text_len = held.buf[10]; + char text[MAX_TEXT_LEN + 1]; + if (held.len != 12 + text_len || text_len > MAX_TEXT_LEN || + held.buf[11 + text_len] != 0) { + removeHeldOneKeyDM(i); + continue; + } + memcpy(text, &held.buf[11], text_len + 1); + uint8_t sender_key[PUB_KEY_SIZE]; + uint8_t id[ONE_KEY_DM_ID_SIZE]; + memcpy(sender_key, entry.sender_key, sizeof(sender_key)); + memcpy(id, entry.id, sizeof(id)); + removeHeldOneKeyDM(i); + if (!wasDeliveredOneKeyDM(sender_key, id)) { + mesh::Packet packet; + packet.header = path_len == 0xFF ? ROUTE_TYPE_DIRECT : ROUTE_TYPE_FLOOD; + packet.path_len = path_len == 0xFF ? 0 : path_len; + packet._snr = snr; + onMessageRecv(*contact, &packet, timestamp, text); + rememberDeliveredOneKeyDM(sender_key, id); + } + sendAckTo(*contact, ack_hash, sizeof(ack_hash)); +#else + if (offline_queue_len >= getOfflineQueueCapacity()) break; + + uint8_t data[MAX_PACKET_PAYLOAD + 1]; + const int decoded_len = mesh::Utils::MACThenDecrypt( + contact->getSharedSecret(self_id), data, &entry.packet.payload[2], + entry.packet.payload_len - 2); + if (decoded_len > 5 && decoded_len <= MAX_PACKET_PAYLOAD) { + data[decoded_len] = 0; + if ((data[4] >> 2) == TXT_TYPE_PLAIN) { + const char* text = reinterpret_cast(&data[5]); + uint32_t timestamp; + memcpy(×tamp, data, sizeof(timestamp)); + if (strlen(text) <= MAX_TEXT_LEN) { + if (!wasDeliveredOneKeyDM(entry.sender_key, entry.id)) { + onMessageRecv(*contact, &entry.packet, timestamp, text); + rememberDeliveredOneKeyDM(entry.sender_key, entry.id); + } + uint8_t ack_hash[4]; + mesh::Utils::sha256(ack_hash, sizeof(ack_hash), data, 5 + strlen(text), + contact->id.pub_key, PUB_KEY_SIZE); + sendAckTo(*contact, ack_hash, sizeof(ack_hash)); + } + } + } + for (uint8_t j = i + 1; j < held_dm_count; ++j) held_dms[j - 1] = held_dms[j]; + --held_dm_count; +#endif + } +} +#endif // MESH_ENABLE_ONE_KEY_DM + void MyMesh::queueMessage(const ContactInfo &from, uint8_t txt_type, mesh::Packet *pkt, uint32_t sender_timestamp, const uint8_t *extra, int extra_len, const char *text, @@ -1194,10 +1600,40 @@ bool MyMesh::sendFloodScoped(const mesh::GroupChannel& channel, mesh::Packet* pk void MyMesh::onMessageRecv(const ContactInfo &from, mesh::Packet *pkt, uint32_t sender_timestamp, const char *text) { +#if MESH_ENABLE_ONE_KEY_DM + uint8_t dm_id[ONE_KEY_DM_ID_SIZE]; + makeOneKeyDMId(dm_id, sender_timestamp, text); + if (wasDeliveredOneKeyDM(from.id.pub_key, dm_id)) return; +#if ONE_KEY_DM_SHARED_OFFLINE_QUEUE + bool replaced_held_dm = false; +#endif + for (uint8_t i = 0; i < held_dm_count; ++i) { + if (memcmp(held_dms[i].sender_key, from.id.pub_key, PUB_KEY_SIZE) == 0 && + memcmp(held_dms[i].id, dm_id, ONE_KEY_DM_ID_SIZE) == 0) { +#if ONE_KEY_DM_SHARED_OFFLINE_QUEUE + // Removing the held frame frees exactly one ordinary slot for this retry. + removeHeldOneKeyDM(i); + replaced_held_dm = true; +#else + // Keep the held copy if the ordinary delivery path cannot queue this + // just-arrived retry; otherwise accepting a peer with a full app queue + // could silently discard the original when the queue drains. + if (offline_queue_len >= getOfflineQueueCapacity()) return; + rememberDeliveredOneKeyDM(from.id.pub_key, dm_id); +#endif + break; + } + } +#endif markConnectionActive(from); // in case this is from a server, and we have a connection // BaseChatMesh updates lastmod immediately before this callback. scheduleContactWrite(from); queueMessage(from, TXT_TYPE_PLAIN, pkt, sender_timestamp, NULL, 0, text); +#if MESH_ENABLE_ONE_KEY_DM +#if ONE_KEY_DM_SHARED_OFFLINE_QUEUE + if (replaced_held_dm) rememberDeliveredOneKeyDM(from.id.pub_key, dm_id); +#endif +#endif } void MyMesh::onCommandDataRecv(const ContactInfo &from, mesh::Packet *pkt, uint32_t sender_timestamp, const char *text) { @@ -1411,6 +1847,27 @@ uint8_t MyMesh::onContactRequest(const ContactInfo &contact, uint32_t sender_tim void MyMesh::onContactResponse(const ContactInfo &contact, const uint8_t *data, uint8_t len) { if (data == NULL || len < 4) return; +#if MESH_ENABLE_ONE_KEY_DM + // Decryption returns block-aligned plaintext including zero padding. + if (len >= 4 + sizeof(ONE_KEY_REJECT_MARKER) + SIGNATURE_SIZE && + len < 4 + sizeof(ONE_KEY_REJECT_MARKER) + SIGNATURE_SIZE + CIPHER_BLOCK_SIZE && + memcmp(data + 4, ONE_KEY_REJECT_MARKER, sizeof(ONE_KEY_REJECT_MARKER)) == 0) { + uint8_t signed_refusal[PUB_KEY_SIZE + 4 + sizeof(ONE_KEY_REJECT_MARKER)]; + memcpy(signed_refusal, self_id.pub_key, PUB_KEY_SIZE); + memcpy(signed_refusal + PUB_KEY_SIZE, data, 4 + sizeof(ONE_KEY_REJECT_MARKER)); + uint32_t intro_tag; + memcpy(&intro_tag, data, sizeof(intro_tag)); + if (contact.id.verify(data + 4 + sizeof(ONE_KEY_REJECT_MARKER), + signed_refusal, sizeof(signed_refusal)) && + rememberOneKeyReject(contact, intro_tag) && _serial->isConnected()) { + out_frame[0] = PUSH_CODE_ONE_KEY_DM_REJECTED; + memcpy(&out_frame[1], contact.id.pub_key, PUB_KEY_SIZE); + _serial->writeFrame(out_frame, 1 + PUB_KEY_SIZE); + } + return; + } +#endif + uint32_t tag; memcpy(&tag, data, 4); @@ -4605,6 +5062,7 @@ void MyMesh::handleCmdFrame(size_t len) { int result; uint32_t expected_ack; + uint32_t one_key_delay = 0; if (txt_type == TXT_TYPE_CLI_DATA || txt_type == TXT_TYPE_CLI_COMMAND) { const uint32_t logical_request_id = msg_timestamp; msg_timestamp = getRTCClock()->getCurrentTimeUnique(); // Use node's RTC instead of app timestamp to avoid tripping replay protection @@ -4623,8 +5081,17 @@ void MyMesh::handleCmdFrame(size_t len) { msg_timestamp = getRTCClock()->getCurrentTimeUnique(); } replacement_entry = findPendingTextMessage(text_fingerprint, msg_timestamp); +#if MESH_ENABLE_ONE_KEY_DM + if (recipient->type == ADV_TYPE_CHAT + && !hasOneKeyReject(*recipient) + && (attempt != 0 || !hasOneKeyAck(*recipient)) + && tlen <= MAX_TEXT_LEN + && (attempt <= 3 || tlen <= MAX_TEXT_LEN - 2)) { + one_key_delay = sendOneKeyIntroduction(*recipient); + } +#endif result = sendMessage(*recipient, msg_timestamp, attempt, text, expected_ack, est_timeout, - packet_retry_key, NULL, text_fingerprint); + packet_retry_key, NULL, text_fingerprint, one_key_delay); if (result != MSG_SEND_FAILED && is_room_post) { room_message_timestamps.remember(text_fingerprint, app_timestamp, msg_timestamp); @@ -4655,7 +5122,7 @@ void MyMesh::handleCmdFrame(size_t len) { // ownership lives in Mesh and ends only on an ACK, retry completion, // or a successfully queued semantic replacement. clearExpectedAck(entry, false); - entry.msg_sent = _ms->getMillis(); // add to circular table + entry.msg_sent = _ms->getMillis() + one_key_delay; // scheduled radio send entry.expires_at = futureMillis(est_timeout); entry.ack = expected_ack; entry.message_timestamp = msg_timestamp; @@ -8714,6 +9181,38 @@ static bool isCompanionRadioPrefsCommand(const char* command) { bool MyMesh::handleDirectCommand(const char* command, char* reply, size_t reply_size) { #if defined(MESH_SOAK_DIAGNOSTICS) if (mesh::hil::handleSoakCommand(command, reply, reply_size)) return true; +#endif +#if MESH_ENABLE_ONE_KEY_DM + if (strcmp(command, "get dm.one_key") == 0) { + snprintf(reply, reply_size, "> %s", _prefs.one_key_dm_enabled == 1 ? "on" : "off"); + return true; + } + if (strcmp(command, "get dm.held") == 0) { + snprintf(reply, reply_size, "> %u", (unsigned)held_dm_count); + return true; + } + if (strncmp(command, "set dm.one_key ", 15) == 0) { + const char* value = command + 15; + if (strcmp(value, "on") != 0 && strcmp(value, "off") != 0) { + snprintf(reply, reply_size, "Error: use on or off"); + return true; + } + const uint8_t old_value = _prefs.one_key_dm_enabled; + _prefs.one_key_dm_enabled = strcmp(value, "on") == 0 ? 1 : 0; + if (!savePrefs()) { + _prefs.one_key_dm_enabled = old_value; + snprintf(reply, reply_size, "Error: could not save DM setting"); + } else { + snprintf(reply, reply_size, "> dm.one_key %s", value); + } + return true; + } +#else + if (strcmp(command, "get dm.one_key") == 0 || strcmp(command, "get dm.held") == 0 || + strncmp(command, "set dm.one_key ", 15) == 0) { + snprintf(reply, reply_size, "Error: one-key DMs unsupported on this build"); + return true; + } #endif if (strcmp(command, "get key.backup.transport") == 0) { snprintf(reply, reply_size, "ephemeral-routed-v1"); @@ -9452,6 +9951,9 @@ void MyMesh::loop() { #endif } servicePendingRadioParamApply(); +#if MESH_ENABLE_ONE_KEY_DM + if (held_dm_count != 0 || verified_pending_count != 0) releaseHeldOneKeyDMs(); +#endif // is there are pending dirty contacts write needed? if (isContactWriteDue()) { diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index 3ee692c62..57af597cb 100644 --- a/examples/companion_radio/MyMesh.h +++ b/examples/companion_radio/MyMesh.h @@ -84,9 +84,29 @@ #define MAX_CONTACTS 100 #endif +// STM32WL Companion images have only 224 KiB of application flash. Keep their +// existing filesystem boundary; larger targets retain the one-key DM feature. +#ifndef MESH_ENABLE_ONE_KEY_DM +#if defined(STM32_PLATFORM) +#define MESH_ENABLE_ONE_KEY_DM 0 +#else +#define MESH_ENABLE_ONE_KEY_DM 1 +#endif +#endif + +#if defined(NRF52_PLATFORM) && MAX_CONTACTS > 300 \ + && !defined(OTA_SHARED_COMPANION_QUEUE) && MESH_CONTACT_CACHE +#define ONE_KEY_DM_SHARED_OFFLINE_QUEUE 1 +#else +#define ONE_KEY_DM_SHARED_OFFLINE_QUEUE 0 +#endif + #ifndef OFFLINE_QUEUE_SIZE #if defined(ESP32_PLATFORM) && defined(BOARD_HAS_PSRAM) #define OFFLINE_QUEUE_SIZE 512 +#elif defined(NRF52_PLATFORM) && MAX_CONTACTS > 300 \ + && !defined(OTA_SHARED_COMPANION_QUEUE) && !ONE_KEY_DM_SHARED_OFFLINE_QUEUE +#define OFFLINE_QUEUE_SIZE 208 #elif defined(ESP32_PLATFORM) || defined(NRF52_PLATFORM) \ || defined(RP2040_PLATFORM) #define OFFLINE_QUEUE_SIZE 256 @@ -96,6 +116,9 @@ #endif static_assert(OFFLINE_QUEUE_SIZE > 0, "OFFLINE_QUEUE_SIZE must be positive"); +#if ONE_KEY_DM_SHARED_OFFLINE_QUEUE +static_assert(OFFLINE_QUEUE_SIZE >= 15, "Shared DM queue needs 15 frame slots"); +#endif #ifndef ROOM_MESSAGE_TIMESTAMP_CACHE_SIZE #define ROOM_MESSAGE_TIMESTAMP_CACHE_SIZE 16 @@ -108,6 +131,11 @@ static_assert(OFFLINE_QUEUE_SIZE > 0, "OFFLINE_QUEUE_SIZE must be positive"); #include #include +#if ONE_KEY_DM_SHARED_OFFLINE_QUEUE +static_assert(MAX_TEXT_LEN + 12 <= MAX_FRAME_SIZE, + "A held plain DM must fit in one offline frame"); +#endif + /* -------------------------------------------------------------------------------------- */ #define REQ_TYPE_GET_STATUS 0x01 // same as _GET_STATS @@ -317,6 +345,13 @@ protected: void onContactVisit(const ContactInfo& contact) override; #endif ContactInfo* processAck(const uint8_t *data) override; +#if MESH_ENABLE_ONE_KEY_DM + void onAnonDataRecv(mesh::Packet* packet, const uint8_t* secret, + const mesh::Identity& sender, uint8_t* data, + size_t len) override; + bool onAddressedTextPacket(mesh::Packet* packet, uint8_t src_hash, + const uint8_t* mac_and_data, size_t len) override; +#endif void queueMessage(const ContactInfo &from, uint8_t txt_type, mesh::Packet *pkt, uint32_t sender_timestamp, const uint8_t *extra, int extra_len, const char *text, bool terminal_command_reply=false, @@ -645,6 +680,10 @@ private: bool isChannelMsg() const; }; Frame& offlineQueueFrameAt(int logical_index); +#if ONE_KEY_DM_SHARED_OFFLINE_QUEUE + Frame& heldDMFrameAt(uint8_t index); + void removeHeldOneKeyDM(uint8_t index); +#endif void initializeOfflineQueue(); int offline_queue_len; int offline_queue_head; @@ -681,6 +720,55 @@ private: }; #define EXPECTED_ACK_TABLE_SIZE 8 AckTableEntry expected_ack_table[EXPECTED_ACK_TABLE_SIZE]; // circular table +#if MESH_ENABLE_ONE_KEY_DM + // A recent ACK proves that peer can decrypt our normal text packets. Retry + // attempts still send the introduction in case its contact was later erased. + static constexpr uint8_t ONE_KEY_PEERS = 8; + struct OneKeyPeerState { + uint8_t pub_key[PUB_KEY_SIZE]; + uint32_t intro_tag; + uint8_t status; // 0: pending, 1: acknowledged, 2: rejected + }; + OneKeyPeerState one_key_peers[ONE_KEY_PEERS] = {}; + uint8_t one_key_peer_count = 0; + uint8_t one_key_peer_next = 0; + bool hasOneKeyAck(const ContactInfo& contact) const; + bool hasOneKeyReject(const ContactInfo& contact) const; + void rememberOneKeyIntro(const ContactInfo& contact, uint32_t tag); + void rememberOneKeyAck(const ContactInfo& contact); + bool rememberOneKeyReject(const ContactInfo& contact, uint32_t tag); + uint32_t sendOneKeyIntroduction(const ContactInfo& contact); + + static constexpr uint8_t MAX_HELD_ONE_KEY_DMS = 15; + static constexpr uint8_t ONE_KEY_DM_ID_SIZE = 8; + uint8_t verified_pending_keys[MAX_HELD_ONE_KEY_DMS][PUB_KEY_SIZE] = {}; + uint8_t verified_pending_count = 0; + struct HeldOneKeyDM { + uint8_t sender_key[PUB_KEY_SIZE]; + uint8_t id[ONE_KEY_DM_ID_SIZE]; +#if !ONE_KEY_DM_SHARED_OFFLINE_QUEUE + mesh::Packet packet; +#endif + }; + HeldOneKeyDM held_dms[MAX_HELD_ONE_KEY_DMS]; + uint8_t held_dm_count = 0; + struct DeliveredOneKeyDM { + uint8_t sender_key[PUB_KEY_SIZE]; + uint8_t id[ONE_KEY_DM_ID_SIZE]; + }; + DeliveredOneKeyDM delivered_dms[MAX_HELD_ONE_KEY_DMS] = {}; + uint8_t delivered_dm_count = 0; + uint8_t delivered_dm_next = 0; + void rememberVerifiedPendingSender(const uint8_t* pub_key); + void forgetVerifiedPendingSender(const uint8_t* pub_key); + static void makeOneKeyDMId(uint8_t id[ONE_KEY_DM_ID_SIZE], uint32_t timestamp, + const char* text); + bool wasDeliveredOneKeyDM(const uint8_t* pub_key, + const uint8_t id[ONE_KEY_DM_ID_SIZE]) const; + void rememberDeliveredOneKeyDM(const uint8_t* pub_key, + const uint8_t id[ONE_KEY_DM_ID_SIZE]); + void releaseHeldOneKeyDMs(); +#endif mesh::LogicalMessageCache room_message_timestamps; int next_ack_idx; unsigned long next_ack_expiry; diff --git a/examples/companion_radio/NodePrefs.h b/examples/companion_radio/NodePrefs.h index 911329ada..f27d8df7e 100644 --- a/examples/companion_radio/NodePrefs.h +++ b/examples/companion_radio/NodePrefs.h @@ -106,6 +106,8 @@ public: uint8_t flood_retry_max_path = 1; uint8_t flood_retry_group_max_path = 0xFF; uint8_t flood_retry_advert_enabled = 1; + // Appended persisted field: old preferences images default to consent required. + uint8_t one_key_dm_enabled = 0; private: class RadioPrefs : public CommonRadioPrefs { diff --git a/src/Mesh.cpp b/src/Mesh.cpp index 6dc9d8a69..33560aaf1 100644 --- a/src/Mesh.cpp +++ b/src/Mesh.cpp @@ -1074,8 +1074,9 @@ DispatcherAction Mesh::onRecvPacket(Packet* pkt) { // scan contacts DB, for all matching hashes of 'src_hash' (max 4 matches supported ATM) int num = searchPeersByHash(&src_hash); // for each matching contact, try to decrypt data - bool found = false; - for (int j = 0; j < num; j++) { + bool found = pkt->getPayloadType() == PAYLOAD_TYPE_TXT_MSG && + onAddressedTextPacket(pkt, src_hash, macAndData, pkt->payload_len - i); + for (int j = 0; j < num && !found; j++) { uint8_t secret[PUB_KEY_SIZE]; getPeerSharedSecret(secret, j); diff --git a/src/Mesh.h b/src/Mesh.h index 8354a2590..fb1891f64 100644 --- a/src/Mesh.h +++ b/src/Mesh.h @@ -500,6 +500,11 @@ protected: */ virtual void onAnonDataRecv(Packet* packet, const uint8_t* secret, const Identity& sender, uint8_t* data, size_t len) { } + // Gives an application a chance to retain addressed text from a verified + // sender that is not yet in the persistent peer table. + virtual bool onAddressedTextPacket(Packet* packet, uint8_t src_hash, + const uint8_t* mac_and_data, size_t len) { return false; } + /** * \brief A path TO 'sender' has been received. (also with optional 'extra' data encoded) * NOTE: these can be received multiple times (per sender), via different routes diff --git a/src/helpers/BaseChatMesh.cpp b/src/helpers/BaseChatMesh.cpp index 07c6f3693..1f7504966 100644 --- a/src/helpers/BaseChatMesh.cpp +++ b/src/helpers/BaseChatMesh.cpp @@ -289,7 +289,14 @@ void BaseChatMesh::onAdvertRecv(mesh::Packet* packet, const mesh::Identity& id, int BaseChatMesh::searchPeersByHash(const uint8_t* hash) { int n = 0; - for (int i = 0; i < num_contacts && n < MAX_SEARCH_RESULTS; i++) { + // A matching transient slot may have the same identity as an accepted contact. + // Try persistent contacts first so the transient slot cannot swallow their DMs. + for (int i = MAX_ANON_CONTACTS; i < num_contacts && n < MAX_SEARCH_RESULTS; i++) { + if (contacts[i].id.isHashMatch(hash)) { + matching_peer_indexes[n++] = i; + } + } + for (int i = 0; i < MAX_ANON_CONTACTS && n < MAX_SEARCH_RESULTS; i++) { if (contacts[i].id.isHashMatch(hash)) { matching_peer_indexes[n++] = i; // store the INDEXES of matching contacts (for subsequent 'peer' methods) } @@ -315,6 +322,9 @@ void BaseChatMesh::onPeerDataRecv(mesh::Packet* packet, uint8_t type, int sender ContactInfo& from = contacts[i]; + // A transient service peer is not an accepted chat contact. + if (type == PAYLOAD_TYPE_TXT_MSG && from.type == ADV_TYPE_NONE) return; + if (type == PAYLOAD_TYPE_TXT_MSG && len > 5) { uint32_t sender_timestamp; memcpy(&sender_timestamp, data, 4); // timestamp (by sender's RTC clock - which could be wrong) @@ -570,7 +580,8 @@ mesh::Packet* BaseChatMesh::composeMsgPacket(const ContactInfo& recipient, uint3 int BaseChatMesh::sendMessage(const ContactInfo& recipient, uint32_t timestamp, uint8_t attempt, const char* text, uint32_t& expected_ack, uint32_t& est_timeout, uint8_t* packet_hash, const uint8_t* replace_retry_key, - const uint8_t* message_retry_key) { + const uint8_t* message_retry_key, + uint32_t delay_millis) { mesh::Packet* pkt = composeMsgPacket(recipient, timestamp, attempt, text, expected_ack); if (pkt == NULL) return MSG_SEND_FAILED; if (packet_hash != NULL) { @@ -588,12 +599,12 @@ int BaseChatMesh::sendMessage(const ContactInfo& recipient, uint32_t timestamp, int rc; bool sent; if (recipient.out_path_len == OUT_PATH_UNKNOWN) { - sent = sendFloodScoped(recipient, pkt); - est_timeout = calcFloodTimeoutMillisFor(t); + sent = sendFloodScoped(recipient, pkt, delay_millis); + est_timeout = delay_millis + calcFloodTimeoutMillisFor(t); rc = MSG_SEND_SENT_FLOOD; } else { - sent = sendDirect(pkt, recipient.getPath(), recipient.out_path_len); - est_timeout = calcDirectTimeoutMillisFor(t, recipient.out_path_len); + sent = sendDirect(pkt, recipient.getPath(), recipient.out_path_len, delay_millis); + est_timeout = delay_millis + calcDirectTimeoutMillisFor(t, recipient.out_path_len); rc = MSG_SEND_SENT_DIRECT; } if (!sent) { diff --git a/src/helpers/BaseChatMesh.h b/src/helpers/BaseChatMesh.h index e2cb8facf..9cd6d48ad 100644 --- a/src/helpers/BaseChatMesh.h +++ b/src/helpers/BaseChatMesh.h @@ -97,9 +97,9 @@ class BaseChatMesh : public mesh::Mesh { ConnectionInfo connections[MAX_CONNECTIONS]; mesh::Packet* composeMsgPacket(const ContactInfo& recipient, uint32_t timestamp, uint8_t attempt, const char *text, uint32_t& expected_ack); - void sendAckTo(const ContactInfo& dest, const uint8_t* ack_hash, uint8_t ack_len=4); protected: + void sendAckTo(const ContactInfo& dest, const uint8_t* ack_hash, uint8_t ack_len=4); BaseChatMesh(mesh::Radio& radio, mesh::MillisecondClock& ms, mesh::RNG& rng, mesh::RTCClock& rtc, mesh::PacketManager& mgr, mesh::MeshTables& tables) : mesh::Mesh(radio, ms, rng, rtc, mgr, tables) { @@ -199,7 +199,8 @@ public: int sendMessage(const ContactInfo& recipient, uint32_t timestamp, uint8_t attempt, const char* text, uint32_t& expected_ack, uint32_t& est_timeout, uint8_t* packet_hash = NULL, const uint8_t* replace_retry_key = NULL, - const uint8_t* message_retry_key = NULL); + const uint8_t* message_retry_key = NULL, + uint32_t delay_millis = 0); int sendCommandData(const ContactInfo& recipient, uint32_t timestamp, uint8_t attempt, uint8_t txt_type, const char* text, uint32_t& est_timeout, diff --git a/src/helpers/CommonCLI.cpp b/src/helpers/CommonCLI.cpp index 221406736..711fd90c1 100644 --- a/src/helpers/CommonCLI.cpp +++ b/src/helpers/CommonCLI.cpp @@ -4853,8 +4853,8 @@ void CommonCLI::handleGetCmd(uint32_t sender_timestamp, char* command, char* rep char freq[16], bw[16]; strcpy(freq, StrHelper::ftoa(_prefs->freq)); strcpy(bw, StrHelper::ftoa3(_prefs->bw)); + // The official app parses this reply as exactly four CSV fields. sprintf(reply, "> %s,%s,%d,%d", freq, bw, (uint32_t)_prefs->sf, (uint32_t)_prefs->cr); - _radio_profiles.appendSavedPreamble(reply, 160, _prefs->sf, _prefs->bw); } else if (configKeyEquals(config, "rxdelay")) { sprintf(reply, "> %s", StrHelper::ftoa(_prefs->rx_delay_base)); } else if (configKeyEquals(config, "txdelay")) { diff --git a/test/fixtures/cli_settings/main.cpp b/test/fixtures/cli_settings/main.cpp index 13695e050..a03130ab4 100644 --- a/test/fixtures/cli_settings/main.cpp +++ b/test/fixtures/cli_settings/main.cpp @@ -16,7 +16,10 @@ struct RadioProfileCLI { } bool savePrimaryPreamble(uint16_t) { return true; } void appendPreamble(char*, size_t) {} - void appendSavedPreamble(char*, size_t, uint8_t, float) {} + void appendSavedPreamble(char* reply, size_t capacity, uint8_t, float) { + const size_t used = strlen(reply); + if (used < capacity) snprintf(reply + used, capacity - used, ",preamble=16 (auto)"); + } mutable unsigned warning_calls = 0; mutable uint8_t warning_sf = 0; mutable float warning_bw = 0; @@ -138,6 +141,9 @@ int main() { assert(cli._radio_profiles.warning_calls==1 && cli._radio_profiles.warning_sf==8); assert(cli._radio_profiles.warning_bw==125 && cli._radio_profiles.warning_preamble==0); cli.call(sender,"get radio",reply); + // The official app requires exactly four comma-separated radio fields. + // Simulate the production preamble suffix above to catch regressions. + assert(!strcmp(reply,"> 916.000,125.000,8,6")); float freq=0,bw=0; int sf=0,cr=0; assert(sscanf(reply,"> %f,%f,%d,%d",&freq,&bw,&sf,&cr)==4); assert(freq==916 && bw==125 && sf==8 && cr==6); diff --git a/test/test_cli_settings_contract.py b/test/test_cli_settings_contract.py index 16d2fb65c..6410985b7 100644 --- a/test/test_cli_settings_contract.py +++ b/test/test_cli_settings_contract.py @@ -190,6 +190,7 @@ class CLISettingsContractTest(unittest.TestCase): self.assertEqual(gets - sets, { 'contact.cache', 'contact.cache.timing', # Contact-storage diagnostics 'display.wifi', # Connection/display status + 'dm.held', # Read-only pending-DM count 'flash.health', # Read-only flash health diagnostics 'key.backup.transport', # Identity-backup transport capability 'mqtt', 'mqtt.running', # MQTT connection status diff --git a/test/test_companion_preferences_transaction.py b/test/test_companion_preferences_transaction.py index 374965349..c05cf8b4d 100644 --- a/test/test_companion_preferences_transaction.py +++ b/test/test_companion_preferences_transaction.py @@ -53,6 +53,7 @@ int main(int argc,char** argv){ original.interference_threshold=8;original.agc_reset_interval=15;original.tz_offset=-7; original.flood_retry_attempts=4;original.flood_retry_max_path=3; original.flood_retry_group_max_path=2;original.flood_retry_advert_enabled=0; + original.one_key_dm_enabled=1; #ifdef TBEAM_1W strcpy(original.fan_mode,"on");original.fan_lo=33;original.fan_hi=42; #endif @@ -88,6 +89,7 @@ int main(int argc,char** argv){ assert(loaded.interference_threshold==8&&loaded.agc_reset_interval==15&&loaded.tz_offset==-7); assert(loaded.flood_retry_attempts==4&&loaded.flood_retry_max_path==3); assert(loaded.flood_retry_group_max_path==2&&loaded.flood_retry_advert_enabled==0); + assert(loaded.one_key_dm_enabled==1); #ifdef TBEAM_1W assert(!strcmp(loaded.fan_mode,"on")&&loaded.fan_lo==33&&loaded.fan_hi==42); #endif @@ -172,6 +174,7 @@ int main(int argc,char** argv){ assert(loaded.interference_threshold==0&&loaded.agc_reset_interval==0&&loaded.tz_offset==0); assert(loaded.flood_retry_attempts==15&&loaded.flood_retry_max_path==1); assert(loaded.flood_retry_group_max_path==0xff&&loaded.flood_retry_advert_enabled==1); + assert(loaded.one_key_dm_enabled==0); assert(loaded.freq==original.freq&&loaded.ble_pin==original.ble_pin); } else if(scenario==6){ DataStore legacy;legacy.fs.files["/new_prefs"]=disk; @@ -186,6 +189,15 @@ int main(int argc,char** argv){ assert(legacy.loadPrefs(loaded,lat,lon)); assert(loaded.flood_retry_attempts==15&&loaded.flood_retry_max_path==1); assert(loaded.flood_retry_group_max_path==0xff&&loaded.flood_retry_advert_enabled==1); + assert(loaded.one_key_dm_enabled==0); + } else if(scenario==7){ + // Pre-consent image: all earlier settings survive with default-off consent. + DataStore legacy;legacy.fs.files["/new_prefs"]=disk; + legacy.fs.files["/new_prefs"].resize(disk.size()-1); + CompanionNodePrefs loaded;double lat=0,lon=0; + assert(legacy.loadPrefs(loaded,lat,lon)); + assert(loaded.flood_retry_advert_enabled==0); + assert(loaded.one_key_dm_enabled==0); } else if(scenario==4){ DataStore legacy;legacy.fs.files["/node_prefs"]=disk; legacy.fs.fail_write_after=17; @@ -273,7 +285,7 @@ inline char* utoa(unsigned int value,char* output,int base){ str(ROOT / 'src/helpers/TxtDataHelpers.cpp'), '-o', str(binary)], capture_output=True, text=True) self.assertEqual(build.returncode, 0, build.stdout + build.stderr) - for scenario in range(7): + for scenario in range(8): with self.subTest(scenario=scenario): run = subprocess.run([str(binary), str(scenario)], capture_output=True, text=True) self.assertEqual(run.returncode, 0, run.stdout + run.stderr) diff --git a/test/test_one_key_dm_shared_queue.py b/test/test_one_key_dm_shared_queue.py new file mode 100644 index 000000000..de427e006 --- /dev/null +++ b/test/test_one_key_dm_shared_queue.py @@ -0,0 +1,315 @@ +#!/usr/bin/env python3 +"""Exercise the real shared Companion queue methods on the host.""" + +from pathlib import Path +import os +import subprocess +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[1] +SOURCE = ROOT / "examples/companion_radio/MyMesh.cpp" + + +def method(source: str, signature: str) -> str: + start = source.index(signature) + end = source.index("{", start) + 1 + depth = 1 + while depth: + depth += (source[end] == "{") - (source[end] == "}") + end += 1 + return source[start:end] + + +class OneKeyDMSharedQueueTest(unittest.TestCase): + def compile_and_run(self, source: str): + with tempfile.TemporaryDirectory(prefix="meshcore-shared-dm-") as temp: + path = Path(temp) / "shared_dm.cpp" + binary = Path(temp) / "shared_dm.exe" + path.write_text(source) + sanitizer_flags = ["-fsanitize=address,undefined"] if os.name != "nt" else [] + built = subprocess.run( + ["c++", "-std=c++17", "-O0", *sanitizer_flags, + str(path), "-o", str(binary)], + text=True, capture_output=True, + ) + self.assertEqual(built.returncode, 0, built.stderr) + result = subprocess.run([str(binary)], text=True, capture_output=True) + self.assertEqual(result.returncode, 0, result.stderr) + + def test_shared_pool_keeps_ordinary_fifo_and_held_tail_separate(self): + source = SOURCE.read_text() + methods = "\n\n".join(method(source, signature) for signature in ( + "bool MyMesh::Frame::isChannelMsg() const", + "int MyMesh::getOfflineQueueCapacity() const", + "MyMesh::Frame& MyMesh::offlineQueueFrameAt(int logical_index)", + "MyMesh::Frame& MyMesh::heldDMFrameAt(uint8_t index)", + "void MyMesh::removeHeldOneKeyDM(uint8_t index)", + "bool MyMesh::addToOfflineQueue(const uint8_t frame[], int len)", + "int MyMesh::getFromOfflineQueue(uint8_t frame[])", + )) + harness = r''' +#include +#include +#include +#define ONE_KEY_DM_SHARED_OFFLINE_QUEUE 1 +#define OFFLINE_QUEUE_SIZE 256 +#define MAX_FRAME_SIZE 176 +#define RESP_CODE_CHANNEL_MSG_RECV 0x8c +#define RESP_CODE_CHANNEL_MSG_RECV_V3 0x90 +#define RESP_CODE_CHANNEL_DATA_RECV 0x8d +#define MESH_DEBUG_PRINTLN(...) ((void)0) +class MyMesh { +public: + struct Frame { + uint8_t len = 0; + uint8_t buf[MAX_FRAME_SIZE] = {}; + bool isChannelMsg() const; + }; + struct Held { uint8_t marker = 0; }; + Frame offline_queue[OFFLINE_QUEUE_SIZE] = {}; + Held held_dms[15] = {}; + uint8_t held_dm_count = 0; + int offline_queue_len = 0; + int offline_queue_head = 0; + int getOfflineQueueCapacity() const; + Frame& offlineQueueFrameAt(int index); + Frame& heldDMFrameAt(uint8_t index); + void removeHeldOneKeyDM(uint8_t index); + bool addToOfflineQueue(const uint8_t frame[], int len); + int getFromOfflineQueue(uint8_t frame[]); +}; +static_assert(sizeof(MyMesh::Frame) == 177); +''' + exercise = r''' +int main() { + MyMesh mesh; + uint8_t out[176] = {}; + // All 256 slots are ordinary messages when there is no held DM. + for (int i = 0; i < 256; ++i) { + uint8_t frame[2] = {42, static_cast(i)}; + assert(mesh.addToOfflineQueue(frame, sizeof(frame))); + } + const uint8_t overflow = 42; + assert(!mesh.addToOfflineQueue(&overflow, 1)); + for (int i = 0; i < 256; ++i) { + assert(mesh.getFromOfflineQueue(out) == 2); + assert(out[1] == static_cast(i)); + } + + MyMesh channel_mesh; + const uint8_t channel = RESP_CODE_CHANNEL_MSG_RECV; + assert(channel_mesh.addToOfflineQueue(&channel, 1)); + for (int i = 0; i < 255; ++i) { + uint8_t frame[2] = {42, static_cast(i)}; + assert(channel_mesh.addToOfflineQueue(frame, sizeof(frame))); + } + assert(channel_mesh.addToOfflineQueue(&overflow, 1)); + assert(channel_mesh.offline_queue_len == 256); + assert(channel_mesh.getFromOfflineQueue(out) == 2 && out[1] == 0); + for (int i = 1; i < 255; ++i) { + assert(channel_mesh.getFromOfflineQueue(out) == 2); + assert(out[1] == static_cast(i)); + } + assert(channel_mesh.getFromOfflineQueue(out) == 1 && out[0] == overflow); + + // Held entries occupy the opposite end; sync must never expose them. + for (int i = 0; i < 15; ++i) { + mesh.held_dms[i].marker = static_cast(i + 1); + mesh.heldDMFrameAt(mesh.held_dm_count).buf[0] = static_cast(i + 1); + ++mesh.held_dm_count; + } + for (int i = 0; i < 241; ++i) { + uint8_t frame[2] = {42, static_cast(i)}; + assert(mesh.addToOfflineQueue(frame, sizeof(frame))); + } + assert(!mesh.addToOfflineQueue(&overflow, 1)); + for (int i = 0; i < 241; ++i) { + assert(mesh.getFromOfflineQueue(out) == 2); + assert(out[1] == static_cast(i)); + } + assert(mesh.getFromOfflineQueue(out) == 0); + for (int i = 0; i < 15; ++i) { + assert(mesh.held_dms[i].marker == i + 1); + assert(mesh.heldDMFrameAt(i).buf[0] == i + 1); + } + + // Removing any held DM shifts only the held tail and frees one normal slot. + mesh.removeHeldOneKeyDM(0); + assert(mesh.held_dm_count == 14); + assert(mesh.held_dms[0].marker == 2); + assert(mesh.heldDMFrameAt(0).buf[0] == 2); + assert(mesh.addToOfflineQueue(&overflow, 1)); + assert(mesh.getFromOfflineQueue(out) == 1 && out[0] == overflow); + for (int i = 0; i < 14; ++i) mesh.removeHeldOneKeyDM(0); + assert(mesh.held_dm_count == 0); + for (int i = 0; i < 256; ++i) { + uint8_t frame[2] = {42, static_cast(i)}; + assert(mesh.addToOfflineQueue(frame, sizeof(frame))); + } + return 0; +} +''' + self.compile_and_run(harness + methods + exercise) + + def test_sixteenth_dm_rolls_off_and_acceptance_releases_only_last_fifteen(self): + source = SOURCE.read_text() + methods = "\n\n".join(method(source, signature) for signature in ( + "bool MyMesh::Frame::isChannelMsg() const", + "int MyMesh::getOfflineQueueCapacity() const", + "MyMesh::Frame& MyMesh::offlineQueueFrameAt(int logical_index)", + "MyMesh::Frame& MyMesh::heldDMFrameAt(uint8_t index)", + "void MyMesh::removeHeldOneKeyDM(uint8_t index)", + "bool MyMesh::addToOfflineQueue(const uint8_t frame[], int len)", + "int MyMesh::getFromOfflineQueue(uint8_t frame[])", + "bool MyMesh::onAddressedTextPacket(mesh::Packet* packet, uint8_t src_hash,", + "void MyMesh::releaseHeldOneKeyDMs()", + )) + harness = r''' +#include +#include +#include +#include +#define ONE_KEY_DM_SHARED_OFFLINE_QUEUE 1 +#define OFFLINE_QUEUE_SIZE 256 +#define MAX_FRAME_SIZE 176 +#define MAX_PACKET_PAYLOAD 184 +#define MAX_TEXT_LEN 160 +#define PUB_KEY_SIZE 32 +#define TXT_TYPE_PLAIN 0 +#define ROUTE_TYPE_DIRECT 2 +#define ROUTE_TYPE_FLOOD 1 +#define RESP_CODE_CHANNEL_MSG_RECV 0x8c +#define RESP_CODE_CHANNEL_MSG_RECV_V3 0x90 +#define RESP_CODE_CHANNEL_DATA_RECV 0x8d +#define MESH_DEBUG_PRINTLN(...) ((void)0) +namespace mesh { +struct Packet { + uint8_t header = ROUTE_TYPE_FLOOD; + uint8_t path_len = 0; + int8_t _snr = 0; + bool isRouteFlood() const { return header == ROUTE_TYPE_FLOOD; } +}; +struct Utils { + static int MACThenDecrypt(const uint8_t*, uint8_t* out, + const uint8_t* in, size_t len) { + memcpy(out, in, len); + return static_cast(len); + } + static void sha256(uint8_t* out, size_t out_len, const uint8_t* in, + size_t in_len, const uint8_t*, size_t) { + for (size_t i = 0; i < out_len; ++i) out[i] = in[i % in_len]; + } +}; +} +struct ContactInfo { struct { uint8_t pub_key[PUB_KEY_SIZE] = {}; } id; }; +struct SelfIdentity { + void calcSharedSecret(uint8_t* secret, const uint8_t*) { memset(secret, 0, PUB_KEY_SIZE); } +}; +class MyMesh { +public: + static constexpr uint8_t MAX_HELD_ONE_KEY_DMS = 15; + static constexpr uint8_t ONE_KEY_DM_ID_SIZE = 8; + struct Frame { + uint8_t len = 0; + uint8_t buf[MAX_FRAME_SIZE] = {}; + bool isChannelMsg() const; + }; + struct HeldOneKeyDM { + uint8_t sender_key[PUB_KEY_SIZE]; + uint8_t id[ONE_KEY_DM_ID_SIZE]; + }; + Frame offline_queue[OFFLINE_QUEUE_SIZE] = {}; + int offline_queue_len = 0; + int offline_queue_head = 0; + HeldOneKeyDM held_dms[MAX_HELD_ONE_KEY_DMS] = {}; + uint8_t held_dm_count = 0; + uint8_t verified_pending_keys[MAX_HELD_ONE_KEY_DMS][PUB_KEY_SIZE] = {}; + uint8_t verified_pending_count = 0; + uint8_t delivered_ids[MAX_HELD_ONE_KEY_DMS][ONE_KEY_DM_ID_SIZE] = {}; + uint8_t delivered_count = 0; + int ack_count = 0; + bool accepted = false; + ContactInfo contact; + SelfIdentity self_id; + int getOfflineQueueCapacity() const; + Frame& offlineQueueFrameAt(int index); + Frame& heldDMFrameAt(uint8_t index); + void removeHeldOneKeyDM(uint8_t index); + bool addToOfflineQueue(const uint8_t frame[], int len); + int getFromOfflineQueue(uint8_t frame[]); + bool onAddressedTextPacket(mesh::Packet*, uint8_t, const uint8_t*, size_t); + void releaseHeldOneKeyDMs(); + ContactInfo* lookupPersistentContactByPubKey(const uint8_t* key, size_t) { + return accepted && memcmp(contact.id.pub_key, key, PUB_KEY_SIZE) == 0 + ? &contact : nullptr; + } + void forgetVerifiedPendingSender(const uint8_t*) { verified_pending_count = 0; } + static void makeOneKeyDMId(uint8_t id[ONE_KEY_DM_ID_SIZE], uint32_t timestamp, + const char*) { + memset(id, 0, ONE_KEY_DM_ID_SIZE); + memcpy(id, ×tamp, sizeof(timestamp)); + } + bool wasDeliveredOneKeyDM(const uint8_t*, const uint8_t id[ONE_KEY_DM_ID_SIZE]) { + for (int i = 0; i < delivered_count; ++i) + if (memcmp(delivered_ids[i], id, ONE_KEY_DM_ID_SIZE) == 0) return true; + return false; + } + void rememberDeliveredOneKeyDM(const uint8_t*, const uint8_t id[ONE_KEY_DM_ID_SIZE]) { + memcpy(delivered_ids[delivered_count++], id, ONE_KEY_DM_ID_SIZE); + } + void onMessageRecv(const ContactInfo&, mesh::Packet*, uint32_t timestamp, + const char* text) { + uint8_t frame[MAX_FRAME_SIZE] = {}; + memcpy(frame, ×tamp, 4); + memcpy(frame + 4, text, strlen(text) + 1); + assert(addToOfflineQueue(frame, 5 + strlen(text))); + } + void sendAckTo(const ContactInfo&, const uint8_t*, uint8_t) { ++ack_count; } +}; +''' + exercise = r''' +int main() { + MyMesh receiver; + receiver.contact.id.pub_key[0] = 0x42; + receiver.verified_pending_count = 1; + receiver.verified_pending_keys[0][0] = 0x42; + mesh::Packet packet; + packet._snr = 12; + for (uint32_t timestamp = 1; timestamp <= 16; ++timestamp) { + uint8_t encrypted[32] = {}; + memcpy(encrypted, ×tamp, 4); + encrypted[4] = TXT_TYPE_PLAIN << 2; + snprintf(reinterpret_cast(encrypted + 5), 20, "msg%02lu", + static_cast(timestamp)); + assert(receiver.onAddressedTextPacket(&packet, 0x42, encrypted, + 5 + strlen(reinterpret_cast(encrypted + 5)))); + } + assert(receiver.held_dm_count == 15); + uint8_t out[MAX_FRAME_SIZE] = {}; + assert(receiver.getFromOfflineQueue(out) == 0); + receiver.accepted = true; + receiver.releaseHeldOneKeyDMs(); + assert(receiver.held_dm_count == 0); + assert(receiver.verified_pending_count == 0); + assert(receiver.offline_queue_len == 15); + assert(receiver.ack_count == 15); + for (uint32_t timestamp = 2; timestamp <= 16; ++timestamp) { + int len = receiver.getFromOfflineQueue(out); + assert(len > 5); + uint32_t delivered_timestamp; + memcpy(&delivered_timestamp, out, 4); + assert(delivered_timestamp == timestamp); + char expected[20]; + snprintf(expected, sizeof(expected), "msg%02lu", static_cast(timestamp)); + assert(strcmp(reinterpret_cast(out + 4), expected) == 0); + } + assert(receiver.getFromOfflineQueue(out) == 0); + return 0; +} +''' + self.compile_and_run(harness + methods + exercise) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/hil/one_key_dm.py b/tools/hil/one_key_dm.py new file mode 100644 index 000000000..ab6b78c17 --- /dev/null +++ b/tools/hil/one_key_dm.py @@ -0,0 +1,233 @@ +#!/usr/bin/env python3 +"""Exercise manual acceptance of a one-key private DM. + +This test changes the two radios' contact lists and transmits one LoRa DM. +It consumes the recipient's deferred DM from the offline queue. Use two nearby +Companion USB radios on the same radio profile, with no other client attached. +""" + +from __future__ import annotations + +import argparse +import json +import struct +import time + +import serial + +from esp32_companion_serial_stress import ( + DeviceFrameReader, + FrameTimeout, + TransportCounters, + encode_host_frame, + make_app_start_spec, + validate_app_start_response, +) + + +class Link: + def __init__(self, port: str) -> None: + self.port = serial.Serial(port, 115200, timeout=0.1, write_timeout=2) + self.reader = DeviceFrameReader(TransportCounters()) + self.pushes: list[bytes] = [] + + def close(self) -> None: + self.port.close() + + def read(self, seconds: float) -> bytes | None: + try: + frame = self.reader.read_frame(self.port, seconds) + except FrameTimeout: + return None + if frame[0] >= 0x80: + self.pushes.append(frame) + return frame + + def request(self, payload: bytes, expected: tuple[int, ...], seconds: float = 5) -> bytes: + self.port.write(encode_host_frame(payload)) + self.port.flush() + until = time.monotonic() + seconds + while time.monotonic() < until: + frame = self.read(min(0.5, until - time.monotonic())) + if frame is None or frame[0] >= 0x80: + continue + if frame[0] not in expected: + raise RuntimeError( + f"unexpected Companion response {frame[0]}" + + (f", error {frame[1]}" if frame[0] == 1 and len(frame) > 1 else "") + ) + return frame + raise TimeoutError(f"Companion request {payload[0]} timed out") + + def start(self) -> tuple[bytes, dict]: + # Some USB Companions need a fresh CDC open after DFU activation. + for attempt in range(3): + try: + frame = self.request(make_app_start_spec("OneKeyDM-HIL").payload, + (5,), seconds=5) + break + except TimeoutError: + if attempt == 2: + raise + self.port.close() + time.sleep(1) + self.port.open() + self.reader = DeviceFrameReader(TransportCounters()) + return frame[4:36], validate_app_start_response(frame) + + def contact(self, key: bytes) -> bytes | None: + frame = self.request(bytes([30]) + key, (1, 3)) + return frame if frame[0] == 3 else None + + def cli(self, command: str) -> str: + return self.request(bytes([0x42]) + command.encode(), (0x1D,))[1:].decode() + + +def run(sender_port: str, recipient_port: str, reset_contact: bool, + zero_hop: bool, invalid_signature_first: bool) -> dict: + sender = Link(sender_port) + recipient = Link(recipient_port) + prior_dm_setting = None + try: + sender_key, sender_info = sender.start() + recipient_key, recipient_info = recipient.start() + prior_dm_setting = recipient.cli("get dm.one_key") + if prior_dm_setting not in ("> on", "> off"): + raise RuntimeError("recipient did not report its one-key DM policy") + if not recipient.cli("set dm.one_key off").endswith("dm.one_key off"): + raise RuntimeError("recipient could not enable manual DM acceptance") + radio_fields = ("frequency_khz", "bandwidth_hz", "spreading_factor", "coding_rate") + if any(sender_info[field] != recipient_info[field] for field in radio_fields): + raise RuntimeError("radios do not use the same frequency, bandwidth, SF, and CR") + + if recipient.contact(sender_key) is not None: + if not reset_contact: + raise RuntimeError("recipient already knows sender; pass --reset-contact to remove it") + recipient.request(bytes([15]) + sender_key, (0,)) + if recipient.contact(sender_key) is not None: + raise RuntimeError("recipient still knows sender") + + contact = sender.contact(recipient_key) + if contact is None: + advert = recipient.request(bytes([17]), (11,))[1:] + sender.request(bytes([18]) + advert, (0,)) + for _ in range(10): + time.sleep(0.25) + contact = sender.contact(recipient_key) + if contact is not None: + break + if contact is None: + raise RuntimeError("sender could not import recipient's signed advert") + + if zero_hop: + # Contact response and CMD_ADD_UPDATE_CONTACT share the same body. + update = bytearray(contact) + update[0] = 9 + update[35] = 0 # zero path hashes + update[36:100] = bytes(64) + sender.request(bytes(update), (0,)) + contact = sender.contact(recipient_key) + if contact is None: + raise RuntimeError("sender contact vanished") + + if invalid_signature_first: + # CMD_SEND_ANON_REQ prepends the four-byte tag itself. This has a + # valid ECDH envelope but an invalid Ed25519 identity signature. + bogus = b"DMK1forged\0" + bytes(64) + sender.request(bytes([0x39]) + recipient_key + bogus, (6,), seconds=20) + time.sleep(2) + if recipient.contact(sender_key) is not None: + raise AssertionError("invalid introduction created a contact") + + timestamp = int(time.time()) + text = f"one-key DM HIL {timestamp}".encode() + message = bytes([2, 0, 0]) + struct.pack(" on": + try: + recipient.cli("set dm.one_key on") + except Exception: + pass + sender.close() + recipient.close() + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--sender", required=True, help="sender Companion USB serial path") + parser.add_argument("--recipient", required=True, help="recipient Companion USB serial path") + parser.add_argument("--reset-contact", action="store_true", + help="remove the sender from recipient contacts before the test") + parser.add_argument("--zero-hop", action="store_true", + help="set a direct zero-hop path; use only when both radios are nearby") + parser.add_argument("--invalid-signature-first", action="store_true", + help="verify an encrypted introduction with a bad signature is rejected") + args = parser.parse_args() + print(json.dumps(run(args.sender, args.recipient, args.reset_contact, + args.zero_hop, args.invalid_signature_first), + sort_keys=True)) diff --git a/variants/rak4631/platformio.ini b/variants/rak4631/platformio.ini index 7250231db..24d7bd8cc 100644 --- a/variants/rak4631/platformio.ini +++ b/variants/rak4631/platformio.ini @@ -369,6 +369,7 @@ build_flags = -D DISPLAY_CLASS=SSD1306Display -D MAX_CONTACTS=350 -D MAX_GROUP_CHANNELS=40 + -D MESH_CONTACT_CACHE=1 -D ENABLE_USB_INTERFACE ; NOTE: DO NOT ENABLE --> -D MESH_PACKET_LOGGING=1 ; NOTE: DO NOT ENABLE --> -D MESH_DEBUG=1 @@ -440,6 +441,7 @@ build_flags = -D DISPLAY_CLASS=SSD1306Display -D MAX_CONTACTS=350 -D MAX_GROUP_CHANNELS=40 + -D MESH_CONTACT_CACHE=1 -D BLE_PIN_CODE=123456 ; -D BLE_DEBUG_LOGGING=1 -D OFFLINE_QUEUE_SIZE=256