diff --git a/docs/companion_protocol.md b/docs/companion_protocol.md index 12d81bc491..dc8142d200 100644 --- a/docs/companion_protocol.md +++ b/docs/companion_protocol.md @@ -628,6 +628,69 @@ 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 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 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 +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, 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 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. +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. +`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`, +`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 +/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 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. + --- ## Response Parsing diff --git a/docs/payloads.md b/docs/payloads.md index 493f628ce6..042c6bc7e7 100644 --- a/docs/payloads.md +++ b/docs/payloads.md @@ -220,6 +220,41 @@ 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. By default it exposes +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 +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 | Field | Size (bytes) | Description | diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index f32ea67aec..e1c51c58cd 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -111,6 +111,13 @@ #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. +#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==" // these are _pushed_ to client app at any time @@ -131,6 +138,7 @@ #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 @@ -221,8 +229,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) { @@ -422,7 +448,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 +457,383 @@ 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 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 expected_ack_table[i].contact; + return contact; } } 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) { + // 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; + rememberOneKeyIntro(contact, tag); + 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) { + 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 (_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 deliver 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; + } + // 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::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 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; + } + 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; ) { + 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 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]; + const int decoded_len = mesh::Utils::MACThenDecrypt( + contact->getSharedSecret(self_id), data, &entry.packet.payload[2], + entry.packet.payload_len - 2); + if (decoded_len > 5 && decoded_len <= MAX_PACKET_PAYLOAD) { + data[decoded_len] = 0; + if ((data[4] >> 2) == TXT_TYPE_PLAIN) { + const char* text = reinterpret_cast(&data[5]); + uint32_t timestamp; + memcpy(×tamp, data, sizeof(timestamp)); + if (strlen(text) <= MAX_TEXT_LEN) { + if (!wasDeliveredOneKeyDM(entry.sender_key, entry.id)) { + onMessageRecv(*contact, &entry.packet, timestamp, text); + rememberDeliveredOneKeyDM(entry.sender_key, entry.id); + } + uint8_t ack_hash[4]; + mesh::Utils::sha256(ack_hash, sizeof(ack_hash), data, 5 + strlen(text), + contact->id.pub_key, PUB_KEY_SIZE); + sendAckTo(*contact, ack_hash, sizeof(ack_hash)); + } + } + } + for (uint8_t j = i + 1; j < held_dm_count; ++j) held_dms[j - 1] = held_dms[j]; + --held_dm_count; +#endif + } +} +#endif // MESH_ENABLE_ONE_KEY_DM + void MyMesh::queueMessage(const ContactInfo &from, uint8_t txt_type, mesh::Packet *pkt, uint32_t sender_timestamp, const uint8_t *extra, int extra_len, const char *text) { int i = 0; @@ -526,8 +923,29 @@ 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; +#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 +#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) { @@ -688,6 +1106,26 @@ 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 && + 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); @@ -1146,19 +1584,30 @@ 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 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, 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; @@ -1329,7 +1778,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); @@ -2122,6 +2586,35 @@ 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; + } + 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) { + _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; + } +#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; + } +#endif + if (memcmp(command, "set pin ", 8) == 0) { _prefs.ble_pin = atoi(&command[8]); savePrefs(); @@ -2426,6 +2919,12 @@ 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 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)) { saveContacts(); diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index 52a22cb419..cca67320ca 100644 --- a/examples/companion_radio/MyMesh.h +++ b/examples/companion_radio/MyMesh.h @@ -58,10 +58,26 @@ #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 +#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 +85,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 @@ -155,6 +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); @@ -274,6 +302,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]; @@ -284,8 +316,58 @@ 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 { + 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); +#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] = {}; + 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 + #define ADVERT_PATH_TABLE_SIZE 16 AdvertPath advert_paths[ADVERT_PATH_TABLE_SIZE]; // circular table }; 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/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.cpp b/src/helpers/BaseChatMesh.cpp index 28592415ca..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) } @@ -228,6 +234,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) @@ -470,7 +480,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 +489,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..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) { @@ -156,7 +157,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/test/test_one_key_dm_shared_queue.py b/test/test_one_key_dm_shared_queue.py new file mode 100644 index 0000000000..c58ed84ea7 --- /dev/null +++ b/test/test_one_key_dm_shared_queue.py @@ -0,0 +1,244 @@ +#!/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_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 ( + "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/tools/hil/one_key_dm.py b/tools/hil/one_key_dm.py new file mode 100644 index 0000000000..a7aa809170 --- /dev/null +++ b/tools/hil/one_key_dm.py @@ -0,0 +1,292 @@ +#!/usr/bin/env python3 +"""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 +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 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, + 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() + 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") + + 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") + 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") + 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() + message = bytes([2, 0, 0]) + struct.pack(" -D MESH_PACKET_LOGGING=1 ; NOTE: DO NOT ENABLE --> -D MESH_DEBUG=1