From c1e07790ec8e04ff78a113dc97207a44f2711b0c Mon Sep 17 00:00:00 2001 From: mikecarper Date: Mon, 28 Sep 2026 02:08:39 -0700 Subject: [PATCH 1/6] Support one-key private messages in Companion --- docs/companion_protocol.md | 18 +++ docs/payloads.md | 23 +++ examples/companion_radio/MyMesh.cpp | 121 ++++++++++++++- examples/companion_radio/MyMesh.h | 11 ++ src/helpers/BaseChatMesh.cpp | 11 +- src/helpers/BaseChatMesh.h | 3 +- tools/hil/one_key_dm.py | 228 ++++++++++++++++++++++++++++ 7 files changed, 405 insertions(+), 10 deletions(-) create mode 100644 tools/hil/one_key_dm.py diff --git a/docs/companion_protocol.md b/docs/companion_protocol.md index 12d81bc491..b0585d4ac0 100644 --- a/docs/companion_protocol.md +++ b/docs/companion_protocol.md @@ -628,6 +628,24 @@ Use the `SEND_CHANNEL_MESSAGE` command (see [Commands](#commands)). - Long messages should be split into chunks - Include a chunk indicator (e.g., "[1/3] message text") +For a private chat contact, `CMD_SEND_TXT_MSG` (`0x02`) remains unchanged. +Before the first unconfirmed DM, Companion firmware sends a signed, encrypted +[one-key introduction](payloads.md#companion-one-key-dm-introduction), then +schedules the ordinary private text packet. The introduction repeats on an +application retry. Once a DM is acknowledged, later first attempts omit the +introduction until the sender reboots or its eight-peer ACK cache is replaced. +The receiver verifies the sender's signature, saves a chat contact even when +advert auto-add is off, and pushes `PUSH_CODE_NEW_ADVERT` before the message +notification. Stock receivers need to learn the sender key by advert or import +before they can decrypt the ordinary DM. The first send consumes an additional +radio packet; the returned send timeout includes the scheduling delay. + +For a two-radio hardware check, run `python3 tools/hil/one_key_dm.py --sender +/dev/ttyACM0 --recipient /dev/ttyACM1 --reset-contact` with two nearby +Companion radios on the same profile. The test checks contact creation, +message notification, and ACK without draining the recipient's message queue. +Add `--invalid-signature-first` to check signature rejection. + --- ## Response Parsing diff --git a/docs/payloads.md b/docs/payloads.md index 493f628ce6..56dd1532aa 100644 --- a/docs/payloads.md +++ b/docs/payloads.md @@ -220,6 +220,29 @@ txt_type | 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 creating a chat +contact. 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 a +verified, addressed `DMK1` introduction. The sender's full public key remains +visible in the radio packet header, as with other anonymous requests. + ### Room server login | Field | Size (bytes) | Description | diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index f32ea67aec..7e25aa9afd 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -111,6 +111,10 @@ #define DIRECT_SEND_PERHOP_EXTRA_MILLIS 250 #define LAZY_CONTACTS_WRITE_DELAY 5000 +// An ANON_REQ already carries its sender's full public key. This marker +// distinguishes a contact introduction from other anonymous requests. +static constexpr uint8_t ONE_KEY_INTRO_MARKER[] = {'D', 'M', 'K', '1'}; + #define PUBLIC_GROUP_PSK "izOH6cXN6mrJ5e26oRXNcg==" // these are _pushed_ to client app at any time @@ -422,7 +426,8 @@ ContactInfo* MyMesh::processAck(const uint8_t *data) { } // see if matches any in a table for (int i = 0; i < EXPECTED_ACK_TABLE_SIZE; i++) { - if (memcmp(data, &expected_ack_table[i].ack, 4) == 0) { // got an ACK from recipient + if (expected_ack_table[i].ack != 0 && + memcmp(data, &expected_ack_table[i].ack, 4) == 0) { // got an ACK from recipient out_frame[0] = PUSH_CODE_SEND_CONFIRMED; memcpy(&out_frame[1], data, 4); uint32_t trip_time = _ms->getMillis() - expected_ack_table[i].msg_sent; @@ -430,13 +435,113 @@ ContactInfo* MyMesh::processAck(const uint8_t *data) { _serial->writeFrame(out_frame, 9); // NOTE: the same ACK can be received multiple times! + ContactInfo* contact = expected_ack_table[i].contact; + if (contact != NULL && contact->type == ADV_TYPE_CHAT) rememberOneKeyAck(*contact); expected_ack_table[i].ack = 0; // clear expected hash, now that we have received ACK - return expected_ack_table[i].contact; + return contact; } } return checkConnectionsAck(data); } +bool MyMesh::hasOneKeyAck(const ContactInfo& contact) const { + for (uint8_t i = 0; i < one_key_acked_count; ++i) { + if (memcmp(one_key_acked_keys[i], contact.id.pub_key, PUB_KEY_SIZE) == 0) return true; + } + return false; +} + +void MyMesh::rememberOneKeyAck(const ContactInfo& contact) { + if (hasOneKeyAck(contact)) return; + memcpy(one_key_acked_keys[one_key_acked_next], contact.id.pub_key, PUB_KEY_SIZE); + if (one_key_acked_count < ONE_KEY_ACKED_PEERS) ++one_key_acked_count; + one_key_acked_next = (one_key_acked_next + 1) % ONE_KEY_ACKED_PEERS; +} + +uint32_t MyMesh::sendOneKeyIntroduction(const ContactInfo& contact) { + // Older Companion firmware ignores this packet. It does not alter the + // ordinary text packet that follows. + 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 = _radio->getEstAirtimeFor(intro->getRawLength()); + uint32_t delay; + if (contact.out_path_len == OUT_PATH_UNKNOWN) { + delay = calcFloodTimeoutMillisFor(airtime) / 2; + sendFloodScoped(contact, intro); + } else { + const uint32_t hops = (contact.out_path_len & 63) + 1; + delay = (airtime * 3 + 500) * hops; + sendDirect(intro, contact.out_path, contact.out_path_len); + } + return delay; +} + +void MyMesh::onAnonDataRecv(mesh::Packet* packet, const uint8_t* secret, + const mesh::Identity& sender, uint8_t* data, + size_t len) { + (void)secret; + if (packet->getPayloadType() != PAYLOAD_TYPE_ANON_REQ || + len < 4 + sizeof(ONE_KEY_INTRO_MARKER) + 2 + SIGNATURE_SIZE || + memcmp(data + 4, ONE_KEY_INTRO_MARKER, + sizeof(ONE_KEY_INTRO_MARKER)) != 0) return; + + const char* name = reinterpret_cast(data + 4 + sizeof(ONE_KEY_INTRO_MARKER)); + size_t available = len - 4 - sizeof(ONE_KEY_INTRO_MARKER) - SIGNATURE_SIZE; + if (available > 32) available = 32; + const char* end = static_cast(memchr(name, 0, available)); + if (end == NULL || end == name || size_t(end - name) >= sizeof(ContactInfo::name)) return; + const size_t signed_body_len = 4 + sizeof(ONE_KEY_INTRO_MARKER) + size_t(end - name) + 1; + if (signed_body_len + SIGNATURE_SIZE > len) return; + uint8_t signed_message[PUB_KEY_SIZE + 4 + sizeof(ONE_KEY_INTRO_MARKER) + 32]; + memcpy(signed_message, self_id.pub_key, PUB_KEY_SIZE); + memcpy(signed_message + PUB_KEY_SIZE, data, signed_body_len); + if (!sender.verify(data + signed_body_len, signed_message, + PUB_KEY_SIZE + signed_body_len)) return; + + ContactInfo* existing = lookupContactByPubKey(sender.pub_key, PUB_KEY_SIZE); + if (existing != NULL && existing->type != ADV_TYPE_NONE) return; + + ContactInfo contact; + memset(&contact, 0, sizeof(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 (!addContact(contact)) { + onContactsFull(); + return; + } + // An earlier anonymous request may have used a reserved transient slot. + // Clear it so lookup for the following text finds the durable contact. + if (existing != NULL) memset(existing, 0, sizeof(*existing)); + ContactInfo* added = lookupContactByPubKey(sender.pub_key, PUB_KEY_SIZE); + if (added == NULL) return; + dirty_contacts_expiry = futureMillis(LAZY_CONTACTS_WRITE_DELAY); + onDiscoveredContact(*added, true, OUT_PATH_UNKNOWN, NULL); +} + 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) { int i = 0; @@ -1146,19 +1251,27 @@ void MyMesh::handleCmdFrame(size_t len) { text[tlen] = 0; // ensure null 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) { msg_timestamp = getRTCClock()->getCurrentTimeUnique(); // Use node's RTC instead of app timestamp to avoid tripping replay protection result = sendCommandData(*recipient, msg_timestamp, attempt, txt_type, text, est_timeout); expected_ack = 0; // no Ack expected } else { - result = sendMessage(*recipient, msg_timestamp, attempt, text, expected_ack, est_timeout); + if (recipient->type == ADV_TYPE_CHAT && + (attempt != 0 || !hasOneKeyAck(*recipient)) && + tlen <= MAX_TEXT_LEN && + (attempt <= 3 || tlen <= MAX_TEXT_LEN - 2)) { + one_key_delay = sendOneKeyIntroduction(*recipient); + } + result = sendMessage(*recipient, msg_timestamp, attempt, text, expected_ack, + est_timeout, one_key_delay); } // TODO: add expected ACK to table if (result == MSG_SEND_FAILED) { writeErrFrame(ERR_CODE_TABLE_FULL); } else { if (expected_ack) { - expected_ack_table[next_ack_idx].msg_sent = _ms->getMillis(); // add to circular table + expected_ack_table[next_ack_idx].msg_sent = _ms->getMillis() + one_key_delay; expected_ack_table[next_ack_idx].ack = expected_ack; expected_ack_table[next_ack_idx].contact = recipient; next_ack_idx = (next_ack_idx + 1) % EXPECTED_ACK_TABLE_SIZE; diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index 52a22cb419..2fce53ee11 100644 --- a/examples/companion_radio/MyMesh.h +++ b/examples/companion_radio/MyMesh.h @@ -155,6 +155,9 @@ class MyMesh : public BaseChatMesh, public DataStoreHost { void onDiscoveredContact(ContactInfo &contact, bool is_new, uint8_t path_len, const uint8_t* path) override; void onContactPathUpdated(const ContactInfo &contact) override; ContactInfo* processAck(const uint8_t *data) override; + void onAnonDataRecv(mesh::Packet* packet, const uint8_t* secret, + const mesh::Identity& sender, uint8_t* data, + size_t len) override; 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); @@ -284,6 +287,14 @@ class MyMesh : public BaseChatMesh, public DataStoreHost { }; #define EXPECTED_ACK_TABLE_SIZE 8 AckTableEntry expected_ack_table[EXPECTED_ACK_TABLE_SIZE]; // circular table + // An ACK proves this peer can decrypt our ordinary private messages. + static constexpr uint8_t ONE_KEY_ACKED_PEERS = 8; + uint8_t one_key_acked_keys[ONE_KEY_ACKED_PEERS][PUB_KEY_SIZE] = {}; + uint8_t one_key_acked_count = 0; + uint8_t one_key_acked_next = 0; + bool hasOneKeyAck(const ContactInfo& contact) const; + void rememberOneKeyAck(const ContactInfo& contact); + uint32_t sendOneKeyIntroduction(const ContactInfo& contact); int next_ack_idx; #define ADVERT_PATH_TABLE_SIZE 16 diff --git a/src/helpers/BaseChatMesh.cpp b/src/helpers/BaseChatMesh.cpp index 28592415ca..60d0a3d2f1 100644 --- a/src/helpers/BaseChatMesh.cpp +++ b/src/helpers/BaseChatMesh.cpp @@ -470,7 +470,8 @@ mesh::Packet* BaseChatMesh::composeMsgPacket(const ContactInfo& recipient, uint3 return createDatagram(PAYLOAD_TYPE_TXT_MSG, recipient.id, recipient.getSharedSecret(self_id), temp, len); } -int BaseChatMesh::sendMessage(const ContactInfo& recipient, uint32_t timestamp, uint8_t attempt, const char* text, uint32_t& expected_ack, uint32_t& est_timeout) { +int BaseChatMesh::sendMessage(const ContactInfo& recipient, uint32_t timestamp, uint8_t attempt, const char* text, + uint32_t& expected_ack, uint32_t& est_timeout, uint32_t delay_millis) { mesh::Packet* pkt = composeMsgPacket(recipient, timestamp, attempt, text, expected_ack); if (pkt == NULL) return MSG_SEND_FAILED; @@ -478,12 +479,12 @@ int BaseChatMesh::sendMessage(const ContactInfo& recipient, uint32_t timestamp, int rc; if (recipient.out_path_len == OUT_PATH_UNKNOWN) { - sendFloodScoped(recipient, pkt); - txt_send_timeout = futureMillis(est_timeout = calcFloodTimeoutMillisFor(t)); + sendFloodScoped(recipient, pkt, delay_millis); + txt_send_timeout = futureMillis(est_timeout = delay_millis + calcFloodTimeoutMillisFor(t)); rc = MSG_SEND_SENT_FLOOD; } else { - sendDirect(pkt, recipient.out_path, recipient.out_path_len); - txt_send_timeout = futureMillis(est_timeout = calcDirectTimeoutMillisFor(t, recipient.out_path_len)); + sendDirect(pkt, recipient.out_path, recipient.out_path_len, delay_millis); + txt_send_timeout = futureMillis(est_timeout = delay_millis + calcDirectTimeoutMillisFor(t, recipient.out_path_len)); rc = MSG_SEND_SENT_DIRECT; } return rc; diff --git a/src/helpers/BaseChatMesh.h b/src/helpers/BaseChatMesh.h index 0a8fdef4f0..095356b655 100644 --- a/src/helpers/BaseChatMesh.h +++ b/src/helpers/BaseChatMesh.h @@ -156,7 +156,8 @@ class BaseChatMesh : public mesh::Mesh { public: mesh::Packet* createSelfAdvert(const char* name); mesh::Packet* createSelfAdvert(const char* name, double lat, double lon); - int sendMessage(const ContactInfo& recipient, uint32_t timestamp, uint8_t attempt, const char* text, uint32_t& expected_ack, uint32_t& est_timeout); + int sendMessage(const ContactInfo& recipient, uint32_t timestamp, uint8_t attempt, const char* text, + uint32_t& expected_ack, uint32_t& est_timeout, 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); bool sendGroupMessage(uint32_t timestamp, mesh::GroupChannel& channel, const char* sender_name, const char* text, int text_len); bool sendGroupData(mesh::GroupChannel& channel, uint8_t* path, uint8_t path_len, uint16_t data_type, const uint8_t* data, int data_len); diff --git a/tools/hil/one_key_dm.py b/tools/hil/one_key_dm.py new file mode 100644 index 0000000000..c99e6d085e --- /dev/null +++ b/tools/hil/one_key_dm.py @@ -0,0 +1,228 @@ +#!/usr/bin/env python3 +"""Exercise a private DM when only its sender knows the recipient's key. + +This test changes the two radios' contact lists and transmits one LoRa DM. +It leaves the recipient's offline message queue untouched. 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 + + +class FrameTimeout(TimeoutError): + pass + + +def encode_host_frame(payload: bytes) -> bytes: + if not 1 <= len(payload) <= 176: + raise ValueError("Companion frame length is outside 1..176") + return b"<" + struct.pack(" None: + self.buffer = bytearray() + + def read_frame(self, port: serial.Serial, seconds: float) -> bytes: + deadline = time.monotonic() + seconds + while time.monotonic() < deadline: + marker = self.buffer.find(b">") + if marker < 0: + self.buffer.clear() + else: + del self.buffer[:marker] + if len(self.buffer) >= 3: + length = self.buffer[1] | self.buffer[2] << 8 + if not 1 <= length <= 176: + del self.buffer[0] + continue + if len(self.buffer) >= 3 + length: + payload = bytes(self.buffer[3:3 + length]) + del self.buffer[:3 + length] + return payload + data = port.read(max(1, min(getattr(port, "in_waiting", 0), 512))) + if data: + self.buffer.extend(data) + raise FrameTimeout("no complete Companion response frame") + + +def validate_app_start_response(frame: bytes) -> dict: + if len(frame) < 58 or frame[0] != 5 or frame[1] != 1: + raise RuntimeError("invalid Companion APP_START response") + frequency_khz, bandwidth_hz = struct.unpack_from(" None: + self.port = serial.Serial(port, 115200, timeout=0.1, write_timeout=2) + self.reader = DeviceFrameReader() + 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(bytes([1]) + bytes(7) + b"OneKeyDM-HIL", + (5,), seconds=5) + break + except TimeoutError: + if attempt == 2: + raise + self.port.close() + time.sleep(1) + self.port.open() + self.reader = DeviceFrameReader() + 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 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) + try: + sender_key, sender_info = sender.start() + recipient_key, recipient_info = recipient.start() + 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(" Date: Mon, 28 Sep 2026 10:55:07 -0700 Subject: [PATCH 2/6] Require consent for one-key DMs by default --- docs/companion_protocol.md | 42 +++++++--- docs/payloads.md | 22 +++-- examples/companion_radio/MyMesh.cpp | 116 +++++++++++++++++++++++++-- examples/companion_radio/MyMesh.h | 18 +++-- examples/companion_radio/NodePrefs.h | 2 + src/helpers/BaseChatMesh.cpp | 4 + tools/hil/one_key_dm.py | 92 ++++++++++++++++++--- 7 files changed, 257 insertions(+), 39 deletions(-) diff --git a/docs/companion_protocol.md b/docs/companion_protocol.md index b0585d4ac0..a3193ee7f5 100644 --- a/docs/companion_protocol.md +++ b/docs/companion_protocol.md @@ -632,18 +632,42 @@ For a private chat contact, `CMD_SEND_TXT_MSG` (`0x02`) remains unchanged. Before the first unconfirmed DM, Companion firmware sends a signed, encrypted [one-key introduction](payloads.md#companion-one-key-dm-introduction), then schedules the ordinary private text packet. The introduction repeats on an -application retry. Once a DM is acknowledged, later first attempts omit the -introduction until the sender reboots or its eight-peer ACK cache is replaced. -The receiver verifies the sender's signature, saves a chat contact even when -advert auto-add is off, and pushes `PUSH_CODE_NEW_ADVERT` before the message -notification. Stock receivers need to learn the sender key by advert or import -before they can decrypt the ordinary DM. The first send consumes an additional -radio packet; the returned send timeout includes the scheduling delay. +application retry unless the receiver has refused it. Once a DM is acknowledged, +later first attempts omit the introduction until the sender reboots or its +eight-peer session cache is replaced. + +`dm.one_key` is **off by default**, including when older preferences are loaded. +With it off, the receiver verifies the introduction, emits a synthetic +`PUSH_CODE_NEW_ADVERT` (`0x8A`) contact record for the app's advert list, and +does not add a contact or decrypt the following DM. The user can add that +record with the ordinary `CMD_ADD_UPDATE_CONTACT` (`0x09`) flow. It is an +app-facing advert record, not a signed broadcast advert packet; it cannot be +used with `CMD_IMPORT_CONTACT` (`0x12`). A signed, encrypted refusal also tells +the sender to stop including introductions for that peer on retries. The sender +emits `PUSH_CODE_ONE_KEY_DM_REJECTED` (`0x91`, followed by the recipient's full +32-byte public key) for apps that want to show the refusal. The refusal cache is +limited to eight peers and resets on reboot or eviction. If the user later adds +the contact, an ordinary retry can be delivered and acknowledged. +Temporary anonymous-request entries do not grant text-DM permission. +The pending advert notification requires a connected app; it is not saved as a +contact on the radio. Existing apps must handle `0x91` to show a visible +refusal, although the sender firmware suppresses repeat introductions itself. + +Use `get dm.one_key` and `set dm.one_key on|off` in the Companion CLI (or send +them through `CMD_RUN_CLI_COMMAND`, `0x42`). The saved preference is +`comp.one_key_dm` (`0` or `1`). With the setting on, a verified unknown sender +is accepted automatically and the first DM is delivered. This setting is +separate from advert auto-add. Neither mode accepts an invalid signature or +overwrites an existing contact. Stock receivers still need to learn the sender +key by advert or import before they can decrypt an ordinary DM. The first send +uses an additional radio packet and its reported timeout includes the delay. For a two-radio hardware check, run `python3 tools/hil/one_key_dm.py --sender /dev/ttyACM0 --recipient /dev/ttyACM1 --reset-contact` with two nearby -Companion radios on the same profile. The test checks contact creation, -message notification, and ACK without draining the recipient's message queue. +Companion radios on the same profile. The default test checks a pending advert, +refusal, manual contact addition, then message notification and ACK without +draining the recipient's message queue. Add `--auto-accept` to test the opt-in +automatic path (restart the sender after a refusal to clear its session cache). Add `--invalid-signature-first` to check signature rejection. --- diff --git a/docs/payloads.md b/docs/payloads.md index 56dd1532aa..92b8479dca 100644 --- a/docs/payloads.md +++ b/docs/payloads.md @@ -236,12 +236,22 @@ without a broadcast advert. The ciphertext contains: 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 creating a chat -contact. 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 a -verified, addressed `DMK1` introduction. The sender's full public key remains -visible in the radio packet header, as with other anonymous requests. +the 32-byte sender key in the anonymous request header. By default it exposes +the sender as a pending, app-facing advert without storing a contact. If +`comp.one_key_dm` is enabled, it stores the sender as a chat contact. 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 sender's full +public key remains visible in the radio packet header, as with other anonymous +requests. + +When automatic acceptance is off, the recipient sends an encrypted +`PAYLOAD_TYPE_RESPONSE` refusal to the sender. Its plaintext is the original +four-byte introduction tag, ASCII `DMR1`, and a 64-byte Ed25519 signature. +The signed bytes are the original sender's full public key followed by the tag +and `DMR1`. The sender verifies the signature and matches the tag to its +outstanding introduction before caching the refusal. It then omits `DMK1` +on retries for that peer. A later ordinary DM can still succeed after the +recipient manually adds the pending contact. ### Room server login diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index 7e25aa9afd..2564151f9f 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -114,6 +114,7 @@ // An ANON_REQ already carries its sender's full public key. This marker // distinguishes a contact introduction from other anonymous requests. static constexpr uint8_t ONE_KEY_INTRO_MARKER[] = {'D', 'M', 'K', '1'}; +static constexpr uint8_t ONE_KEY_REJECT_MARKER[] = {'D', 'M', 'R', '1'}; #define PUBLIC_GROUP_PSK "izOH6cXN6mrJ5e26oRXNcg==" @@ -135,6 +136,7 @@ static constexpr uint8_t ONE_KEY_INTRO_MARKER[] = {'D', 'M', 'K', '1'}; #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 // recipient declined automatic contact creation #define ERR_CODE_UNSUPPORTED_CMD 1 #define ERR_CODE_NOT_FOUND 2 @@ -445,17 +447,63 @@ ContactInfo* MyMesh::processAck(const uint8_t *data) { } bool MyMesh::hasOneKeyAck(const ContactInfo& contact) const { - for (uint8_t i = 0; i < one_key_acked_count; ++i) { - if (memcmp(one_key_acked_keys[i], contact.id.pub_key, PUB_KEY_SIZE) == 0) return true; + 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; - memcpy(one_key_acked_keys[one_key_acked_next], contact.id.pub_key, PUB_KEY_SIZE); - if (one_key_acked_count < ONE_KEY_ACKED_PEERS) ++one_key_acked_count; - one_key_acked_next = (one_key_acked_next + 1) % ONE_KEY_ACKED_PEERS; + 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) { @@ -484,6 +532,7 @@ uint32_t MyMesh::sendOneKeyIntroduction(const ContactInfo& contact) { contact.getSharedSecret(self_id), body, signed_body_len + SIGNATURE_SIZE); if (intro == NULL) return 0; + rememberOneKeyIntro(contact, tag); const uint32_t airtime = _radio->getEstAirtimeFor(intro->getRawLength()); uint32_t delay; if (contact.out_path_len == OUT_PATH_UNKNOWN) { @@ -500,7 +549,6 @@ uint32_t MyMesh::sendOneKeyIntroduction(const ContactInfo& contact) { void MyMesh::onAnonDataRecv(mesh::Packet* packet, const uint8_t* secret, const mesh::Identity& sender, uint8_t* data, size_t len) { - (void)secret; if (packet->getPayloadType() != PAYLOAD_TYPE_ANON_REQ || len < 4 + sizeof(ONE_KEY_INTRO_MARKER) + 2 + SIGNATURE_SIZE || memcmp(data + 4, ONE_KEY_INTRO_MARKER, @@ -529,6 +577,27 @@ void MyMesh::onAnonDataRecv(mesh::Packet* packet, const uint8_t* secret, contact.type = ADV_TYPE_CHAT; contact.out_path_len = OUT_PATH_UNKNOWN; contact.lastmod = getRTCClock()->getCurrentTime(); + if (_prefs.one_key_dm_enabled != 1) { + // Mirror the manual-advert flow: notify the app with a complete, addable + // contact record, but do not store it or decrypt the following DM. This is + // a synthetic advert notification, not a forged signed radio advert. + onDiscoveredContact(contact, true, packet->path_len, packet->path); + + // An authenticated refusal lets the sender stop repeating introductions. + // Bind it to this introduction's tag and the sender's full public key. + 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 (!addContact(contact)) { onContactsFull(); return; @@ -793,6 +862,24 @@ uint8_t MyMesh::onContactRequest(const ContactInfo &contact, uint32_t sender_tim } void MyMesh::onContactResponse(const ContactInfo &contact, const uint8_t *data, uint8_t len) { + // MACThenDecrypt returns a block-aligned length, 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; + } uint32_t tag; memcpy(&tag, data, 4); @@ -1258,6 +1345,7 @@ void MyMesh::handleCmdFrame(size_t len) { expected_ack = 0; // no Ack expected } else { if (recipient->type == ADV_TYPE_CHAT && + !hasOneKeyReject(*recipient) && (attempt != 0 || !hasOneKeyAck(*recipient)) && tlen <= MAX_TEXT_LEN && (attempt <= 3 || tlen <= MAX_TEXT_LEN - 2)) { @@ -2235,6 +2323,22 @@ bool MyMesh::handleCommand(const char* command, uint32_t sender_timestamp, char* return true; } + if (strcmp(command, "get dm.one_key") == 0) { + sprintf(reply, "> %s", _prefs.one_key_dm_enabled == 1 ? "on" : "off"); + return true; + } + if (memcmp(command, "set dm.one_key ", 15) == 0) { + const char* value = &command[15]; + if (strcmp(value, "on") == 0 || strcmp(value, "off") == 0) { + _prefs.one_key_dm_enabled = strcmp(value, "on") == 0; + savePrefs(); + sprintf(reply, "> dm.one_key is now %s", value); + } else { + strcpy(reply, "Error, use on or off"); + } + return true; + } + if (memcmp(command, "set pin ", 8) == 0) { _prefs.ble_pin = atoi(&command[8]); savePrefs(); diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index 2fce53ee11..5c1f407fa8 100644 --- a/examples/companion_radio/MyMesh.h +++ b/examples/companion_radio/MyMesh.h @@ -287,13 +287,21 @@ class MyMesh : public BaseChatMesh, public DataStoreHost { }; #define EXPECTED_ACK_TABLE_SIZE 8 AckTableEntry expected_ack_table[EXPECTED_ACK_TABLE_SIZE]; // circular table - // An ACK proves this peer can decrypt our ordinary private messages. - static constexpr uint8_t ONE_KEY_ACKED_PEERS = 8; - uint8_t one_key_acked_keys[ONE_KEY_ACKED_PEERS][PUB_KEY_SIZE] = {}; - uint8_t one_key_acked_count = 0; - uint8_t one_key_acked_next = 0; + // Bounded session state for introductions, ACKs, and explicit refusals. + 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); int next_ack_idx; diff --git a/examples/companion_radio/NodePrefs.h b/examples/companion_radio/NodePrefs.h index 46a3dbb7c9..cdf18a7831 100644 --- a/examples/companion_radio/NodePrefs.h +++ b/examples/companion_radio/NodePrefs.h @@ -21,6 +21,7 @@ class NodePrefs : public ConfigSerializer { // persisted to file uint8_t cr = 0; uint8_t multi_acks = 0; uint8_t manual_add_contacts = 0; + uint8_t one_key_dm_enabled = 0; // unknown senders require an explicit contact import by default float bw = 0; int8_t tx_power_dbm = 0; uint8_t telemetry_mode_base = 0; @@ -162,6 +163,7 @@ class NodePrefs : public ConfigSerializer { // persisted to file def("vibe_q", _parent->vibe_quiet); def("auto_add", _parent->autoadd_config); // bitmask for auto-add contacts config def("man_add", _parent->manual_add_contacts); + def("one_key_dm", _parent->one_key_dm_enabled); def("tel_base", _parent->telemetry_mode_base); def("tel_loc", _parent->telemetry_mode_loc); def("tel_env", _parent->telemetry_mode_env); diff --git a/src/helpers/BaseChatMesh.cpp b/src/helpers/BaseChatMesh.cpp index 60d0a3d2f1..5cdd01ac41 100644 --- a/src/helpers/BaseChatMesh.cpp +++ b/src/helpers/BaseChatMesh.cpp @@ -228,6 +228,10 @@ void BaseChatMesh::onPeerDataRecv(mesh::Packet* packet, uint8_t type, int sender ContactInfo& from = contacts[i]; + // Anonymous-request peers occupy transient contact slots. Those slots + // permit replies to the request, but must not turn into DM permissions. + 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) diff --git a/tools/hil/one_key_dm.py b/tools/hil/one_key_dm.py index c99e6d085e..87cc06e0e4 100644 --- a/tools/hil/one_key_dm.py +++ b/tools/hil/one_key_dm.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Exercise a private DM when only its sender knows the recipient's key. +"""Exercise default-reject and opt-in one-key private DMs. This test changes the two radios' contact lists and transmits one LoRa DM. It leaves the recipient's offline message queue untouched. Use two nearby @@ -119,11 +119,17 @@ 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: + frame = self.request(bytes([66]) + command.encode(), (29,)) + return frame[1:].decode(errors="replace").strip() + def run(sender_port: str, recipient_port: str, reset_contact: bool, - zero_hop: bool, invalid_signature_first: bool) -> dict: + zero_hop: bool, invalid_signature_first: bool, + auto_accept: bool = False) -> dict: sender = Link(sender_port) recipient = Link(recipient_port) + original_setting = None try: sender_key, sender_info = sender.start() recipient_key, recipient_info = recipient.start() @@ -131,6 +137,15 @@ def run(sender_port: str, recipient_port: str, reset_contact: bool, 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") + original_setting = recipient.cli("get dm.one_key").removeprefix("> ") + if original_setting not in ("on", "off"): + raise RuntimeError(f"unknown one-key DM setting: {original_setting}") + requested_setting = "on" if auto_accept else "off" + if original_setting != requested_setting: + reply = recipient.cli(f"set dm.one_key {requested_setting}") + if f"dm.one_key is now {requested_setting}" not in reply: + raise RuntimeError(f"could not select one-key DM policy: {reply}") + if recipient.contact(sender_key) is not None: if not reset_contact: raise RuntimeError("recipient already knows sender; pass --reset-contact to remove it") @@ -169,6 +184,9 @@ def run(sender_port: str, recipient_port: str, reset_contact: bool, time.sleep(2) if recipient.contact(sender_key) is not None: raise AssertionError("invalid introduction created a contact") + if any(frame[0] == 0x8A and frame[1:33] == sender_key + for frame in recipient.pushes): + raise AssertionError("invalid introduction created a pending advert") timestamp = int(time.time()) text = f"one-key DM HIL {timestamp}".encode() @@ -176,17 +194,55 @@ def run(sender_port: str, recipient_port: str, reset_contact: bool, sent = sender.request(message, (6,), seconds=20) expected_ack = sent[2:6] timeout_ms = struct.unpack(" Date: Mon, 28 Sep 2026 11:28:54 -0700 Subject: [PATCH 3/6] Hold verified one-key DMs until contact acceptance --- docs/companion_protocol.md | 18 ++- docs/payloads.md | 6 +- examples/companion_radio/MyMesh.cpp | 164 +++++++++++++++++++++++++++- examples/companion_radio/MyMesh.h | 30 +++++ src/Mesh.cpp | 6 +- src/Mesh.h | 6 + src/helpers/BaseChatMesh.h | 3 +- tools/hil/one_key_dm.py | 8 +- 8 files changed, 225 insertions(+), 16 deletions(-) diff --git a/docs/companion_protocol.md b/docs/companion_protocol.md index a3193ee7f5..c96eb4a286 100644 --- a/docs/companion_protocol.md +++ b/docs/companion_protocol.md @@ -639,7 +639,7 @@ eight-peer session cache is replaced. `dm.one_key` is **off by default**, including when older preferences are loaded. With it off, the receiver verifies the introduction, emits a synthetic `PUSH_CODE_NEW_ADVERT` (`0x8A`) contact record for the app's advert list, and -does not add a contact or decrypt the following DM. The user can add that +does not add a contact or deliver the following DM. The user can add that record with the ordinary `CMD_ADD_UPDATE_CONTACT` (`0x09`) flow. It is an app-facing advert record, not a signed broadcast advert packet; it cannot be used with `CMD_IMPORT_CONTACT` (`0x12`). A signed, encrypted refusal also tells @@ -647,7 +647,14 @@ the sender to stop including introductions for that peer on retries. The sender emits `PUSH_CODE_ONE_KEY_DM_REJECTED` (`0x91`, followed by the recipient's full 32-byte public key) for apps that want to show the refusal. The refusal cache is limited to eight peers and resets on reboot or eviction. If the user later adds -the contact, an ordinary retry can be delivered and acknowledged. +the contact, already-received DMs can be delivered from the pending queue +without the sender retransmitting them. The receiver holds up to **15 verified, +decryptable text DMs** across pending senders in RAM. It drops the oldest held +DM when a sixteenth arrives, ignores duplicate retries, and waits for space in +the normal offline queue before releasing accepted DMs. Held DMs are lost on +radio reboot; packets that never reached the receiver still require a retry. +Packets received before a valid `DMK1` introduction cannot enter this queue. +Delivery after acceptance is acknowledged to the sender when possible. Temporary anonymous-request entries do not grant text-DM permission. The pending advert notification requires a connected app; it is not saved as a contact on the radio. Existing apps must handle `0x91` to show a visible @@ -665,9 +672,10 @@ uses an additional radio packet and its reported timeout includes the delay. For a two-radio hardware check, run `python3 tools/hil/one_key_dm.py --sender /dev/ttyACM0 --recipient /dev/ttyACM1 --reset-contact` with two nearby Companion radios on the same profile. The default test checks a pending advert, -refusal, manual contact addition, then message notification and ACK without -draining the recipient's message queue. Add `--auto-accept` to test the opt-in -automatic path (restart the sender after a refusal to clear its session cache). +refusal, manual contact addition, then delivery and ACK of the original DM +without a retry or draining the recipient's message queue. Add `--auto-accept` +to test the opt-in automatic path (restart the sender after a refusal to clear +its session cache). Add `--invalid-signature-first` to check signature rejection. --- diff --git a/docs/payloads.md b/docs/payloads.md index 92b8479dca..042c6bc7e7 100644 --- a/docs/payloads.md +++ b/docs/payloads.md @@ -237,8 +237,10 @@ without a broadcast advert. The ciphertext contains: 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. By default it exposes -the sender as a pending, app-facing advert without storing a contact. If -`comp.one_key_dm` is enabled, it stores the sender as a chat contact. A receiver +the sender as a pending, app-facing advert without storing a contact. Up to 15 +verified, decryptable text packets from pending senders can be held in RAM and +delivered after the contact is added, oldest first. If `comp.one_key_dm` is +enabled, it stores the sender as a chat contact. 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 sender's full public key remains visible in the radio packet header, as with other anonymous diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index 2564151f9f..623a3df037 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -578,8 +578,9 @@ void MyMesh::onAnonDataRecv(mesh::Packet* packet, const uint8_t* secret, contact.out_path_len = OUT_PATH_UNKNOWN; contact.lastmod = getRTCClock()->getCurrentTime(); if (_prefs.one_key_dm_enabled != 1) { + rememberVerifiedPendingSender(sender.pub_key); // Mirror the manual-advert flow: notify the app with a complete, addable - // contact record, but do not store it or decrypt the following DM. This is + // contact record, but do not store it or deliver the following DM. This is // a synthetic advert notification, not a forged signed radio advert. onDiscoveredContact(contact, true, packet->path_len, packet->path); @@ -611,6 +612,143 @@ void MyMesh::onAnonDataRecv(mesh::Packet* packet, const uint8_t* secret, onDiscoveredContact(*added, true, OUT_PATH_UNKNOWN, NULL); } +void MyMesh::rememberVerifiedPendingSender(const uint8_t* pub_key) { + for (uint8_t i = 0; i < verified_pending_count; ++i) { + if (memcmp(verified_pending_keys[i], pub_key, PUB_KEY_SIZE) == 0) return; + } + if (verified_pending_count == MAX_HELD_ONE_KEY_DMS) { + for (uint8_t i = 1; i < verified_pending_count; ++i) { + memcpy(verified_pending_keys[i - 1], verified_pending_keys[i], PUB_KEY_SIZE); + } + --verified_pending_count; + } + memcpy(verified_pending_keys[verified_pending_count++], pub_key, PUB_KEY_SIZE); +} + +void MyMesh::forgetVerifiedPendingSender(const uint8_t* pub_key) { + for (uint8_t i = 0; i < verified_pending_count; ++i) { + if (memcmp(verified_pending_keys[i], pub_key, PUB_KEY_SIZE) == 0) { + for (uint8_t j = i + 1; j < verified_pending_count; ++j) { + memcpy(verified_pending_keys[j - 1], verified_pending_keys[j], PUB_KEY_SIZE); + } + --verified_pending_count; + return; + } + } +} + +void MyMesh::makeOneKeyDMId(uint8_t id[ONE_KEY_DM_ID_SIZE], uint32_t timestamp, + const char* text) { + mesh::Utils::sha256(id, ONE_KEY_DM_ID_SIZE, + reinterpret_cast(×tamp), sizeof(timestamp), + reinterpret_cast(text), strlen(text)); +} + +bool MyMesh::wasDeliveredOneKeyDM(const uint8_t* pub_key, + const uint8_t id[ONE_KEY_DM_ID_SIZE]) const { + for (uint8_t i = 0; i < delivered_dm_count; ++i) { + if (memcmp(delivered_dms[i].sender_key, pub_key, PUB_KEY_SIZE) == 0 && + memcmp(delivered_dms[i].id, id, ONE_KEY_DM_ID_SIZE) == 0) return true; + } + return false; +} + +void MyMesh::rememberDeliveredOneKeyDM(const uint8_t* pub_key, + const uint8_t id[ONE_KEY_DM_ID_SIZE]) { + 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; + ContactInfo* existing = lookupContactByPubKey(pub_key, PUB_KEY_SIZE); + if (existing != NULL && existing->type != ADV_TYPE_NONE) continue; + + uint8_t secret[PUB_KEY_SIZE]; + self_id.calcSharedSecret(secret, pub_key); + uint8_t data[MAX_PACKET_PAYLOAD + 1]; + const int decoded_len = mesh::Utils::MACThenDecrypt(secret, data, mac_and_data, len); + if (decoded_len <= 5 || decoded_len > MAX_PACKET_PAYLOAD) continue; + data[decoded_len] = 0; + if ((data[4] >> 2) != TXT_TYPE_PLAIN) continue; + const char* text = reinterpret_cast(&data[5]); + if (strlen(text) > MAX_TEXT_LEN) continue; + + uint32_t timestamp; + memcpy(×tamp, data, sizeof(timestamp)); + uint8_t id[ONE_KEY_DM_ID_SIZE]; + makeOneKeyDMId(id, timestamp, text); + for (uint8_t j = 0; j < held_dm_count; ++j) { + if (memcmp(held_dms[j].sender_key, pub_key, PUB_KEY_SIZE) == 0 && + memcmp(held_dms[j].id, id, ONE_KEY_DM_ID_SIZE) == 0) return true; + } + + // All retained messages have passed MAC/decryption and sender-key checks. + // Keep one global FIFO of 15; the oldest decodable DM rolls off first. + 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; + return true; + } + return false; +} + +void MyMesh::releaseHeldOneKeyDMs() { + for (uint8_t i = 0; i < verified_pending_count; ) { + ContactInfo* contact = lookupContactByPubKey(verified_pending_keys[i], PUB_KEY_SIZE); + if (contact != NULL && contact->type != ADV_TYPE_NONE) { + 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 = lookupContactByPubKey(entry.sender_key, PUB_KEY_SIZE); + if (contact == NULL || contact->type == ADV_TYPE_NONE) { ++i; continue; } + if (offline_queue_len >= OFFLINE_QUEUE_SIZE) break; + + uint8_t data[MAX_PACKET_PAYLOAD + 1]; + const int decoded_len = mesh::Utils::MACThenDecrypt( + contact->getSharedSecret(self_id), data, &entry.packet.payload[2], + entry.packet.payload_len - 2); + if (decoded_len > 5 && decoded_len <= MAX_PACKET_PAYLOAD) { + data[decoded_len] = 0; + if ((data[4] >> 2) == TXT_TYPE_PLAIN) { + const char* text = reinterpret_cast(&data[5]); + uint32_t timestamp; + memcpy(×tamp, data, sizeof(timestamp)); + if (strlen(text) <= MAX_TEXT_LEN) { + if (!wasDeliveredOneKeyDM(entry.sender_key, entry.id)) { + onMessageRecv(*contact, &entry.packet, timestamp, text); + rememberDeliveredOneKeyDM(entry.sender_key, entry.id); + } + uint8_t ack_hash[4]; + mesh::Utils::sha256(ack_hash, sizeof(ack_hash), data, 5 + strlen(text), + contact->id.pub_key, PUB_KEY_SIZE); + sendAckTo(*contact, ack_hash, sizeof(ack_hash)); + } + } + } + for (uint8_t j = i + 1; j < held_dm_count; ++j) held_dms[j - 1] = held_dms[j]; + --held_dm_count; + } +} + 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) { int i = 0; @@ -700,6 +838,9 @@ void 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) { + uint8_t id[ONE_KEY_DM_ID_SIZE]; + makeOneKeyDMId(id, sender_timestamp, text); + if (wasDeliveredOneKeyDM(from.id.pub_key, id)) return; markConnectionActive(from); // in case this is from a server, and we have a connection queueMessage(from, TXT_TYPE_PLAIN, pkt, sender_timestamp, NULL, 0, text); } @@ -1530,7 +1671,22 @@ void MyMesh::handleCmdFrame(size_t len) { uint8_t *pub_key = &cmd_frame[1]; ContactInfo *recipient = lookupContactByPubKey(pub_key, PUB_KEY_SIZE); uint32_t last_mod = getRTCClock()->getCurrentTime(); // fallback value if not present in cmd_frame - if (recipient) { + if (recipient != NULL && recipient->type == ADV_TYPE_NONE) { + // A prior anonymous request can leave this key in a transient slot. + // Accepting an advert must create a durable contact, not merely change + // that slot's type (transient slots are omitted from saved contacts). + ContactInfo contact; + updateContactFromFrame(contact, last_mod, cmd_frame, len); + contact.lastmod = last_mod; + contact.sync_since = 0; + if (addContact(contact)) { + memset(recipient, 0, sizeof(*recipient)); + dirty_contacts_expiry = futureMillis(LAZY_CONTACTS_WRITE_DELAY); + writeOKFrame(); + } else { + writeErrFrame(ERR_CODE_TABLE_FULL); + } + } else if (recipient) { updateContactFromFrame(*recipient, last_mod, cmd_frame, len); recipient->lastmod = last_mod; dirty_contacts_expiry = futureMillis(LAZY_CONTACTS_WRITE_DELAY); @@ -2643,6 +2799,10 @@ void MyMesh::loop() { #endif } + // Contact acceptance can arrive through the app or a signed advert. Hold + // accepted DMs until the ordinary offline queue has room to deliver them. + if (held_dm_count > 0 || verified_pending_count > 0) releaseHeldOneKeyDMs(); + // is there are pending dirty contacts write needed? if (dirty_contacts_expiry && millisHasNowPassed(dirty_contacts_expiry)) { saveContacts(); diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index 5c1f407fa8..e8d81b443f 100644 --- a/examples/companion_radio/MyMesh.h +++ b/examples/companion_radio/MyMesh.h @@ -158,6 +158,8 @@ class MyMesh : public BaseChatMesh, public DataStoreHost { 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; 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); @@ -305,6 +307,34 @@ class MyMesh : public BaseChatMesh, public DataStoreHost { uint32_t sendOneKeyIntroduction(const ContactInfo& contact); int next_ack_idx; + 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]; + mesh::Packet packet; + }; + 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(); + #define ADVERT_PATH_TABLE_SIZE 16 AdvertPath advert_paths[ADVERT_PATH_TABLE_SIZE]; // circular table }; diff --git a/src/Mesh.cpp b/src/Mesh.cpp index c11f37cacf..40ab4b6012 100644 --- a/src/Mesh.cpp +++ b/src/Mesh.cpp @@ -184,6 +184,10 @@ DispatcherAction Mesh::onRecvPacket(Packet* pkt) { break; } } + if (pkt->getPayloadType() == PAYLOAD_TYPE_TXT_MSG && + onAddressedTextPacket(pkt, src_hash, macAndData, pkt->payload_len - i)) { + found = true; + } if (found) { pkt->markDoNotRetransmit(); // packet was for this node, so don't retransmit } else { @@ -738,4 +742,4 @@ void Mesh::sendZeroHop(Packet* packet, uint16_t* transport_codes, uint32_t delay sendPacket(packet, 0, delay_millis); } -} \ No newline at end of file +} diff --git a/src/Mesh.h b/src/Mesh.h index 49a299a6a4..331280abb4 100644 --- a/src/Mesh.h +++ b/src/Mesh.h @@ -94,6 +94,12 @@ class Mesh : public Dispatcher { */ virtual void onPeerDataRecv(Packet* packet, uint8_t type, int sender_idx, const uint8_t* secret, uint8_t* data, size_t len) { } + // Called for addressed text packets after contact lookup. Receivers can + // recognize and retain a packet from a verified, not-yet-added sender. + // Return true when the packet was handled and should not be retransmitted. + virtual bool onAddressedTextPacket(Packet* packet, uint8_t src_hash, + const uint8_t* mac_and_data, size_t len) { return false; } + /** * \brief A TRACE packet has been received. (and has reached the end of its given path) * NOTE: this may have been initiated by another node. diff --git a/src/helpers/BaseChatMesh.h b/src/helpers/BaseChatMesh.h index 095356b655..c514330eb1 100644 --- a/src/helpers/BaseChatMesh.h +++ b/src/helpers/BaseChatMesh.h @@ -75,9 +75,10 @@ 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) { diff --git a/tools/hil/one_key_dm.py b/tools/hil/one_key_dm.py index 87cc06e0e4..a7aa809170 100644 --- a/tools/hil/one_key_dm.py +++ b/tools/hil/one_key_dm.py @@ -230,10 +230,8 @@ def run(sender_port: str, recipient_port: str, reset_contact: bool, recipient.request(bytes([9]) + advertised[1:], (0,)) if recipient.contact(sender_key) is None: raise AssertionError("manual acceptance did not add the sender") - retry = bytes([2, 0, 1]) + struct.pack(" Date: Mon, 28 Sep 2026 12:55:26 -0700 Subject: [PATCH 4/6] Share one-key held DMs with high-contact nRF52 offline queue --- docs/companion_protocol.md | 9 +- examples/companion_radio/MyMesh.cpp | 95 +++++++++++- examples/companion_radio/MyMesh.h | 17 +++ test/test_one_key_dm_shared_queue.py | 212 +++++++++++++++++++++++++++ variants/rak4631/platformio.ini | 1 + 5 files changed, 331 insertions(+), 3 deletions(-) create mode 100644 test/test_one_key_dm_shared_queue.py diff --git a/docs/companion_protocol.md b/docs/companion_protocol.md index c96eb4a286..1fd211ad62 100644 --- a/docs/companion_protocol.md +++ b/docs/companion_protocol.md @@ -650,8 +650,13 @@ limited to eight peers and resets on reboot or eviction. If the user later adds the contact, already-received DMs can be delivered from the pending queue without the sender retransmitting them. The receiver holds up to **15 verified, decryptable text DMs** across pending senders in RAM. It drops the oldest held -DM when a sixteenth arrives, ignores duplicate retries, and waits for space in -the normal offline queue before releasing accepted DMs. Held DMs are lost on +DM when a sixteenth arrives and ignores duplicate retries. High-contact nRF52 +builds with a 256-frame offline queue share that pool with held DMs: all 256 +slots are ordinary-message slots when none are held, and 241 are available +when all 15 held slots are occupied. Held entries are hidden from app sync and +become ordinary messages on acceptance without requiring an additional slot. +Other builds wait for space in the normal offline queue before releasing an +accepted DM. Held DMs are lost on radio reboot; packets that never reached the receiver still require a retry. Packets received before a valid `DMK1` introduction cannot enter this queue. Delivery after acceptance is acknowledged to the sender when possible. diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index 623a3df037..d0594ad8c4 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -227,8 +227,26 @@ bool MyMesh::Frame::isChannelMsg() const { buf[0] == RESP_CODE_CHANNEL_DATA_RECV; } +#if ONE_KEY_DM_SHARED_OFFLINE_QUEUE +MyMesh::Frame& MyMesh::heldDMFrameAt(uint8_t index) { + return offline_queue[OFFLINE_QUEUE_SIZE - 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 + void MyMesh::addToOfflineQueue(const uint8_t frame[], int len) { - if (offline_queue_len >= OFFLINE_QUEUE_SIZE) { + int capacity = OFFLINE_QUEUE_SIZE; +#if ONE_KEY_DM_SHARED_OFFLINE_QUEUE + capacity -= held_dm_count; +#endif + if (offline_queue_len >= capacity) { MESH_DEBUG_PRINTLN("WARN: offline_queue is full!"); int pos = 0; while (pos < offline_queue_len) { @@ -691,6 +709,33 @@ bool MyMesh::onAddressedTextPacket(mesh::Packet* packet, uint8_t src_hash, // All retained messages have passed MAC/decryption and sender-key checks. // Keep one global FIFO of 15; the oldest decodable DM rolls off first. +#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 >= OFFLINE_QUEUE_SIZE) { + // Do not discard an unread private message to hold a new sender's DM. + int pos = 0; + while (pos < offline_queue_len && !offline_queue[pos].isChannelMsg()) ++pos; + if (pos == offline_queue_len) return false; + for (int j = pos; j < offline_queue_len - 1; ++j) { + offline_queue[j] = offline_queue[j + 1]; + } + --offline_queue_len; + if (_listener) _listener->onQueueSizeChanged(offline_queue_len); + } + 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; @@ -699,6 +744,7 @@ bool MyMesh::onAddressedTextPacket(mesh::Packet* packet, uint8_t src_hash, 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; @@ -720,6 +766,38 @@ void MyMesh::releaseHeldOneKeyDMs() { HeldOneKeyDM& entry = held_dms[i]; ContactInfo* contact = lookupContactByPubKey(entry.sender_key, PUB_KEY_SIZE); if (contact == NULL || contact->type == ADV_TYPE_NONE) { ++i; continue; } +#if ONE_KEY_DM_SHARED_OFFLINE_QUEUE + 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)); + const uint8_t path_len = held.buf[8]; + const int8_t snr = static_cast(held.buf[9]); + const 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)); + // Releasing the held frame makes exactly one ordinary queue slot. + 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 >= OFFLINE_QUEUE_SIZE) break; uint8_t data[MAX_PACKET_PAYLOAD + 1]; @@ -746,6 +824,7 @@ void MyMesh::releaseHeldOneKeyDMs() { } for (uint8_t j = i + 1; j < held_dm_count; ++j) held_dms[j - 1] = held_dms[j]; --held_dm_count; +#endif } } @@ -841,8 +920,22 @@ void MyMesh::onMessageRecv(const ContactInfo &from, mesh::Packet *pkt, uint32_t uint8_t id[ONE_KEY_DM_ID_SIZE]; makeOneKeyDMId(id, sender_timestamp, text); if (wasDeliveredOneKeyDM(from.id.pub_key, id)) return; +#if ONE_KEY_DM_SHARED_OFFLINE_QUEUE + bool replaced_held_dm = false; + 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, id, ONE_KEY_DM_ID_SIZE) == 0) { + removeHeldOneKeyDM(i); + replaced_held_dm = true; + break; + } + } +#endif markConnectionActive(from); // in case this is from a server, and we have a connection queueMessage(from, TXT_TYPE_PLAIN, pkt, sender_timestamp, NULL, 0, text); +#if ONE_KEY_DM_SHARED_OFFLINE_QUEUE + if (replaced_held_dm) rememberDeliveredOneKeyDM(from.id.pub_key, id); +#endif } void MyMesh::onCommandDataRecv(const ContactInfo &from, mesh::Packet *pkt, uint32_t sender_timestamp, const char *text) { diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index e8d81b443f..e73b239189 100644 --- a/examples/companion_radio/MyMesh.h +++ b/examples/companion_radio/MyMesh.h @@ -62,6 +62,12 @@ #define OFFLINE_QUEUE_SIZE 16 #endif +#if defined(NRF52_PLATFORM) && MAX_CONTACTS > 300 && OFFLINE_QUEUE_SIZE >= 256 +#define ONE_KEY_DM_SHARED_OFFLINE_QUEUE 1 +#else +#define ONE_KEY_DM_SHARED_OFFLINE_QUEUE 0 +#endif + #ifndef BLE_NAME_PREFIX #define BLE_NAME_PREFIX "MeshCore-" #endif @@ -69,6 +75,11 @@ #include #include +#if ONE_KEY_DM_SHARED_OFFLINE_QUEUE +static_assert(MAX_TEXT_LEN + 12 <= MAX_FRAME_SIZE, + "A held plain DM must fit in one offline frame"); +#endif + /* -------------------------------------------------------------------------------------- */ #define REQ_TYPE_GET_STATUS 0x01 // same as _GET_STATS @@ -279,6 +290,10 @@ class MyMesh : public BaseChatMesh, public DataStoreHost { bool isChannelMsg() const; }; +#if ONE_KEY_DM_SHARED_OFFLINE_QUEUE + Frame& heldDMFrameAt(uint8_t index); + void removeHeldOneKeyDM(uint8_t index); +#endif int offline_queue_len; Frame offline_queue[OFFLINE_QUEUE_SIZE]; @@ -314,7 +329,9 @@ class MyMesh : public BaseChatMesh, public DataStoreHost { 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; diff --git a/test/test_one_key_dm_shared_queue.py b/test/test_one_key_dm_shared_queue.py new file mode 100644 index 0000000000..976fe446f3 --- /dev/null +++ b/test/test_one_key_dm_shared_queue.py @@ -0,0 +1,212 @@ +#!/usr/bin/env python3 +"""Run the One-Key branch's shared queue and DM release methods on the host.""" + +from pathlib import Path +import os +import subprocess +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[1] + + +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 SharedOneKeyQueueTest(unittest.TestCase): + def test_256_slots_and_fifteen_held_dms(self): + source = (ROOT / "examples/companion_radio/MyMesh.cpp").read_text() + methods = "\n\n".join(method(source, signature) for signature in ( + "bool MyMesh::Frame::isChannelMsg() const", + "MyMesh::Frame& MyMesh::heldDMFrameAt(uint8_t index)", + "void MyMesh::removeHeldOneKeyDM(uint8_t index)", + "void MyMesh::addToOfflineQueue(const uint8_t frame[], int len)", + "int MyMesh::getFromOfflineQueue(uint8_t frame[])", + "bool MyMesh::onAddressedTextPacket(mesh::Packet* packet, uint8_t src_hash,", + "void MyMesh::releaseHeldOneKeyDMs()", + )) + harness = r''' +#include +#include +#include +#include +#define ONE_KEY_DM_SHARED_OFFLINE_QUEUE 1 +#define OFFLINE_QUEUE_SIZE 256 +#define MAX_FRAME_SIZE 176 +#define MAX_PACKET_PAYLOAD 184 +#define MAX_TEXT_LEN 160 +#define PUB_KEY_SIZE 32 +#define TXT_TYPE_PLAIN 0 +#define ADV_TYPE_NONE 0 +#define ROUTE_TYPE_DIRECT 2 +#define ROUTE_TYPE_FLOOD 1 +#define RESP_CODE_CHANNEL_MSG_RECV 0x8c +#define RESP_CODE_CHANNEL_MSG_RECV_V3 0x90 +#define RESP_CODE_CHANNEL_DATA_RECV 0x8d +#define MESH_DEBUG_PRINTLN(...) ((void)0) +namespace mesh { +struct Packet { + uint8_t header = ROUTE_TYPE_FLOOD; + uint8_t path_len = 0; + int8_t _snr = 0; + bool isRouteFlood() const { return header == ROUTE_TYPE_FLOOD; } +}; +struct Utils { + static int MACThenDecrypt(const uint8_t*, uint8_t* out, + const uint8_t* in, size_t len) { + memcpy(out, in, len); + return static_cast(len); + } + static void sha256(uint8_t* out, size_t out_len, const uint8_t* in, + size_t in_len, const uint8_t*, size_t) { + for (size_t i = 0; i < out_len; ++i) out[i] = in[i % in_len]; + } +}; +} +struct ContactInfo { + struct { uint8_t pub_key[PUB_KEY_SIZE] = {}; } id; + uint8_t type = 1; +}; +struct SelfIdentity { + void calcSharedSecret(uint8_t* secret, const uint8_t*) { + memset(secret, 0, PUB_KEY_SIZE); + } +}; +struct Listener { void onQueueSizeChanged(int) {} }; +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; + 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; + Listener* _listener = nullptr; + Frame& heldDMFrameAt(uint8_t index); + void removeHeldOneKeyDM(uint8_t index); + void 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* lookupContactByPubKey(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); + addToOfflineQueue(frame, 5 + strlen(text)); + } + void sendAckTo(const ContactInfo&, const uint8_t*, uint8_t) { ++ack_count; } +}; +static_assert(sizeof(MyMesh::Frame) == 177); +''' + exercise = r''' +int main() { + MyMesh ordinary; + uint8_t out[MAX_FRAME_SIZE] = {}; + for (uint32_t i = 0; i < 256; ++i) { + uint8_t frame[2] = {42, static_cast(i)}; + ordinary.addToOfflineQueue(frame, 2); + } + assert(ordinary.offline_queue_len == 256); + for (uint32_t i = 0; i < 256; ++i) { + assert(ordinary.getFromOfflineQueue(out) == 2); + assert(out[1] == static_cast(i)); + } + + MyMesh receiver; + receiver.contact.id.pub_key[0] = 0x42; + receiver.verified_pending_count = 1; + receiver.verified_pending_keys[0][0] = 0x42; + for (uint32_t i = 0; i < 241; ++i) { + uint8_t frame[2] = {42, static_cast(i)}; + receiver.addToOfflineQueue(frame, 2); + } + mesh::Packet packet; + packet._snr = 12; + for (uint32_t timestamp = 1; timestamp <= 16; ++timestamp) { + uint8_t encrypted[32] = {}; + memcpy(encrypted, ×tamp, 4); + encrypted[4] = TXT_TYPE_PLAIN << 2; + snprintf(reinterpret_cast(encrypted + 5), 20, "msg%02lu", + static_cast(timestamp)); + assert(receiver.onAddressedTextPacket(&packet, 0x42, encrypted, + 5 + strlen(reinterpret_cast(encrypted + 5)))); + } + assert(receiver.held_dm_count == 15); + assert(receiver.offline_queue_len == 241); // Held DMs are invisible to app sync. + receiver.accepted = true; + receiver.releaseHeldOneKeyDMs(); + assert(receiver.held_dm_count == 0); + assert(receiver.offline_queue_len == 256); + assert(receiver.ack_count == 15); + for (uint32_t i = 0; i < 241; ++i) { + assert(receiver.getFromOfflineQueue(out) == 2); + assert(out[1] == static_cast(i)); + } + for (uint32_t timestamp = 2; timestamp <= 16; ++timestamp) { + assert(receiver.getFromOfflineQueue(out) > 5); + uint32_t delivered_timestamp; + memcpy(&delivered_timestamp, out, 4); + assert(delivered_timestamp == timestamp); + } + return 0; +} +''' + with tempfile.TemporaryDirectory(prefix="meshcore-shared-dm-") as temp: + path = Path(temp) / "shared_dm.cpp" + binary = Path(temp) / "shared_dm.exe" + path.write_text(harness + methods + exercise) + flags = ["-fsanitize=address,undefined"] if os.name != "nt" else [] + built = subprocess.run( + ["c++", "-std=c++17", "-O0", *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) + + +if __name__ == "__main__": + unittest.main() diff --git a/variants/rak4631/platformio.ini b/variants/rak4631/platformio.ini index 0dfbf79775..f99bb7c054 100644 --- a/variants/rak4631/platformio.ini +++ b/variants/rak4631/platformio.ini @@ -165,6 +165,7 @@ build_flags = -D DISPLAY_CLASS=SSD1306Display -D MAX_CONTACTS=350 -D MAX_GROUP_CHANNELS=40 + -D OFFLINE_QUEUE_SIZE=256 -D ENABLE_USB_INTERFACE ; NOTE: DO NOT ENABLE --> -D MESH_PACKET_LOGGING=1 ; NOTE: DO NOT ENABLE --> -D MESH_DEBUG=1 From 42d8caa009956316c25dfe1612197e37ff234724 Mon Sep 17 00:00:00 2001 From: mikecarper Date: Mon, 28 Sep 2026 14:13:36 -0700 Subject: [PATCH 5/6] Prioritize accepted DM contacts and fit small STM32 builds --- docs/companion_protocol.md | 5 +++++ examples/companion_radio/MyMesh.cpp | 24 ++++++++++++++++++++++++ examples/companion_radio/MyMesh.h | 16 ++++++++++++++++ src/helpers/BaseChatMesh.cpp | 8 +++++++- 4 files changed, 52 insertions(+), 1 deletion(-) diff --git a/docs/companion_protocol.md b/docs/companion_protocol.md index 1fd211ad62..4b8c2e6efa 100644 --- a/docs/companion_protocol.md +++ b/docs/companion_protocol.md @@ -674,6 +674,11 @@ overwrites an existing contact. Stock receivers still need to learn the sender key by advert or import before they can decrypt an ordinary DM. The first send uses an additional radio packet and its reported timeout includes the delay. +Flash-constrained STM32WL Companion builds omit one-key DM support to retain +their existing filesystem boundary. On those builds, `get dm.one_key` and +`set dm.one_key on|off` report `Error: one-key DMs unsupported on this build`. +Ordinary contact-based private messages are unaffected. + For a two-radio hardware check, run `python3 tools/hil/one_key_dm.py --sender /dev/ttyACM0 --recipient /dev/ttyACM1 --reset-contact` with two nearby Companion radios on the same profile. The default test checks a pending advert, diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index d0594ad8c4..1d20d16038 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -113,8 +113,10 @@ // An ANON_REQ already carries its sender's full public key. This marker // distinguishes a contact introduction from other anonymous requests. +#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 #define PUBLIC_GROUP_PSK "izOH6cXN6mrJ5e26oRXNcg==" @@ -456,7 +458,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 expected_ack_table[i].ack = 0; // clear expected hash, now that we have received ACK return contact; } @@ -464,6 +468,7 @@ 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 && @@ -827,6 +832,7 @@ void MyMesh::releaseHeldOneKeyDMs() { #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) { @@ -917,6 +923,7 @@ void 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 id[ONE_KEY_DM_ID_SIZE]; makeOneKeyDMId(id, sender_timestamp, text); if (wasDeliveredOneKeyDM(from.id.pub_key, id)) return; @@ -930,12 +937,15 @@ void MyMesh::onMessageRecv(const ContactInfo &from, mesh::Packet *pkt, uint32_t break; } } +#endif #endif markConnectionActive(from); // in case this is from a server, and we have a connection 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, id); #endif +#endif } void MyMesh::onCommandDataRecv(const ContactInfo &from, mesh::Packet *pkt, uint32_t sender_timestamp, const char *text) { @@ -1096,6 +1106,7 @@ 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 MESH_ENABLE_ONE_KEY_DM // MACThenDecrypt returns a block-aligned length, 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 && @@ -1114,6 +1125,7 @@ void MyMesh::onContactResponse(const ContactInfo &contact, const uint8_t *data, } return; } +#endif uint32_t tag; memcpy(&tag, data, 4); @@ -1578,6 +1590,7 @@ void MyMesh::handleCmdFrame(size_t len) { result = sendCommandData(*recipient, msg_timestamp, attempt, txt_type, text, est_timeout); expected_ack = 0; // no Ack expected } else { +#if MESH_ENABLE_ONE_KEY_DM if (recipient->type == ADV_TYPE_CHAT && !hasOneKeyReject(*recipient) && (attempt != 0 || !hasOneKeyAck(*recipient)) && @@ -1585,6 +1598,7 @@ void MyMesh::handleCmdFrame(size_t 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, one_key_delay); } @@ -2572,6 +2586,7 @@ bool MyMesh::handleCommand(const char* command, uint32_t sender_timestamp, char* return true; } +#if MESH_ENABLE_ONE_KEY_DM if (strcmp(command, "get dm.one_key") == 0) { sprintf(reply, "> %s", _prefs.one_key_dm_enabled == 1 ? "on" : "off"); return true; @@ -2587,6 +2602,13 @@ bool MyMesh::handleCommand(const char* command, uint32_t sender_timestamp, char* } return true; } +#else + if (strcmp(command, "get dm.one_key") == 0 || + strncmp(command, "set dm.one_key ", 15) == 0) { + strcpy(reply, "Error: one-key DMs unsupported on this build"); + return true; + } +#endif if (memcmp(command, "set pin ", 8) == 0) { _prefs.ble_pin = atoi(&command[8]); @@ -2894,7 +2916,9 @@ void MyMesh::loop() { // Contact acceptance can arrive through the app or a signed advert. Hold // accepted DMs until the ordinary offline queue has room to deliver them. +#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 (dirty_contacts_expiry && millisHasNowPassed(dirty_contacts_expiry)) { diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index e73b239189..cca67320ca 100644 --- a/examples/companion_radio/MyMesh.h +++ b/examples/companion_radio/MyMesh.h @@ -58,6 +58,16 @@ #define MAX_CONTACTS 100 #endif +// The small STM32WL Companion images cannot fit this feature without moving +// their existing filesystem boundary. +#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 + #ifndef OFFLINE_QUEUE_SIZE #define OFFLINE_QUEUE_SIZE 16 #endif @@ -166,11 +176,13 @@ class MyMesh : public BaseChatMesh, public DataStoreHost { void onDiscoveredContact(ContactInfo &contact, bool is_new, uint8_t path_len, const uint8_t* path) override; void onContactPathUpdated(const ContactInfo &contact) override; 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); @@ -304,6 +316,7 @@ class MyMesh : public BaseChatMesh, public DataStoreHost { }; #define EXPECTED_ACK_TABLE_SIZE 8 AckTableEntry expected_ack_table[EXPECTED_ACK_TABLE_SIZE]; // circular table +#if MESH_ENABLE_ONE_KEY_DM // Bounded session state for introductions, ACKs, and explicit refusals. static constexpr uint8_t ONE_KEY_PEERS = 8; struct OneKeyPeerState { @@ -320,8 +333,10 @@ class MyMesh : public BaseChatMesh, public DataStoreHost { void rememberOneKeyAck(const ContactInfo& contact); bool rememberOneKeyReject(const ContactInfo& contact, uint32_t tag); uint32_t sendOneKeyIntroduction(const ContactInfo& contact); +#endif int next_ack_idx; +#if MESH_ENABLE_ONE_KEY_DM 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] = {}; @@ -351,6 +366,7 @@ class MyMesh : public BaseChatMesh, public DataStoreHost { void rememberDeliveredOneKeyDM(const uint8_t* pub_key, const uint8_t id[ONE_KEY_DM_ID_SIZE]); void releaseHeldOneKeyDMs(); +#endif #define ADVERT_PATH_TABLE_SIZE 16 AdvertPath advert_paths[ADVERT_PATH_TABLE_SIZE]; // circular table diff --git a/src/helpers/BaseChatMesh.cpp b/src/helpers/BaseChatMesh.cpp index 5cdd01ac41..9d306df7a8 100644 --- a/src/helpers/BaseChatMesh.cpp +++ b/src/helpers/BaseChatMesh.cpp @@ -202,7 +202,13 @@ 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++) { + // An accepted contact must take priority over a matching transient slot. + 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) } From 406fce62ea4bfba0a79f02ff3513896c3d1c127c Mon Sep 17 00:00:00 2001 From: mikecarper Date: Mon, 28 Sep 2026 15:04:15 -0700 Subject: [PATCH 6/6] Expose held one-key DM count in Companion CLI --- docs/companion_protocol.md | 7 ++++-- examples/companion_radio/MyMesh.cpp | 5 +++++ test/test_one_key_dm_shared_queue.py | 32 ++++++++++++++++++++++++++++ 3 files changed, 42 insertions(+), 2 deletions(-) diff --git a/docs/companion_protocol.md b/docs/companion_protocol.md index 4b8c2e6efa..dc8142d200 100644 --- a/docs/companion_protocol.md +++ b/docs/companion_protocol.md @@ -673,10 +673,13 @@ separate from advert auto-add. Neither mode accepts an invalid signature or overwrites an existing contact. Stock receivers still need to learn the sender key by advert or import before they can decrypt an ordinary DM. The first send uses an additional radio packet and its reported timeout includes the delay. +`get dm.held` reports the current number of verified, decryptable DMs waiting +for contact acceptance (0-15); it is read-only and does not expose message text. Flash-constrained STM32WL Companion builds omit one-key DM support to retain -their existing filesystem boundary. On those builds, `get dm.one_key` and -`set dm.one_key on|off` report `Error: one-key DMs unsupported on this build`. +their existing filesystem boundary. On those builds, `get dm.one_key`, +`set dm.one_key on|off`, and `get dm.held` report +`Error: one-key DMs unsupported on this build`. Ordinary contact-based private messages are unaffected. For a two-radio hardware check, run `python3 tools/hil/one_key_dm.py --sender diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index 1d20d16038..e1c51c58cd 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -2591,6 +2591,10 @@ bool MyMesh::handleCommand(const char* command, uint32_t sender_timestamp, char* sprintf(reply, "> %s", _prefs.one_key_dm_enabled == 1 ? "on" : "off"); return true; } + if (strcmp(command, "get dm.held") == 0) { + sprintf(reply, "> %u", (unsigned)held_dm_count); + return true; + } if (memcmp(command, "set dm.one_key ", 15) == 0) { const char* value = &command[15]; if (strcmp(value, "on") == 0 || strcmp(value, "off") == 0) { @@ -2604,6 +2608,7 @@ bool MyMesh::handleCommand(const char* command, uint32_t sender_timestamp, char* } #else if (strcmp(command, "get dm.one_key") == 0 || + strcmp(command, "get dm.held") == 0 || strncmp(command, "set dm.one_key ", 15) == 0) { strcpy(reply, "Error: one-key DMs unsupported on this build"); return true; diff --git a/test/test_one_key_dm_shared_queue.py b/test/test_one_key_dm_shared_queue.py index 976fe446f3..c58ed84ea7 100644 --- a/test/test_one_key_dm_shared_queue.py +++ b/test/test_one_key_dm_shared_queue.py @@ -21,6 +21,38 @@ def method(source: str, signature: str) -> str: class SharedOneKeyQueueTest(unittest.TestCase): + def test_held_count_cli_reports_current_queue_length(self): + source = (ROOT / "examples/companion_radio/MyMesh.cpp").read_text() + getter = method(source, 'if (strcmp(command, "get dm.held") == 0)') + harness = f''' +#include +#include +#include +bool query(const char* command, char* reply, unsigned held_dm_count) {{ +{getter} + return false; +}} +int main() {{ + char reply[16] = {{}}; + assert(query("get dm.held", reply, 0)); + assert(strcmp(reply, "> 0") == 0); + assert(query("get dm.held", reply, 15)); + assert(strcmp(reply, "> 15") == 0); + assert(!query("get dm.one_key", reply, 15)); +}} +''' + with tempfile.TemporaryDirectory(prefix="meshcore-held-dm-cli-") as temp: + path = Path(temp) / "held_dm_cli.cpp" + binary = Path(temp) / "held_dm_cli.exe" + path.write_text(harness) + built = subprocess.run( + ["c++", "-std=c++17", 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_256_slots_and_fifteen_held_dms(self): source = (ROOT / "examples/companion_radio/MyMesh.cpp").read_text() methods = "\n\n".join(method(source, signature) for signature in (