diff --git a/docs/companion_offline_queue.md b/docs/companion_offline_queue.md index e003a0d1..062f6e98 100644 --- a/docs/companion_offline_queue.md +++ b/docs/companion_offline_queue.md @@ -11,7 +11,8 @@ A reboot clears it. | --- | ---: | | ESP32 with configured PSRAM | 512 | | ESP32 without PSRAM | 256 | -| nRF52840 | 256 | +| nRF52840 with at most 300 contact slots | 256 | +| nRF52840 with more than 300 contact slots (non-shared queue) | 208 | | nRF52 Full Companion with the memory correction | 256 normally; 128 while mOTA owns shared storage | | RP2040 | 256 | | STM32 | 16 | @@ -44,7 +45,9 @@ Full profiles require at least 72 KiB of heap space at link time; OLED and headless profiles use their own [memory budgets](firmware_memory_budget.md). Standard, logging, MQTT, and Cascade build overlays retain the selected target -capacity; they do not silently shrink the queue. +capacity. The high-contact nRF52 default above is the exception: it reserves +RAM for the separate 15-entry consent-pending DM queue. A target's explicit +`OFFLINE_QUEUE_SIZE` still takes precedence. Each queue slot currently costs 177 bytes. A 256-frame queue reserves 45,312 bytes, while a 512-frame queue reserves 90,624 bytes. There is no 256-frame diff --git a/docs/companion_protocol.md b/docs/companion_protocol.md index 5f0120ea..92af36f6 100644 --- a/docs/companion_protocol.md +++ b/docs/companion_protocol.md @@ -255,19 +255,28 @@ that the recipient can decrypt normal DMs, so subsequent first attempts omit the extra packet until the sender reboots or the small recent-ACK cache is replaced. -The receiving Companion verifies the introduction signature and adds the -sender as a chat contact even when advert auto-add is off. It emits the usual -`PUSH_CODE_NEW_ADVERT` contact frame before the normal private-message frame, -so existing clients can associate the six-byte message prefix with the full -key. A recipient running older firmware still needs the sender contact before -it can decrypt a normal DM. This flow uses a second radio packet for the -initial DM; `RESP_CODE_SENT` includes that scheduling delay in its timeout. +The receiving Companion verifies the introduction signature. By default, +`dm.one_key` is `off`: the sender is offered to the app as a synthetic +`PUSH_CODE_NEW_ADVERT` (`0x8A`) but is not added as a contact. The app/user +accepts through the normal add-contact command. Up to 15 verified, decryptable +plain DMs are held in RAM and delivered after acceptance; the oldest rolls +off first. The queue is lost on reboot. On high-contact nRF52 builds, the +ordinary offline queue is 208 frames (previously 256) to retain the runtime +heap margin for this separate 15-DM queue. The device sends a signed `DMR1` +refusal, and the sender pushes `0x91` plus the full rejecting key to a +connected app, then stops further introductions for that peer until reboot. +Directly attached clients may use `get dm.one_key` or `set dm.one_key on|off`; +`get dm.held` reports the number of held DMs. `on` auto-accepts verified senders. +A recipient running older firmware still +needs the sender contact before it can decrypt a normal DM. This flow uses +a second radio packet for the initial DM; `RESP_CODE_SENT` includes that +scheduling delay in its timeout. For a two-radio regression test, run `tools/hil/one_key_dm.py` with the USB serial paths for `--sender` and `--recipient`. `--reset-contact` removes the sender from recipient contacts, and `--zero-hop` sets the sender's route to -zero hop when the devices are nearby. The test checks contact creation, -message waiting, and the matching ACK without draining the offline queue. +zero hop when the devices are nearby. The test checks the synthetic advert, +refusal, manual acceptance, held-message delivery, and matching ACK. `--invalid-signature-first` also checks that a decryptable introduction with an invalid Ed25519 signature does not add a contact. @@ -977,13 +986,14 @@ Byte values are authoritative; names are aliases. When reading firmware source, | `0x87` | `PUSH_CODE_STATUS_RESPONSE` | Server status response. | | `0x88` | `PUSH_CODE_LOG_RX_DATA` | Radio receive log data. | | `0x89` | `PUSH_CODE_TRACE_DATA` | Completed trace data. | -| `0x8A` | `PUSH_CODE_NEW_ADVERT` | Newly stored contact advertisement. | +| `0x8A` | `PUSH_CODE_NEW_ADVERT` | New advert; a verified one-key DM may use this as an addable synthetic advert without storing the contact. | | `0x8B` | `PUSH_CODE_TELEMETRY_RESPONSE` | Telemetry response. | | `0x8C` | `PUSH_CODE_BINARY_RESPONSE` | Binary request response. | | `0x8D` | `PUSH_CODE_PATH_DISCOVERY_RESPONSE` | Path-discovery response. | | `0x8E` | `PUSH_CODE_CONTROL_DATA` | Control/discovery data. | | `0x8F` | `PUSH_CODE_CONTACT_DELETED` | Oldest contact was deleted while making room. | | `0x90` | `PUSH_CODE_CONTACTS_FULL` | Contact storage is full. | +| `0x91` | `PUSH_CODE_ONE_KEY_DM_REJECTED` | One-key DM refused; bytes 1–32 contain the refusing contact's full public key. | ### Parsing Responses diff --git a/docs/payloads.md b/docs/payloads.md index 183ceaa7..8649f401 100644 --- a/docs/payloads.md +++ b/docs/payloads.md @@ -247,13 +247,20 @@ 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 32-byte sender key in the anonymous request header before offering a chat +contact for manual acceptance, or auto-adding it if `dm.one_key` is enabled. +A receiver that does not implement `DMK1` ignores the introduction; the following normal text packet still works when it already has the sender's -key. The recipient's manual advert auto-add setting does not suppress a -verified, addressed `DMK1` introduction. The sender's full public key remains +key. The recipient's manual advert auto-add setting does not suppress the +synthetic advert for a verified, addressed `DMK1` introduction. The sender's +full public key remains visible in the radio packet header, as with other anonymous requests. +With `dm.one_key` off, the recipient replies with an encrypted `DMR1` response: +the original 4-byte tag, ASCII `DMR1`, then its 64-byte Ed25519 signature +over the sender's full key followed by that tag and marker. Only a matching +pending introduction and verified signature stops repeat introductions. + ### Room server login | Field | Size (bytes) | Description | diff --git a/examples/companion_radio/DataStore.cpp b/examples/companion_radio/DataStore.cpp index c999da94..dac7b057 100644 --- a/examples/companion_radio/DataStore.cpp +++ b/examples/companion_radio/DataStore.cpp @@ -1039,6 +1039,13 @@ bool DataStore::loadPrefsInt(const char *filename, #endif #if defined(RP2040_PLATFORM) && defined(ENABLE_WIFI_INTERFACE) 327, +#endif + 231, // One-key DM consent policy, appended after flood retry controls +#ifdef TBEAM_1W + 238, +#endif +#if defined(RP2040_PLATFORM) && defined(ENABLE_WIFI_INTERFACE) + 328, #endif }; const uint32_t prefs_size = file.size(); @@ -1179,6 +1186,8 @@ bool DataStore::loadPrefsInt(const char *filename, sizeof(loaded_prefs.flood_retry_group_max_path)); readOptionalField(&loaded_prefs.flood_retry_advert_enabled, sizeof(loaded_prefs.flood_retry_advert_enabled)); + readOptionalField(&loaded_prefs.one_key_dm_enabled, + sizeof(loaded_prefs.one_key_dm_enabled)); // Any bytes left over form only part of a historically appended field. // Preserve the file and defaults rather than treating that tail as EOF. @@ -1337,6 +1346,8 @@ bool DataStore::savePrefs(const CompanionNodePrefs& _prefs, double node_lat, dou sizeof(_prefs.flood_retry_group_max_path)) == sizeof(_prefs.flood_retry_group_max_path); success = success && file.write((uint8_t *)&_prefs.flood_retry_advert_enabled, sizeof(_prefs.flood_retry_advert_enabled)) == sizeof(_prefs.flood_retry_advert_enabled); + success = success && file.write((uint8_t *)&_prefs.one_key_dm_enabled, + sizeof(_prefs.one_key_dm_enabled)) == sizeof(_prefs.one_key_dm_enabled); #if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) || defined(ESP32_PLATFORM) || defined(RP2040_PLATFORM) success = file.commit(success); diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index 01860000..ec7876b2 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -221,6 +221,7 @@ static constexpr uint8_t DEFAULT_FEM_RX_GAIN = 1; // A distinct marker after that tag prevents a contact introduction from being // mistaken for a login or another anonymous request. static constexpr uint8_t ONE_KEY_INTRO_MARKER[] = {'D', 'M', 'K', '1'}; +static constexpr uint8_t ONE_KEY_REJECT_MARKER[] = {'D', 'M', 'R', '1'}; static bool save_filter(const ContactInfo& c); @@ -296,6 +297,7 @@ static mesh::Packet* emergency_client_repeat_packet; #define PUSH_CODE_CONTROL_DATA 0x8E // v8+ #define PUSH_CODE_CONTACT_DELETED 0x8F // used to notify client app of deleted contact when overwriting oldest #define PUSH_CODE_CONTACTS_FULL 0x90 // used to notify client app that contacts storage is full +#define PUSH_CODE_ONE_KEY_DM_REJECTED 0x91 // full sender key follows #define ERR_CODE_UNSUPPORTED_CMD 1 #define ERR_CODE_NOT_FOUND 2 @@ -1005,17 +1007,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) { @@ -1058,18 +1106,17 @@ uint32_t MyMesh::sendOneKeyIntroduction(const ContactInfo& contact) { } // Scheduling the text after the introduction also lets the ordinary text // path work on older recipients which already know this sender. + if (sent) rememberOneKeyIntro(contact, tag); return sent ? delay : 0; } void MyMesh::onAnonDataRecv(mesh::Packet* packet, const uint8_t* secret, const mesh::Identity& sender, uint8_t* data, size_t len) { - (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 - || !canMutateContacts()) return; + sizeof(ONE_KEY_INTRO_MARKER)) != 0) return; const char* name = reinterpret_cast(data + 4 + sizeof(ONE_KEY_INTRO_MARKER)); const size_t available = min(size_t(32), len - 4 - sizeof(ONE_KEY_INTRO_MARKER) - SIGNATURE_SIZE); @@ -1084,12 +1131,32 @@ void MyMesh::onAnonDataRecv(mesh::Packet* packet, const uint8_t* secret, PUB_KEY_SIZE + signed_body_len)) return; if (lookupPersistentContactByPubKey(sender.pub_key, PUB_KEY_SIZE) != NULL) return; - ContactInfo contact; + ContactInfo contact = {}; memcpy(contact.id.pub_key, sender.pub_key, PUB_KEY_SIZE); StrHelper::strncpy(contact.name, name, sizeof(contact.name)); contact.type = ADV_TYPE_CHAT; contact.out_path_len = OUT_PATH_UNKNOWN; contact.lastmod = getRTCClock()->getCurrentTime(); + if (_prefs.one_key_dm_enabled != 1) { + rememberVerifiedPendingSender(sender.pub_key); + // Synthetic advert for the attached app's normal manual-add flow. It is + // never added to the radio peer table until the user accepts it. + onDiscoveredContact(contact, true, packet->path_len, packet->path); + + uint8_t refusal[4 + sizeof(ONE_KEY_REJECT_MARKER) + SIGNATURE_SIZE]; + memcpy(refusal, data, 4); + memcpy(refusal + 4, ONE_KEY_REJECT_MARKER, sizeof(ONE_KEY_REJECT_MARKER)); + uint8_t signed_refusal[PUB_KEY_SIZE + 4 + sizeof(ONE_KEY_REJECT_MARKER)]; + memcpy(signed_refusal, sender.pub_key, PUB_KEY_SIZE); + memcpy(signed_refusal + PUB_KEY_SIZE, refusal, 4 + sizeof(ONE_KEY_REJECT_MARKER)); + self_id.sign(refusal + 4 + sizeof(ONE_KEY_REJECT_MARKER), + signed_refusal, sizeof(signed_refusal)); + mesh::Packet* reply = createDatagram(PAYLOAD_TYPE_RESPONSE, sender, secret, + refusal, sizeof(refusal)); + if (reply != NULL) sendFloodScoped(contact, reply, 300); + return; + } + if (!canMutateContacts()) return; if (!addContact(contact)) { onContactsFull(); return; @@ -1106,6 +1173,141 @@ 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]) { + if (wasDeliveredOneKeyDM(pub_key, id)) return; + DeliveredOneKeyDM& entry = delivered_dms[delivered_dm_next]; + memcpy(entry.sender_key, pub_key, PUB_KEY_SIZE); + memcpy(entry.id, id, ONE_KEY_DM_ID_SIZE); + if (delivered_dm_count < MAX_HELD_ONE_KEY_DMS) ++delivered_dm_count; + delivered_dm_next = (delivered_dm_next + 1) % MAX_HELD_ONE_KEY_DMS; +} + +bool MyMesh::onAddressedTextPacket(mesh::Packet* packet, uint8_t src_hash, + const uint8_t* mac_and_data, size_t len) { + for (uint8_t i = 0; i < verified_pending_count; ++i) { + const uint8_t* pub_key = verified_pending_keys[i]; + if (pub_key[0] != src_hash) continue; + if (lookupPersistentContactByPubKey(pub_key, PUB_KEY_SIZE) != NULL) continue; + + uint8_t secret[PUB_KEY_SIZE]; + self_id.calcSharedSecret(secret, pub_key); + uint8_t data[MAX_PACKET_PAYLOAD + 1]; + const int decoded_len = mesh::Utils::MACThenDecrypt(secret, data, mac_and_data, len); + if (decoded_len <= 5 || decoded_len > MAX_PACKET_PAYLOAD) continue; + data[decoded_len] = 0; + if ((data[4] >> 2) != TXT_TYPE_PLAIN) continue; + const char* text = reinterpret_cast(&data[5]); + if (strlen(text) > MAX_TEXT_LEN) continue; + + uint32_t timestamp; + memcpy(×tamp, data, sizeof(timestamp)); + uint8_t id[ONE_KEY_DM_ID_SIZE]; + makeOneKeyDMId(id, timestamp, text); + for (uint8_t j = 0; j < held_dm_count; ++j) { + if (memcmp(held_dms[j].sender_key, pub_key, PUB_KEY_SIZE) == 0 && + memcmp(held_dms[j].id, id, ONE_KEY_DM_ID_SIZE) == 0) return true; + } + + // Only verified and decryptable messages count toward the FIFO of 15. + if (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; ) { + if (lookupPersistentContactByPubKey(verified_pending_keys[i], PUB_KEY_SIZE) != NULL) { + uint8_t key[PUB_KEY_SIZE]; + memcpy(key, verified_pending_keys[i], PUB_KEY_SIZE); + forgetVerifiedPendingSender(key); + } else { + ++i; + } + } + + for (uint8_t i = 0; i < held_dm_count; ) { + HeldOneKeyDM& entry = held_dms[i]; + ContactInfo* contact = lookupPersistentContactByPubKey(entry.sender_key, PUB_KEY_SIZE); + if (contact == NULL) { ++i; continue; } + if (offline_queue_len >= getOfflineQueueCapacity()) break; + + uint8_t data[MAX_PACKET_PAYLOAD + 1]; + const int decoded_len = mesh::Utils::MACThenDecrypt( + contact->getSharedSecret(self_id), data, &entry.packet.payload[2], + entry.packet.payload_len - 2); + if (decoded_len > 5 && decoded_len <= MAX_PACKET_PAYLOAD) { + data[decoded_len] = 0; + if ((data[4] >> 2) == TXT_TYPE_PLAIN) { + const char* text = reinterpret_cast(&data[5]); + uint32_t timestamp; + memcpy(×tamp, data, sizeof(timestamp)); + if (strlen(text) <= MAX_TEXT_LEN) { + if (!wasDeliveredOneKeyDM(entry.sender_key, entry.id)) { + onMessageRecv(*contact, &entry.packet, timestamp, text); + rememberDeliveredOneKeyDM(entry.sender_key, entry.id); + } + uint8_t ack_hash[4]; + mesh::Utils::sha256(ack_hash, sizeof(ack_hash), data, 5 + strlen(text), + contact->id.pub_key, PUB_KEY_SIZE); + sendAckTo(*contact, ack_hash, sizeof(ack_hash)); + } + } + } + for (uint8_t j = i + 1; j < held_dm_count; ++j) held_dms[j - 1] = held_dms[j]; + --held_dm_count; + } +} + 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, @@ -1302,6 +1504,20 @@ bool MyMesh::sendFloodScoped(const mesh::GroupChannel& channel, mesh::Packet* pk void MyMesh::onMessageRecv(const ContactInfo &from, mesh::Packet *pkt, uint32_t sender_timestamp, const char *text) { + uint8_t dm_id[ONE_KEY_DM_ID_SIZE]; + makeOneKeyDMId(dm_id, sender_timestamp, text); + if (wasDeliveredOneKeyDM(from.id.pub_key, dm_id)) return; + for (uint8_t i = 0; i < held_dm_count; ++i) { + if (memcmp(held_dms[i].sender_key, from.id.pub_key, PUB_KEY_SIZE) == 0 && + memcmp(held_dms[i].id, dm_id, ONE_KEY_DM_ID_SIZE) == 0) { + // Keep the held copy if the ordinary delivery path cannot queue this + // just-arrived retry; otherwise accepting a peer with a full app queue + // could silently discard the original when the queue drains. + if (offline_queue_len >= getOfflineQueueCapacity()) return; + rememberDeliveredOneKeyDM(from.id.pub_key, dm_id); + break; + } + } markConnectionActive(from); // in case this is from a server, and we have a connection // BaseChatMesh updates lastmod immediately before this callback. scheduleContactWrite(from); @@ -1519,6 +1735,25 @@ uint8_t MyMesh::onContactRequest(const ContactInfo &contact, uint32_t sender_tim void MyMesh::onContactResponse(const ContactInfo &contact, const uint8_t *data, uint8_t len) { if (data == NULL || len < 4) return; + // Decryption returns block-aligned plaintext including zero padding. + if (len >= 4 + sizeof(ONE_KEY_REJECT_MARKER) + SIGNATURE_SIZE && + len < 4 + sizeof(ONE_KEY_REJECT_MARKER) + SIGNATURE_SIZE + CIPHER_BLOCK_SIZE && + memcmp(data + 4, ONE_KEY_REJECT_MARKER, sizeof(ONE_KEY_REJECT_MARKER)) == 0) { + uint8_t signed_refusal[PUB_KEY_SIZE + 4 + sizeof(ONE_KEY_REJECT_MARKER)]; + memcpy(signed_refusal, self_id.pub_key, PUB_KEY_SIZE); + memcpy(signed_refusal + PUB_KEY_SIZE, data, 4 + sizeof(ONE_KEY_REJECT_MARKER)); + uint32_t intro_tag; + memcpy(&intro_tag, data, sizeof(intro_tag)); + if (contact.id.verify(data + 4 + sizeof(ONE_KEY_REJECT_MARKER), + signed_refusal, sizeof(signed_refusal)) && + rememberOneKeyReject(contact, intro_tag) && _serial->isConnected()) { + out_frame[0] = PUSH_CODE_ONE_KEY_DM_REJECTED; + memcpy(&out_frame[1], contact.id.pub_key, PUB_KEY_SIZE); + _serial->writeFrame(out_frame, 1 + PUB_KEY_SIZE); + } + return; + } + uint32_t tag; memcpy(&tag, data, 4); @@ -4733,6 +4968,7 @@ void MyMesh::handleCmdFrame(size_t len) { } replacement_entry = findPendingTextMessage(text_fingerprint, msg_timestamp); if (recipient->type == ADV_TYPE_CHAT + && !hasOneKeyReject(*recipient) && (attempt != 0 || !hasOneKeyAck(*recipient)) && tlen <= MAX_TEXT_LEN && (attempt <= 3 || tlen <= MAX_TEXT_LEN - 2)) { @@ -8830,6 +9066,30 @@ bool MyMesh::handleDirectCommand(const char* command, char* reply, size_t reply_ #if defined(MESH_SOAK_DIAGNOSTICS) if (mesh::hil::handleSoakCommand(command, reply, reply_size)) return true; #endif + if (strcmp(command, "get dm.one_key") == 0) { + snprintf(reply, reply_size, "> %s", _prefs.one_key_dm_enabled == 1 ? "on" : "off"); + return true; + } + if (strcmp(command, "get dm.held") == 0) { + snprintf(reply, reply_size, "> %u", (unsigned)held_dm_count); + return true; + } + if (strncmp(command, "set dm.one_key ", 15) == 0) { + const char* value = command + 15; + if (strcmp(value, "on") != 0 && strcmp(value, "off") != 0) { + snprintf(reply, reply_size, "Error: use on or off"); + return true; + } + const uint8_t old_value = _prefs.one_key_dm_enabled; + _prefs.one_key_dm_enabled = strcmp(value, "on") == 0 ? 1 : 0; + if (!savePrefs()) { + _prefs.one_key_dm_enabled = old_value; + snprintf(reply, reply_size, "Error: could not save DM setting"); + } else { + snprintf(reply, reply_size, "> dm.one_key %s", value); + } + return true; + } if (strcmp(command, "get key.backup.transport") == 0) { snprintf(reply, reply_size, "ephemeral-routed-v1"); return true; @@ -9567,6 +9827,7 @@ void MyMesh::loop() { #endif } servicePendingRadioParamApply(); + if (held_dm_count != 0 || verified_pending_count != 0) releaseHeldOneKeyDMs(); // is there are pending dirty contacts write needed? if (isContactWriteDue()) { diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index a9acaa12..7b67667e 100644 --- a/examples/companion_radio/MyMesh.h +++ b/examples/companion_radio/MyMesh.h @@ -87,6 +87,11 @@ #ifndef OFFLINE_QUEUE_SIZE #if defined(ESP32_PLATFORM) && defined(BOARD_HAS_PSRAM) #define OFFLINE_QUEUE_SIZE 512 +#elif defined(NRF52_PLATFORM) && MAX_CONTACTS > 300 \ + && !defined(OTA_SHARED_COMPANION_QUEUE) +// Large nRF52 contact tables and the 15-DM consent queue need a smaller +// ordinary offline queue to preserve the runtime heap safety margin. +#define OFFLINE_QUEUE_SIZE 208 #elif defined(ESP32_PLATFORM) || defined(NRF52_PLATFORM) \ || defined(RP2040_PLATFORM) #define OFFLINE_QUEUE_SIZE 256 @@ -320,6 +325,8 @@ protected: 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, bool terminal_command_reply=false, @@ -686,13 +693,49 @@ private: AckTableEntry expected_ack_table[EXPECTED_ACK_TABLE_SIZE]; // circular table // A recent ACK proves that peer can decrypt our normal text packets. Retry // attempts still send the introduction in case its contact was later erased. - static constexpr uint8_t ONE_KEY_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; + static constexpr uint8_t ONE_KEY_PEERS = 8; + struct OneKeyPeerState { + uint8_t pub_key[PUB_KEY_SIZE]; + uint32_t intro_tag; + uint8_t status; // 0: pending, 1: acknowledged, 2: rejected + }; + OneKeyPeerState one_key_peers[ONE_KEY_PEERS] = {}; + uint8_t one_key_peer_count = 0; + uint8_t one_key_peer_next = 0; bool hasOneKeyAck(const ContactInfo& contact) const; + bool hasOneKeyReject(const ContactInfo& contact) const; + void rememberOneKeyIntro(const ContactInfo& contact, uint32_t tag); void rememberOneKeyAck(const ContactInfo& contact); + bool rememberOneKeyReject(const ContactInfo& contact, uint32_t tag); uint32_t sendOneKeyIntroduction(const ContactInfo& contact); + + static constexpr uint8_t MAX_HELD_ONE_KEY_DMS = 15; + static constexpr uint8_t ONE_KEY_DM_ID_SIZE = 8; + uint8_t verified_pending_keys[MAX_HELD_ONE_KEY_DMS][PUB_KEY_SIZE] = {}; + uint8_t verified_pending_count = 0; + struct HeldOneKeyDM { + uint8_t sender_key[PUB_KEY_SIZE]; + uint8_t id[ONE_KEY_DM_ID_SIZE]; + 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(); mesh::LogicalMessageCache room_message_timestamps; int next_ack_idx; unsigned long next_ack_expiry; diff --git a/examples/companion_radio/NodePrefs.h b/examples/companion_radio/NodePrefs.h index 911329ad..f27d8df7 100644 --- a/examples/companion_radio/NodePrefs.h +++ b/examples/companion_radio/NodePrefs.h @@ -106,6 +106,8 @@ public: uint8_t flood_retry_max_path = 1; uint8_t flood_retry_group_max_path = 0xFF; uint8_t flood_retry_advert_enabled = 1; + // Appended persisted field: old preferences images default to consent required. + uint8_t one_key_dm_enabled = 0; private: class RadioPrefs : public CommonRadioPrefs { diff --git a/src/Mesh.cpp b/src/Mesh.cpp index 6dc9d8a6..33560aaf 100644 --- a/src/Mesh.cpp +++ b/src/Mesh.cpp @@ -1074,8 +1074,9 @@ DispatcherAction Mesh::onRecvPacket(Packet* pkt) { // scan contacts DB, for all matching hashes of 'src_hash' (max 4 matches supported ATM) int num = searchPeersByHash(&src_hash); // for each matching contact, try to decrypt data - bool found = false; - for (int j = 0; j < num; j++) { + bool found = pkt->getPayloadType() == PAYLOAD_TYPE_TXT_MSG && + onAddressedTextPacket(pkt, src_hash, macAndData, pkt->payload_len - i); + for (int j = 0; j < num && !found; j++) { uint8_t secret[PUB_KEY_SIZE]; getPeerSharedSecret(secret, j); diff --git a/src/Mesh.h b/src/Mesh.h index 8354a259..fb1891f6 100644 --- a/src/Mesh.h +++ b/src/Mesh.h @@ -500,6 +500,11 @@ protected: */ virtual void onAnonDataRecv(Packet* packet, const uint8_t* secret, const Identity& sender, uint8_t* data, size_t len) { } + // Gives an application a chance to retain addressed text from a verified + // sender that is not yet in the persistent peer table. + virtual bool onAddressedTextPacket(Packet* packet, uint8_t src_hash, + const uint8_t* mac_and_data, size_t len) { return false; } + /** * \brief A path TO 'sender' has been received. (also with optional 'extra' data encoded) * NOTE: these can be received multiple times (per sender), via different routes diff --git a/src/helpers/BaseChatMesh.cpp b/src/helpers/BaseChatMesh.cpp index 45b39644..12c40e47 100644 --- a/src/helpers/BaseChatMesh.cpp +++ b/src/helpers/BaseChatMesh.cpp @@ -315,6 +315,9 @@ void BaseChatMesh::onPeerDataRecv(mesh::Packet* packet, uint8_t type, int sender ContactInfo& from = contacts[i]; + // A transient service peer is not an accepted chat contact. + if (type == PAYLOAD_TYPE_TXT_MSG && from.type == ADV_TYPE_NONE) return; + if (type == PAYLOAD_TYPE_TXT_MSG && len > 5) { uint32_t sender_timestamp; memcpy(&sender_timestamp, data, 4); // timestamp (by sender's RTC clock - which could be wrong) diff --git a/src/helpers/BaseChatMesh.h b/src/helpers/BaseChatMesh.h index 483059df..9cd6d48a 100644 --- a/src/helpers/BaseChatMesh.h +++ b/src/helpers/BaseChatMesh.h @@ -97,9 +97,9 @@ class BaseChatMesh : public mesh::Mesh { ConnectionInfo connections[MAX_CONNECTIONS]; mesh::Packet* composeMsgPacket(const ContactInfo& recipient, uint32_t timestamp, uint8_t attempt, const char *text, uint32_t& expected_ack); - void sendAckTo(const ContactInfo& dest, const uint8_t* ack_hash, uint8_t ack_len=4); protected: + void sendAckTo(const ContactInfo& dest, const uint8_t* ack_hash, uint8_t ack_len=4); BaseChatMesh(mesh::Radio& radio, mesh::MillisecondClock& ms, mesh::RNG& rng, mesh::RTCClock& rtc, mesh::PacketManager& mgr, mesh::MeshTables& tables) : mesh::Mesh(radio, ms, rng, rtc, mgr, tables) { diff --git a/test/test_companion_preferences_transaction.py b/test/test_companion_preferences_transaction.py index 37496534..c05cf8b4 100644 --- a/test/test_companion_preferences_transaction.py +++ b/test/test_companion_preferences_transaction.py @@ -53,6 +53,7 @@ int main(int argc,char** argv){ original.interference_threshold=8;original.agc_reset_interval=15;original.tz_offset=-7; original.flood_retry_attempts=4;original.flood_retry_max_path=3; original.flood_retry_group_max_path=2;original.flood_retry_advert_enabled=0; + original.one_key_dm_enabled=1; #ifdef TBEAM_1W strcpy(original.fan_mode,"on");original.fan_lo=33;original.fan_hi=42; #endif @@ -88,6 +89,7 @@ int main(int argc,char** argv){ assert(loaded.interference_threshold==8&&loaded.agc_reset_interval==15&&loaded.tz_offset==-7); assert(loaded.flood_retry_attempts==4&&loaded.flood_retry_max_path==3); assert(loaded.flood_retry_group_max_path==2&&loaded.flood_retry_advert_enabled==0); + assert(loaded.one_key_dm_enabled==1); #ifdef TBEAM_1W assert(!strcmp(loaded.fan_mode,"on")&&loaded.fan_lo==33&&loaded.fan_hi==42); #endif @@ -172,6 +174,7 @@ int main(int argc,char** argv){ assert(loaded.interference_threshold==0&&loaded.agc_reset_interval==0&&loaded.tz_offset==0); assert(loaded.flood_retry_attempts==15&&loaded.flood_retry_max_path==1); assert(loaded.flood_retry_group_max_path==0xff&&loaded.flood_retry_advert_enabled==1); + assert(loaded.one_key_dm_enabled==0); assert(loaded.freq==original.freq&&loaded.ble_pin==original.ble_pin); } else if(scenario==6){ DataStore legacy;legacy.fs.files["/new_prefs"]=disk; @@ -186,6 +189,15 @@ int main(int argc,char** argv){ assert(legacy.loadPrefs(loaded,lat,lon)); assert(loaded.flood_retry_attempts==15&&loaded.flood_retry_max_path==1); assert(loaded.flood_retry_group_max_path==0xff&&loaded.flood_retry_advert_enabled==1); + assert(loaded.one_key_dm_enabled==0); + } else if(scenario==7){ + // Pre-consent image: all earlier settings survive with default-off consent. + DataStore legacy;legacy.fs.files["/new_prefs"]=disk; + legacy.fs.files["/new_prefs"].resize(disk.size()-1); + CompanionNodePrefs loaded;double lat=0,lon=0; + assert(legacy.loadPrefs(loaded,lat,lon)); + assert(loaded.flood_retry_advert_enabled==0); + assert(loaded.one_key_dm_enabled==0); } else if(scenario==4){ DataStore legacy;legacy.fs.files["/node_prefs"]=disk; legacy.fs.fail_write_after=17; @@ -273,7 +285,7 @@ inline char* utoa(unsigned int value,char* output,int base){ str(ROOT / 'src/helpers/TxtDataHelpers.cpp'), '-o', str(binary)], capture_output=True, text=True) self.assertEqual(build.returncode, 0, build.stdout + build.stderr) - for scenario in range(7): + for scenario in range(8): with self.subTest(scenario=scenario): run = subprocess.run([str(binary), str(scenario)], capture_output=True, text=True) self.assertEqual(run.returncode, 0, run.stdout + run.stderr) diff --git a/tools/hil/one_key_dm.py b/tools/hil/one_key_dm.py index 46b0b499..ab6b78c1 100644 --- a/tools/hil/one_key_dm.py +++ b/tools/hil/one_key_dm.py @@ -1,8 +1,8 @@ #!/usr/bin/env python3 -"""Exercise a private DM when only its sender knows the recipient's key. +"""Exercise manual acceptance of a one-key private DM. 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 +It consumes the recipient's deferred DM from the offline queue. Use two nearby Companion USB radios on the same radio profile, with no other client attached. """ @@ -79,14 +79,23 @@ class Link: frame = self.request(bytes([30]) + key, (1, 3)) return frame if frame[0] == 3 else None + def cli(self, command: str) -> str: + return self.request(bytes([0x42]) + command.encode(), (0x1D,))[1:].decode() + def run(sender_port: str, recipient_port: str, reset_contact: bool, zero_hop: bool, invalid_signature_first: bool) -> dict: sender = Link(sender_port) recipient = Link(recipient_port) + prior_dm_setting = None try: sender_key, sender_info = sender.start() recipient_key, recipient_info = recipient.start() + prior_dm_setting = recipient.cli("get dm.one_key") + if prior_dm_setting not in ("> on", "> off"): + raise RuntimeError("recipient did not report its one-key DM policy") + if not recipient.cli("set dm.one_key off").endswith("dm.one_key off"): + raise RuntimeError("recipient could not enable manual DM acceptance") radio_fields = ("frequency_khz", "bandwidth_hz", "spreading_factor", "coding_rate") if any(sender_info[field] != recipient_info[field] for field in radio_fields): raise RuntimeError("radios do not use the same frequency, bandwidth, SF, and CR") @@ -136,14 +145,40 @@ 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(" on": + try: + recipient.cli("set dm.one_key on") + except Exception: + pass sender.close() recipient.close()