Port consent-gated one-key DM queue to keymindCascade
This commit is contained in:
1 parent
01054ba979
commit
7ba8c5bfa2
13 files changed
+443
-40
No files matched your search
@@ -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
@@ -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
@@ -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 |
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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*>(×tamp), 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(×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<const char*>(&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()) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
|
||||
Reference in new issue
Block a user