Merge remote-tracking branch 'origin/keymindCascade' into keymindCascade

This commit is contained in:
mikecarper committed 2026-09-28 17:46:45 -07:00
commit 666150ab2f
20 files changed
+1307 -24

No files matched your search

+4
View File
@@ -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
+7 -4
View File
@@ -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
+13 -3
View File
@@ -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
+47 -1
View File
@@ -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
+30
View File
@@ -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 |
+11
View File
@@ -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);
+505 -3
View File
@@ -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*>(&timestamp), 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(&timestamp, 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, &timestamp, 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(&timestamp, 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(&timestamp, 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()) {
+88
View File
@@ -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;
+2
View File
@@ -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
View File
@@ -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);
+5
View File
@@ -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
+17 -6
View File
@@ -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) {
+3 -2
View File
@@ -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,
+1 -1
View File
@@ -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")) {
+7 -1
View File
@@ -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);
+1
View File
@@ -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
+13 -1
View File
@@ -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)
+315
View File
@@ -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, &timestamp, 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, &timestamp, 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, &timestamp, 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()
+233
View File
@@ -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))
+2
View File
@@ -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