Merge remote-tracking branch 'origin/keymindCascade' into keymindCascade
This commit is contained in:
commit
666150ab2f
20 files changed
+1307
-24
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<MyMesh*>(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<const char*>(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<const char*>(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<const uint8_t*>(×tamp), sizeof(timestamp),
|
||||
reinterpret_cast<const uint8_t*>(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<const char*>(&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<int8_t>(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<const char*>(&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()) {
|
||||
|
||||
@@ -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 <helpers/BaseChatMesh.h>
|
||||
#include <helpers/TransportKeyStore.h>
|
||||
|
||||
#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_TIMESTAMP_CACHE_SIZE> room_message_timestamps;
|
||||
int next_ack_idx;
|
||||
unsigned long next_ack_expiry;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+3
-2
@@ -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);
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")) {
|
||||
|
||||
Vendored
+7
-1
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 <cassert>
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
#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<uint8_t>(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<uint8_t>(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<uint8_t>(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<uint8_t>(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<uint8_t>(i + 1);
|
||||
mesh.heldDMFrameAt(mesh.held_dm_count).buf[0] = static_cast<uint8_t>(i + 1);
|
||||
++mesh.held_dm_count;
|
||||
}
|
||||
for (int i = 0; i < 241; ++i) {
|
||||
uint8_t frame[2] = {42, static_cast<uint8_t>(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<uint8_t>(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<uint8_t>(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 <cassert>
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#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<int>(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<char*>(encrypted + 5), 20, "msg%02lu",
|
||||
static_cast<unsigned long>(timestamp));
|
||||
assert(receiver.onAddressedTextPacket(&packet, 0x42, encrypted,
|
||||
5 + strlen(reinterpret_cast<char*>(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<unsigned long>(timestamp));
|
||||
assert(strcmp(reinterpret_cast<char*>(out + 4), expected) == 0);
|
||||
}
|
||||
assert(receiver.getFromOfflineQueue(out) == 0);
|
||||
return 0;
|
||||
}
|
||||
'''
|
||||
self.compile_and_run(harness + methods + exercise)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -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("<I", timestamp) + recipient_key[:6] + text
|
||||
sent = sender.request(message, (6,), seconds=20)
|
||||
expected_ack = sent[2:6]
|
||||
timeout_ms = struct.unpack("<I", sent[6:10])[0]
|
||||
offered = None
|
||||
rejected = False
|
||||
until = time.monotonic() + min(timeout_ms / 1000 + 5, 120)
|
||||
while time.monotonic() < until:
|
||||
for link in (sender, recipient):
|
||||
link.read(0.1)
|
||||
offered = next((frame for frame in recipient.pushes
|
||||
if frame[0] == 0x8A and frame[1:33] == sender_key), None)
|
||||
rejected = any(frame[0] == 0x91 and frame[1:33] == recipient_key
|
||||
for frame in sender.pushes)
|
||||
if offered and rejected:
|
||||
break
|
||||
|
||||
if offered is None or not rejected:
|
||||
raise AssertionError("signed introduction did not produce advert and refusal")
|
||||
if recipient.contact(sender_key) is not None:
|
||||
raise AssertionError("recipient added sender without acceptance")
|
||||
if any(frame[0] == 0x83 for frame in recipient.pushes):
|
||||
raise AssertionError("recipient delivered DM before acceptance")
|
||||
|
||||
# The text follows the introduction on air. Ensure it is actually in
|
||||
# the holding queue before accepting, not merely delivered afterward.
|
||||
until = time.monotonic() + min(timeout_ms / 1000 + 5, 120)
|
||||
while time.monotonic() < until and recipient.cli("get dm.held") != "> 1":
|
||||
time.sleep(0.5)
|
||||
if recipient.cli("get dm.held") != "> 1":
|
||||
raise AssertionError("no decryptable DM was held before acceptance")
|
||||
recipient.request(bytes([9]) + offered[1:], (0,))
|
||||
until = time.monotonic() + min(timeout_ms / 1000 + 5, 120)
|
||||
while time.monotonic() < until:
|
||||
for link in (sender, recipient):
|
||||
link.read(0.1)
|
||||
confirmed = any(frame[0] == 0x82 and frame[1:5] == expected_ack
|
||||
for frame in sender.pushes)
|
||||
waiting = any(frame[0] == 0x83 for frame in recipient.pushes)
|
||||
if confirmed and waiting:
|
||||
break
|
||||
|
||||
confirmed = any(
|
||||
frame[0] == 0x82 and frame[1:5] == expected_ack for frame in sender.pushes
|
||||
)
|
||||
waiting = any(frame[0] == 0x83 for frame in recipient.pushes)
|
||||
learned = recipient.contact(sender_key) is not None
|
||||
queued = recipient.request(bytes([10]), (7, 10, 16))
|
||||
replayed = queued[0] in (7, 16) and text in queued
|
||||
result = {
|
||||
"sender_prefix": sender_key[:6].hex(),
|
||||
"recipient_prefix": recipient_key[:6].hex(),
|
||||
"route": "direct" if sent[1] == 0 else "flood",
|
||||
"ack_confirmed": confirmed,
|
||||
"recipient_message_waiting": waiting,
|
||||
"recipient_learned_sender": learned,
|
||||
"synthetic_advert_offered": offered is not None,
|
||||
"signed_refusal_received": rejected,
|
||||
"held_message_replayed": replayed,
|
||||
"timeout_ms": timeout_ms,
|
||||
}
|
||||
if invalid_signature_first:
|
||||
result["invalid_signature_rejected"] = True
|
||||
if not (confirmed and waiting and learned and replayed):
|
||||
raise AssertionError(json.dumps(result, sort_keys=True))
|
||||
return result
|
||||
finally:
|
||||
if prior_dm_setting == "> 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))
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user