Port consent-gated one-key DM queue to keymindCascade

This commit is contained in:
mikecarper committed 2026-09-28 12:09:05 -07:00
1 parent 01054ba979
commit 7ba8c5bfa2
13 files changed
+443 -40

No files matched your search

+5 -2
View File
@@ -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
+20 -10
View File
@@ -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
+11 -4
View File
@@ -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 |
+11
View File
@@ -1039,6 +1039,13 @@ bool DataStore::loadPrefsInt(const char *filename,
#endif
#if defined(RP2040_PLATFORM) && defined(ENABLE_WIFI_INTERFACE)
327,
#endif
231, // One-key DM consent policy, appended after flood retry controls
#ifdef TBEAM_1W
238,
#endif
#if defined(RP2040_PLATFORM) && defined(ENABLE_WIFI_INTERFACE)
328,
#endif
};
const uint32_t prefs_size = file.size();
@@ -1179,6 +1186,8 @@ bool DataStore::loadPrefsInt(const char *filename,
sizeof(loaded_prefs.flood_retry_group_max_path));
readOptionalField(&loaded_prefs.flood_retry_advert_enabled,
sizeof(loaded_prefs.flood_retry_advert_enabled));
readOptionalField(&loaded_prefs.one_key_dm_enabled,
sizeof(loaded_prefs.one_key_dm_enabled));
// Any bytes left over form only part of a historically appended field.
// Preserve the file and defaults rather than treating that tail as EOF.
@@ -1337,6 +1346,8 @@ bool DataStore::savePrefs(const CompanionNodePrefs& _prefs, double node_lat, dou
sizeof(_prefs.flood_retry_group_max_path)) == sizeof(_prefs.flood_retry_group_max_path);
success = success && file.write((uint8_t *)&_prefs.flood_retry_advert_enabled,
sizeof(_prefs.flood_retry_advert_enabled)) == sizeof(_prefs.flood_retry_advert_enabled);
success = success && file.write((uint8_t *)&_prefs.one_key_dm_enabled,
sizeof(_prefs.one_key_dm_enabled)) == sizeof(_prefs.one_key_dm_enabled);
#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) || defined(ESP32_PLATFORM) || defined(RP2040_PLATFORM)
success = file.commit(success);
+270 -9
View File
@@ -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<const char*>(data + 4 + sizeof(ONE_KEY_INTRO_MARKER));
const size_t available = min(size_t(32), len - 4 - sizeof(ONE_KEY_INTRO_MARKER) - SIGNATURE_SIZE);
@@ -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<const uint8_t*>(&timestamp), sizeof(timestamp),
reinterpret_cast<const uint8_t*>(text), strlen(text));
}
bool MyMesh::wasDeliveredOneKeyDM(const uint8_t* pub_key,
const uint8_t id[ONE_KEY_DM_ID_SIZE]) const {
for (uint8_t i = 0; i < delivered_dm_count; ++i) {
if (memcmp(delivered_dms[i].sender_key, pub_key, PUB_KEY_SIZE) == 0 &&
memcmp(delivered_dms[i].id, id, ONE_KEY_DM_ID_SIZE) == 0) return true;
}
return false;
}
void MyMesh::rememberDeliveredOneKeyDM(const uint8_t* pub_key,
const uint8_t id[ONE_KEY_DM_ID_SIZE]) {
if (wasDeliveredOneKeyDM(pub_key, id)) return;
DeliveredOneKeyDM& entry = delivered_dms[delivered_dm_next];
memcpy(entry.sender_key, pub_key, PUB_KEY_SIZE);
memcpy(entry.id, id, ONE_KEY_DM_ID_SIZE);
if (delivered_dm_count < MAX_HELD_ONE_KEY_DMS) ++delivered_dm_count;
delivered_dm_next = (delivered_dm_next + 1) % MAX_HELD_ONE_KEY_DMS;
}
bool MyMesh::onAddressedTextPacket(mesh::Packet* packet, uint8_t src_hash,
const uint8_t* mac_and_data, size_t len) {
for (uint8_t i = 0; i < verified_pending_count; ++i) {
const uint8_t* pub_key = verified_pending_keys[i];
if (pub_key[0] != src_hash) continue;
if (lookupPersistentContactByPubKey(pub_key, PUB_KEY_SIZE) != NULL) continue;
uint8_t secret[PUB_KEY_SIZE];
self_id.calcSharedSecret(secret, pub_key);
uint8_t data[MAX_PACKET_PAYLOAD + 1];
const int decoded_len = mesh::Utils::MACThenDecrypt(secret, data, mac_and_data, len);
if (decoded_len <= 5 || decoded_len > MAX_PACKET_PAYLOAD) continue;
data[decoded_len] = 0;
if ((data[4] >> 2) != TXT_TYPE_PLAIN) continue;
const char* text = reinterpret_cast<const char*>(&data[5]);
if (strlen(text) > MAX_TEXT_LEN) continue;
uint32_t timestamp;
memcpy(&timestamp, data, sizeof(timestamp));
uint8_t id[ONE_KEY_DM_ID_SIZE];
makeOneKeyDMId(id, timestamp, text);
for (uint8_t j = 0; j < held_dm_count; ++j) {
if (memcmp(held_dms[j].sender_key, pub_key, PUB_KEY_SIZE) == 0 &&
memcmp(held_dms[j].id, id, ONE_KEY_DM_ID_SIZE) == 0) return true;
}
// Only verified and decryptable messages count toward the FIFO of 15.
if (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<const char*>(&data[5]);
uint32_t timestamp;
memcpy(&timestamp, data, sizeof(timestamp));
if (strlen(text) <= MAX_TEXT_LEN) {
if (!wasDeliveredOneKeyDM(entry.sender_key, entry.id)) {
onMessageRecv(*contact, &entry.packet, timestamp, text);
rememberDeliveredOneKeyDM(entry.sender_key, entry.id);
}
uint8_t ack_hash[4];
mesh::Utils::sha256(ack_hash, sizeof(ack_hash), data, 5 + strlen(text),
contact->id.pub_key, PUB_KEY_SIZE);
sendAckTo(*contact, ack_hash, sizeof(ack_hash));
}
}
}
for (uint8_t j = i + 1; j < held_dm_count; ++j) held_dms[j - 1] = held_dms[j];
--held_dm_count;
}
}
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()) {
+47 -4
View File
@@ -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_TIMESTAMP_CACHE_SIZE> room_message_timestamps;
int next_ack_idx;
unsigned long next_ack_expiry;
+2
View File
@@ -106,6 +106,8 @@ public:
uint8_t flood_retry_max_path = 1;
uint8_t flood_retry_group_max_path = 0xFF;
uint8_t flood_retry_advert_enabled = 1;
// Appended persisted field: old preferences images default to consent required.
uint8_t one_key_dm_enabled = 0;
private:
class RadioPrefs : public CommonRadioPrefs {
+3 -2
View File
@@ -1074,8 +1074,9 @@ DispatcherAction Mesh::onRecvPacket(Packet* pkt) {
// scan contacts DB, for all matching hashes of 'src_hash' (max 4 matches supported ATM)
int num = searchPeersByHash(&src_hash);
// for each matching contact, try to decrypt data
bool found = false;
for (int j = 0; j < num; j++) {
bool found = pkt->getPayloadType() == PAYLOAD_TYPE_TXT_MSG &&
onAddressedTextPacket(pkt, src_hash, macAndData, pkt->payload_len - i);
for (int j = 0; j < num && !found; j++) {
uint8_t secret[PUB_KEY_SIZE];
getPeerSharedSecret(secret, j);
+5
View File
@@ -500,6 +500,11 @@ protected:
*/
virtual void onAnonDataRecv(Packet* packet, const uint8_t* secret, const Identity& sender, uint8_t* data, size_t len) { }
// Gives an application a chance to retain addressed text from a verified
// sender that is not yet in the persistent peer table.
virtual bool onAddressedTextPacket(Packet* packet, uint8_t src_hash,
const uint8_t* mac_and_data, size_t len) { return false; }
/**
* \brief A path TO 'sender' has been received. (also with optional 'extra' data encoded)
* NOTE: these can be received multiple times (per sender), via different routes
+3
View File
@@ -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)
+1 -1
View File
@@ -97,9 +97,9 @@ class BaseChatMesh : public mesh::Mesh {
ConnectionInfo connections[MAX_CONNECTIONS];
mesh::Packet* composeMsgPacket(const ContactInfo& recipient, uint32_t timestamp, uint8_t attempt, const char *text, uint32_t& expected_ack);
void sendAckTo(const ContactInfo& dest, const uint8_t* ack_hash, uint8_t ack_len=4);
protected:
void sendAckTo(const ContactInfo& dest, const uint8_t* ack_hash, uint8_t ack_len=4);
BaseChatMesh(mesh::Radio& radio, mesh::MillisecondClock& ms, mesh::RNG& rng, mesh::RTCClock& rtc, mesh::PacketManager& mgr, mesh::MeshTables& tables)
: mesh::Mesh(radio, ms, rng, rtc, mgr, tables)
{
+13 -1
View File
@@ -53,6 +53,7 @@ int main(int argc,char** argv){
original.interference_threshold=8;original.agc_reset_interval=15;original.tz_offset=-7;
original.flood_retry_attempts=4;original.flood_retry_max_path=3;
original.flood_retry_group_max_path=2;original.flood_retry_advert_enabled=0;
original.one_key_dm_enabled=1;
#ifdef TBEAM_1W
strcpy(original.fan_mode,"on");original.fan_lo=33;original.fan_hi=42;
#endif
@@ -88,6 +89,7 @@ int main(int argc,char** argv){
assert(loaded.interference_threshold==8&&loaded.agc_reset_interval==15&&loaded.tz_offset==-7);
assert(loaded.flood_retry_attempts==4&&loaded.flood_retry_max_path==3);
assert(loaded.flood_retry_group_max_path==2&&loaded.flood_retry_advert_enabled==0);
assert(loaded.one_key_dm_enabled==1);
#ifdef TBEAM_1W
assert(!strcmp(loaded.fan_mode,"on")&&loaded.fan_lo==33&&loaded.fan_hi==42);
#endif
@@ -172,6 +174,7 @@ int main(int argc,char** argv){
assert(loaded.interference_threshold==0&&loaded.agc_reset_interval==0&&loaded.tz_offset==0);
assert(loaded.flood_retry_attempts==15&&loaded.flood_retry_max_path==1);
assert(loaded.flood_retry_group_max_path==0xff&&loaded.flood_retry_advert_enabled==1);
assert(loaded.one_key_dm_enabled==0);
assert(loaded.freq==original.freq&&loaded.ble_pin==original.ble_pin);
} else if(scenario==6){
DataStore legacy;legacy.fs.files["/new_prefs"]=disk;
@@ -186,6 +189,15 @@ int main(int argc,char** argv){
assert(legacy.loadPrefs(loaded,lat,lon));
assert(loaded.flood_retry_attempts==15&&loaded.flood_retry_max_path==1);
assert(loaded.flood_retry_group_max_path==0xff&&loaded.flood_retry_advert_enabled==1);
assert(loaded.one_key_dm_enabled==0);
} else if(scenario==7){
// Pre-consent image: all earlier settings survive with default-off consent.
DataStore legacy;legacy.fs.files["/new_prefs"]=disk;
legacy.fs.files["/new_prefs"].resize(disk.size()-1);
CompanionNodePrefs loaded;double lat=0,lon=0;
assert(legacy.loadPrefs(loaded,lat,lon));
assert(loaded.flood_retry_advert_enabled==0);
assert(loaded.one_key_dm_enabled==0);
} else if(scenario==4){
DataStore legacy;legacy.fs.files["/node_prefs"]=disk;
legacy.fs.fail_write_after=17;
@@ -273,7 +285,7 @@ inline char* utoa(unsigned int value,char* output,int base){
str(ROOT / 'src/helpers/TxtDataHelpers.cpp'),
'-o', str(binary)], capture_output=True, text=True)
self.assertEqual(build.returncode, 0, build.stdout + build.stderr)
for scenario in range(7):
for scenario in range(8):
with self.subTest(scenario=scenario):
run = subprocess.run([str(binary), str(scenario)], capture_output=True, text=True)
self.assertEqual(run.returncode, 0, run.stdout + run.stderr)
+52 -7
View File
@@ -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("<I", sent[6:10])[0]
offered = None
rejected = False
until = time.monotonic() + min(timeout_ms / 1000 + 5, 120)
while time.monotonic() < until:
for link in (sender, recipient):
link.read(0.1)
confirmed = any(
frame[0] == 0x82 and frame[1:5] == expected_ack
for frame in sender.pushes
)
offered = next((frame for frame in recipient.pushes
if frame[0] == 0x8A and frame[1:33] == sender_key), None)
rejected = any(frame[0] == 0x91 and frame[1:33] == recipient_key
for frame in sender.pushes)
if offered and rejected:
break
if offered is None or not rejected:
raise AssertionError("signed introduction did not produce advert and refusal")
if recipient.contact(sender_key) is not None:
raise AssertionError("recipient added sender without acceptance")
if any(frame[0] == 0x83 for frame in recipient.pushes):
raise AssertionError("recipient delivered DM before acceptance")
# The text follows the introduction on air. Ensure it is actually in
# the holding queue before accepting, not merely delivered afterward.
until = time.monotonic() + min(timeout_ms / 1000 + 5, 120)
while time.monotonic() < until and recipient.cli("get dm.held") != "> 1":
time.sleep(0.5)
if recipient.cli("get dm.held") != "> 1":
raise AssertionError("no decryptable DM was held before acceptance")
recipient.request(bytes([9]) + offered[1:], (0,))
until = time.monotonic() + min(timeout_ms / 1000 + 5, 120)
while time.monotonic() < until:
for link in (sender, recipient):
link.read(0.1)
confirmed = any(frame[0] == 0x82 and frame[1:5] == expected_ack
for frame in sender.pushes)
waiting = any(frame[0] == 0x83 for frame in recipient.pushes)
if confirmed and waiting:
break
@@ -153,6 +188,8 @@ def run(sender_port: str, recipient_port: str, reset_contact: bool,
)
waiting = any(frame[0] == 0x83 for frame in recipient.pushes)
learned = recipient.contact(sender_key) is not None
queued = recipient.request(bytes([10]), (7, 10, 16))
replayed = queued[0] in (7, 16) and text in queued
result = {
"sender_prefix": sender_key[:6].hex(),
"recipient_prefix": recipient_key[:6].hex(),
@@ -160,14 +197,22 @@ def run(sender_port: str, recipient_port: str, reset_contact: bool,
"ack_confirmed": confirmed,
"recipient_message_waiting": waiting,
"recipient_learned_sender": learned,
"synthetic_advert_offered": offered is not None,
"signed_refusal_received": rejected,
"held_message_replayed": replayed,
"timeout_ms": timeout_ms,
}
if invalid_signature_first:
result["invalid_signature_rejected"] = True
if not (confirmed and waiting and learned):
if not (confirmed and waiting and learned and replayed):
raise AssertionError(json.dumps(result, sort_keys=True))
return result
finally:
if prior_dm_setting == "> on":
try:
recipient.cli("set dm.one_key on")
except Exception:
pass
sender.close()
recipient.close()