From 00a9e9375451332d66d2a955b3298a81f061d630 Mon Sep 17 00:00:00 2001 From: Scott Powell Date: Sat, 25 Jan 2025 22:03:25 +1100 Subject: [PATCH] * simple_secure_chat now with a proper CLI * new: BaseChatMesh class, for abstract chat client --- examples/simple_secure_chat/main.cpp | 556 +++++++++++++-------------- platformio.ini | 36 +- src/helpers/AdvertDataHelpers.cpp | 28 ++ src/helpers/AdvertDataHelpers.h | 5 + src/helpers/BaseChatMesh.cpp | 258 +++++++++++++ src/helpers/BaseChatMesh.h | 88 +++++ src/helpers/IdentityStore.cpp | 45 +++ src/helpers/IdentityStore.h | 2 + 8 files changed, 712 insertions(+), 306 deletions(-) create mode 100644 src/helpers/BaseChatMesh.cpp create mode 100644 src/helpers/BaseChatMesh.h diff --git a/examples/simple_secure_chat/main.cpp b/examples/simple_secure_chat/main.cpp index 58094de6..3205c0fe 100644 --- a/examples/simple_secure_chat/main.cpp +++ b/examples/simple_secure_chat/main.cpp @@ -1,13 +1,20 @@ #include // needed for PlatformIO #include +#if defined(NRF52_PLATFORM) + #include +#elif defined(ESP32) + #include +#endif + #define RADIOLIB_STATIC_ONLY 1 #include #include #include #include #include -#include +#include +#include /* ---------------------------------- CONFIGURATION ------------------------------------- */ @@ -27,22 +34,31 @@ #define LORA_TX_POWER 20 #endif -//#define RUN_AS_ALICE true - -#if RUN_AS_ALICE - #define USER_NAME "Alice" - const char* alice_private = "B8830658388B2DDF22C3A508F4386975970CDE1E2A2A495C8F3B5727957A97629255A1392F8BA4C26A023A0DAB78BFC64D261C8E51507496DD39AFE3707E7B42"; -#else - #define USER_NAME "Bob" - const char *bob_private = "30BAA23CCB825D8020A59C936D0AB7773B07356020360FC77192813640BAD375E43BBF9A9A7537E4B9614610F1F2EF874AAB390BA9B0C2F01006B01FDDFEFF0C"; +#ifndef MAX_CONTACTS + #define MAX_CONTACTS 100 #endif - const char *alice_public = "106A5136EC0DD797650AD204C065CF9B66095F6ED772B0822187785D65E11B1F"; - const char *bob_public = "020BCEDAC07D709BD8507EC316EB5A7FF2F0939AF5057353DCE7E4436A1B9681"; -#ifdef HELTEC_LORA_V3 +#include + +#define SEND_TIMEOUT_BASE_MILLIS 300 +#define FLOOD_SEND_TIMEOUT_FACTOR 16.0f +#define DIRECT_SEND_PERHOP_FACTOR 4.0f +#define DIRECT_SEND_PERHOP_EXTRA_MILLIS 200 + + +#if defined(HELTEC_LORA_V3) #include #include static HeltecV3Board board; +#elif defined(ARDUINO_XIAO_ESP32C3) + #include + #include + #include + static XiaoC3Board board; +#elif defined(SEEED_XIAO_S3) + #include + #include + static ESP32Board board; #elif defined(RAK_4631) #include #include @@ -51,234 +67,278 @@ #error "need to provide a 'board' object" #endif -#define SEND_TIMEOUT_BASE_MILLIS 300 -#define FLOOD_SEND_TIMEOUT_FACTOR 16.0f -#define DIRECT_SEND_PERHOP_FACTOR 4.0f -#define DIRECT_SEND_PERHOP_EXTRA_MILLIS 100 - /* -------------------------------------------------------------------------------------- */ -static unsigned long txt_send_timeout; static int curr_contact_idx = 0; -#define MAX_CONTACTS 8 -#define MAX_SEARCH_RESULTS 2 +class MyMesh : public BaseChatMesh, ContactVisitor { + FILESYSTEM* _fs; + uint32_t expected_ack_crc; + unsigned long last_msg_sent; + ContactInfo* curr_recipient; + char command[MAX_TEXT_LEN+1]; -#define MAX_TEXT_LEN (10*CIPHER_BLOCK_SIZE) // must be LESS than (MAX_PACKET_PAYLOAD - 4 - CIPHER_MAC_SIZE - 1) + const char* getTypeName(uint8_t type) const { + if (type == ADV_TYPE_CHAT) return "Chat"; + if (type == ADV_TYPE_REPEATER) return "Repeater"; + if (type == ADV_TYPE_ROOM) return "Room"; + return "??"; // unknown + } -struct ContactInfo { - mesh::Identity id; - const char* name; - int out_path_len; - uint8_t out_path[MAX_PATH_SIZE]; - uint32_t last_advert_timestamp; - uint8_t shared_secret[PUB_KEY_SIZE]; -}; + void loadContacts() { + if (_fs->exists("/contacts")) { + File file = _fs->open("/contacts"); + if (file) { + bool full = false; + while (!full) { + ContactInfo c; + uint8_t pub_key[32]; + uint8_t unused; + uint32_t reserved; -class MyMesh : public mesh::Mesh { -public: - ContactInfo contacts[MAX_CONTACTS]; - int num_contacts; + bool success = (file.read(pub_key, 32) == 32); + success = success && (file.read((uint8_t *) &c.name, 32) == 32); + success = success && (file.read(&c.type, 1) == 1); + success = success && (file.read(&c.flags, 1) == 1); + success = success && (file.read(&unused, 1) == 1); + success = success && (file.read((uint8_t *) &reserved, 4) == 4); + success = success && (file.read((uint8_t *) &c.out_path_len, 1) == 1); + success = success && (file.read((uint8_t *) &c.last_advert_timestamp, 4) == 4); + success = success && (file.read(c.out_path, 64) == 64); - void addContact(const char* name, const mesh::Identity& id) { - if (num_contacts < MAX_CONTACTS) { - curr_contact_idx = num_contacts; // auto-select this contact as current selection + if (!success) break; // EOF - contacts[num_contacts].id = id; - contacts[num_contacts].name = strdup(name); - contacts[num_contacts].last_advert_timestamp = 0; - contacts[num_contacts].out_path_len = -1; - // only need to calculate the shared_secret once, for better performance - self_id.calcSharedSecret(contacts[num_contacts].shared_secret, id); - num_contacts++; + c.id = mesh::Identity(pub_key); + if (!addContact(c)) full = true; + } + file.close(); + } + } + } + + void saveContacts() { +#if defined(NRF52_PLATFORM) + File file = _fs->open("/contacts", FILE_O_WRITE); + if (file) { file.seek(0); file.truncate(); } +#else + File file = _fs->open("/contacts", "w", true); +#endif + if (file) { + ContactsIterator iter; + ContactInfo c; + uint8_t unused = 0; + uint32_t reserved = 0; + + while (iter.hasNext(this, c)) { + bool success = (file.write(c.id.pub_key, 32) == 32); + success = success && (file.write((uint8_t *) &c.name, 32) == 32); + success = success && (file.write(&c.type, 1) == 1); + success = success && (file.write(&c.flags, 1) == 1); + success = success && (file.write(&unused, 1) == 1); + success = success && (file.write((uint8_t *) &reserved, 4) == 4); + success = success && (file.write((uint8_t *) &c.out_path_len, 1) == 1); + success = success && (file.write((uint8_t *) &c.last_advert_timestamp, 4) == 4); + success = success && (file.write(c.out_path, 64) == 64); + + if (!success) break; // write failed + } + file.close(); } } protected: - int matching_peer_indexes[MAX_SEARCH_RESULTS]; + void onDiscoveredContact(ContactInfo& contact, bool is_new) override { + // TODO: if not in favs, prompt to add as fav(?) - int searchPeersByHash(const uint8_t* hash) override { - int n = 0; - for (int i = 0; i < num_contacts && n < MAX_SEARCH_RESULTS; i++) { - if (contacts[i].id.isHashMatch(hash)) { - matching_peer_indexes[n++] = i; // store the INDEXES of matching contacts (for subsequent 'peer' methods) - } - } - return n; + Serial.printf("ADVERT from -> %s\n", contact.name); + Serial.printf(" type: %s\n", getTypeName(contact.type)); + Serial.print(" public key: "); mesh::Utils::printHex(Serial, contact.id.pub_key, PUB_KEY_SIZE); Serial.println(); + + saveContacts(); } - #define ADV_TYPE_NONE 0 // unknown - #define ADV_TYPE_CHAT 1 - #define ADV_TYPE_REPEATER 2 - //FUTURE: 3..15 - - #define ADV_LATLON_MASK 0x10 - #define ADV_BATTERY_MASK 0x20 - #define ADV_TEMPERATURE_MASK 0x40 - #define ADV_NAME_MASK 0x80 - - void onAdvertRecv(mesh::Packet* packet, const mesh::Identity& id, uint32_t timestamp, const uint8_t* app_data, size_t app_data_len) override { - Serial.print("Valid Advertisement -> "); - mesh::Utils::printHex(Serial, id.pub_key, PUB_KEY_SIZE); - Serial.println(); - - for (int i = 0; i < num_contacts; i++) { - ContactInfo& from = contacts[i]; - if (id.matches(from.id)) { // is from one of our contacts - if (timestamp > from.last_advert_timestamp) { // check for replay attacks!! - from.last_advert_timestamp = timestamp; - Serial.printf(" From contact: %s\n", from.name); - } - return; - } - } - // unknown node - AdvertDataParser parser(app_data, app_data_len); - if (parser.getType() == ADV_TYPE_CHAT && parser.hasName()) { // is it a 'Chat' node (with a name)? - // automatically add to our contacts - addContact(parser.getName(), id); - Serial.printf(" ADDED contact: %s\n", parser.getName()); - } else { - Serial.printf(" Unknown app_data type: %02X, len=%d\n", app_data[0], app_data_len); - } + void onContactPathUpdated(const ContactInfo& contact) override { + Serial.printf("PATH to: %s, path_len=%d\n", contact.name, (int32_t) contact.out_path_len); + saveContacts(); } - void getPeerSharedSecret(uint8_t* dest_secret, int peer_idx) override { - int i = matching_peer_indexes[peer_idx]; - if (i >= 0 && i < num_contacts) { - // lookup pre-calculated shared_secret - memcpy(dest_secret, contacts[i].shared_secret, PUB_KEY_SIZE); - } else { - MESH_DEBUG_PRINTLN("getPeerSHharedSecret: Invalid peer idx: %d", i); - } - } - - void onPeerDataRecv(mesh::Packet* packet, uint8_t type, int sender_idx, const uint8_t* secret, uint8_t* data, size_t len) override { - if (type == PAYLOAD_TYPE_TXT_MSG && len > 5) { - int i = matching_peer_indexes[sender_idx]; - if (i < 0 || i >= num_contacts) { - MESH_DEBUG_PRINTLN("onPeerDataRecv: Invalid sender idx: %d", i); - return; - } - - ContactInfo& from = contacts[i]; - - uint32_t timestamp; - memcpy(×tamp, data, 4); // timestamp (by sender's RTC clock - which could be wrong) - uint flags = data[4]; // message attempt number, and other flags - - // len can be > original length, but 'text' will be padded with zeroes - data[len] = 0; // need to make a C string again, with null terminator - - //if ( ! alreadyReceived timestamp ) { - Serial.printf("(%s) MSG -> from %s\n", packet->isRouteFlood() ? "FLOOD" : "DIRECT", from.name); - Serial.printf(" %s\n", (const char *) &data[5]); - //} - - uint32_t ack_hash; // calc truncated hash of the message timestamp + text + sender pub_key, to prove to sender that we got it - mesh::Utils::sha256((uint8_t *) &ack_hash, 4, data, 5 + strlen((char *)&data[5]), from.id.pub_key, PUB_KEY_SIZE); - - if (packet->isRouteFlood()) { - // let this sender know path TO here, so they can use sendDirect(), and ALSO encode the ACK - mesh::Packet* path = createPathReturn(from.id, secret, packet->path, packet->path_len, - PAYLOAD_TYPE_ACK, (uint8_t *) &ack_hash, 4); - if (path) sendFlood(path); - } else { - mesh::Packet* ack = createAck(ack_hash); - if (ack) { - if (from.out_path_len < 0) { - sendFlood(ack); - } else { - sendDirect(ack, from.out_path, from.out_path_len); - } - } - } - } - } - - bool onPeerPathRecv(mesh::Packet* packet, int sender_idx, const uint8_t* secret, uint8_t* path, uint8_t path_len, uint8_t extra_type, uint8_t* extra, uint8_t extra_len) override { - int i = matching_peer_indexes[sender_idx]; - if (i < 0 || i >= num_contacts) { - MESH_DEBUG_PRINTLN("onPeerPathRecv: Invalid sender idx: %d", i); - return false; - } - - ContactInfo& from = contacts[i]; - Serial.printf("PATH to: %s, path_len=%d\n", from.name, (uint32_t) path_len); - - // NOTE: for this impl, we just replace the current 'out_path' regardless, whenever sender sends us a new out_path. - // FUTURE: could store multiple out_paths per contact, and try to find which is the 'best'(?) - memcpy(from.out_path, path, from.out_path_len = path_len); // store a copy of path, for sendDirect() - - if (extra_type == PAYLOAD_TYPE_ACK && extra_len >= 4) { - // also got an encoded ACK! - processAck(extra); - } - return true; // send reciprocal path if necessary - } - - void onAckRecv(mesh::Packet* packet, uint32_t ack_crc) override { - if (processAck((uint8_t *)&ack_crc)) { - packet->markDoNotRetransmit(); // ACK was for this node, so don't retransmit - } - } - - bool processAck(const uint8_t *data) { + bool processAck(const uint8_t *data) override { if (memcmp(data, &expected_ack_crc, 4) == 0) { // got an ACK from recipient Serial.printf(" Got ACK! (round trip: %d millis)\n", _ms->getMillis() - last_msg_sent); // NOTE: the same ACK can be received multiple times! expected_ack_crc = 0; // reset our expected hash, now that we have received ACK - txt_send_timeout = 0; return true; } - uint32_t crc; - memcpy(&crc, data, 4); - MESH_DEBUG_PRINTLN(" unknown ACK received: %08X (expected: %08X)", crc, expected_ack_crc); + //uint32_t crc; + //memcpy(&crc, data, 4); + //MESH_DEBUG_PRINTLN("unknown ACK received: %08X (expected: %08X)", crc, expected_ack_crc); return false; } + void onMessageRecv(const ContactInfo& from, bool was_flood, uint32_t sender_timestamp, const char *text) override { + Serial.printf("(%s) MSG -> from %s\n", was_flood ? "FLOOD" : "DIRECT", from.name); + Serial.printf(" %s\n", text); + + if (strcmp(text, "clock sync") == 0) { // special text command + uint32_t curr = getRTCClock()->getCurrentTime(); + if (sender_timestamp > curr) { + getRTCClock()->setCurrentTime(sender_timestamp + 1); + Serial.println(" (OK - clock set!)"); + } else { + Serial.println(" (ERR: clock cannot go backwards)"); + } + } + } + + uint32_t calcFloodTimeoutMillisFor(uint32_t pkt_airtime_millis) const override { + return SEND_TIMEOUT_BASE_MILLIS + (FLOOD_SEND_TIMEOUT_FACTOR * pkt_airtime_millis); + } + uint32_t calcDirectTimeoutMillisFor(uint32_t pkt_airtime_millis, uint8_t path_len) const override { + return SEND_TIMEOUT_BASE_MILLIS + + ( (pkt_airtime_millis*DIRECT_SEND_PERHOP_FACTOR + DIRECT_SEND_PERHOP_EXTRA_MILLIS) * (path_len + 1)); + } + + void onSendTimeout() override { + Serial.println(" ERROR: timed out, no ACK."); + } + public: - uint32_t expected_ack_crc; - unsigned long last_msg_sent; + char self_name[sizeof(ContactInfo::name)]; MyMesh(RadioLibWrapper& radio, mesh::RNG& rng, mesh::RTCClock& rtc, SimpleMeshTables& tables) - : mesh::Mesh(radio, *new ArduinoMillis(), rng, rtc, *new StaticPoolPacketManager(16), tables) + : BaseChatMesh(radio, *new ArduinoMillis(), rng, rtc, *new StaticPoolPacketManager(16), tables) { - num_contacts = 0; + command[0] = 0; + curr_recipient = NULL; } - mesh::Packet* composeMsgPacket(ContactInfo& recipient, uint8_t attempt, const char *text) { - int text_len = strlen(text); - if (text_len > MAX_TEXT_LEN) return NULL; + void begin(FILESYSTEM& fs) { + _fs = &fs; - uint8_t temp[5+MAX_TEXT_LEN+1]; - uint32_t timestamp = getRTCClock()->getCurrentTime(); - memcpy(temp, ×tamp, 4); // mostly an extra blob to help make packet_hash unique - temp[4] = attempt; - memcpy(&temp[5], text, text_len + 1); + BaseChatMesh::begin(); - // calc expected ACK reply - mesh::Utils::sha256((uint8_t *)&expected_ack_crc, 4, temp, 5 + text_len, self_id.pub_key, PUB_KEY_SIZE); - last_msg_sent = _ms->getMillis(); - - return createDatagram(PAYLOAD_TYPE_TXT_MSG, recipient.id, recipient.shared_secret, temp, 5 + text_len); - } - - void sendSelfAdvert() { - uint8_t app_data[MAX_ADVERT_DATA_SIZE]; - uint8_t app_data_len; - { - AdvertDataBuilder builder(ADV_TYPE_CHAT, USER_NAME); - app_data_len = builder.encodeTo(app_data); + strcpy(self_name, "UNSET"); + IdentityStore store(fs, "/identity"); + if (!store.load("_main", self_id, self_name, sizeof(self_name))) { + self_id = mesh::LocalIdentity(getRNG()); // create new random identity + store.save("_main", self_id); } - - mesh::Packet* adv = createAdvert(self_id, app_data, app_data_len); - if (adv) { - sendFlood(adv, 800); // add slight delay - Serial.println(" (advert sent)."); + + loadContacts(); + } + + void showWelcome() { + Serial.println("===== MeshCore Chat Terminal ====="); + Serial.println(); + Serial.printf("WELCOME %s\n", self_name); + Serial.println(" (enter 'help' for basic commands)"); + Serial.println(); + } + + // ContactVisitor + void onContactVisit(const ContactInfo& contact) override { + Serial.printf(" %s - ", contact.name); + char tmp[40]; + int32_t secs = contact.last_advert_timestamp - getRTCClock()->getCurrentTime(); + AdvertTimeHelper::formatRelativeTimeDiff(tmp, secs, false); + Serial.println(tmp); + } + + void handleCommand(const char* command) { + while (*command == ' ') command++; // skip leading spaces + + if (memcmp(command, "send ", 5) == 0) { + if (curr_recipient) { + const char *text = &command[5]; + int result = sendMessage(*curr_recipient, 0, text, expected_ack_crc); + if (result == MSG_SEND_FAILED) { + Serial.println(" ERROR: unable to send."); + } else { + last_msg_sent = _ms->getMillis(); + Serial.printf(" (message sent - %s)\n", result == MSG_SEND_SENT_FLOOD ? "FLOOD" : "DIRECT"); + } + } else { + Serial.println(" ERROR: no recipient selected (use 'to' cmd)."); + } + } else if (memcmp(command, "list", 4) == 0) { // show Contact list, by most recent + int n = 0; + if (command[4] == ' ') { // optional param, last 'N' + n = atoi(&command[5]); + } + scanRecentContacts(n, this); + } else if (strcmp(command, "clock") == 0) { // show current time + uint32_t now = getRTCClock()->getCurrentTime(); + DateTime dt = DateTime(now); + Serial.printf( "%02d:%02d - %d/%d/%d UTC\n", dt.hour(), dt.minute(), dt.day(), dt.month(), dt.year()); + } else if (memcmp(command, "name ", 5) == 0) { // set name + strncpy(self_name, &command[5], sizeof(self_name)-1); + self_name[sizeof(self_name)-1] = 0; + IdentityStore store(*_fs, "/identity"); // update IdentityStore + store.save("_main", self_id, self_name); + } else if (memcmp(command, "to ", 3) == 0) { // set current recipient + curr_recipient = searchContactsByPrefix(&command[3]); + if (curr_recipient) { + Serial.printf(" Recipient %s now selected.\n", curr_recipient->name); + } else { + Serial.println(" Error: Name prefix not found."); + } + } else if (strcmp(command, "to") == 0) { // show current recipient + if (curr_recipient) { + Serial.printf(" Current: %s\n", curr_recipient->name); + } else { + Serial.println(" Err: no recipient selected"); + } + } else if (strcmp(command, "advert") == 0) { + auto pkt = createSelfAdvert(self_name); + if (pkt) { + sendZeroHop(pkt); + Serial.println(" (advert sent, zero hop)."); + } else { + Serial.println(" ERR: unable to send"); + } + } else if (strcmp(command, "reset path") == 0) { + if (curr_recipient) { + resetPathTo(*curr_recipient); + saveContacts(); + Serial.println(" Done."); + } + } else if (memcmp(command, "help", 4) == 0) { + Serial.printf("Hello %s, Commands:\n", self_name); + Serial.println(" name "); + Serial.println(" clock"); + Serial.println(" list {n}"); + Serial.println(" to "); + Serial.println(" to"); + Serial.println(" send "); + Serial.println(" advert"); + Serial.println(" reset path"); } else { - Serial.println(" ERROR: unable to create packet."); + Serial.print(" ERROR: unknown command: "); Serial.println(command); + } + } + + void loop() { + BaseChatMesh::loop(); + + int len = strlen(command); + while (Serial.available() && len < sizeof(command)-1) { + char c = Serial.read(); + if (c != '\n') { + command[len++] = c; + command[len] = 0; + } + Serial.print(c); + } + if (len == sizeof(command)-1) { // command buffer full + command[sizeof(command)-1] = '\r'; + } + + if (len > 0 && command[len - 1] == '\r') { // received complete line + command[len - 1] = 0; // replace newline with C string null terminator + + handleCommand(command); + command[0] = 0; // reset command buffer } } }; @@ -299,8 +359,6 @@ void halt() { while (1) ; } -static char command[MAX_TEXT_LEN+1]; - void setup() { Serial.begin(115200); @@ -336,91 +394,25 @@ void setup() { fast_rng.begin(radio.random(0x7FFFFFFF)); -#if RUN_AS_ALICE - Serial.println(" --- user: Alice ---"); - the_mesh.self_id = mesh::LocalIdentity(alice_private, alice_public); - the_mesh.addContact("Bob", mesh::Identity(bob_public)); +#if defined(NRF52_PLATFORM) + InternalFS.begin(); + the_mesh.begin(InternalFS); +#elif defined(ESP32) + SPIFFS.begin(true); + the_mesh.begin(SPIFFS); #else - Serial.println(" --- user: Bob ---"); - the_mesh.self_id = mesh::LocalIdentity(bob_private, bob_public); - the_mesh.addContact("Alice", mesh::Identity(alice_public)); + #error "need to define filesystem" #endif - Serial.println("Help:"); - Serial.println(" enter 'adv' to advertise presence to mesh"); - Serial.println(" enter 'send {message text}' to send a message"); - the_mesh.begin(); - - command[0] = 0; - txt_send_timeout = 0; + the_mesh.showWelcome(); // send out initial Advertisement to the mesh - the_mesh.sendSelfAdvert(); + auto pkt = the_mesh.createSelfAdvert(the_mesh.self_name); + if (pkt) { + the_mesh.sendFlood(pkt, 1200); // add slight delay + } } void loop() { - int len = strlen(command); - while (Serial.available() && len < sizeof(command)-1) { - char c = Serial.read(); - if (c != '\n') { - command[len++] = c; - command[len] = 0; - } - Serial.print(c); - } - if (len == sizeof(command)-1) { // command buffer full - command[sizeof(command)-1] = '\r'; - } - - if (len > 0 && command[len - 1] == '\r') { // received complete line - command[len - 1] = 0; // replace newline with C string null terminator - - if (memcmp(command, "send ", 5) == 0) { - // TODO: some way to select recipient?? - ContactInfo& recipient = the_mesh.contacts[curr_contact_idx]; - - const char *text = &command[5]; - mesh::Packet* pkt = the_mesh.composeMsgPacket(recipient, 0, text); - if (pkt) { - uint32_t t = radio.getTimeOnAir(pkt->payload_len + pkt->path_len + 2) / 1000; - - if (recipient.out_path_len < 0) { - the_mesh.sendFlood(pkt); - txt_send_timeout = the_mesh.futureMillis(SEND_TIMEOUT_BASE_MILLIS + (FLOOD_SEND_TIMEOUT_FACTOR * t)); - Serial.printf(" (message sent - FLOOD, t=%d)\n", t); - } else { - the_mesh.sendDirect(pkt, recipient.out_path, recipient.out_path_len); - - txt_send_timeout = the_mesh.futureMillis(SEND_TIMEOUT_BASE_MILLIS + - ( (t*DIRECT_SEND_PERHOP_FACTOR + DIRECT_SEND_PERHOP_EXTRA_MILLIS) * (recipient.out_path_len + 1))); - - Serial.printf(" (message sent - DIRECT, t=%d)\n", t); - } - } else { - Serial.println(" ERROR: unable to create packet."); - } - } else if (strcmp(command, "adv") == 0) { - the_mesh.sendSelfAdvert(); - } else if (strcmp(command, "key") == 0) { - mesh::LocalIdentity new_id(the_mesh.getRNG()); - new_id.printTo(Serial); - } else { - Serial.print(" ERROR: unknown command: "); Serial.println(command); - } - - command[0] = 0; // reset command buffer - } - - if (txt_send_timeout && the_mesh.millisHasNowPassed(txt_send_timeout)) { - // failed to get an ACK - ContactInfo& recipient = the_mesh.contacts[curr_contact_idx]; - Serial.println(" ERROR: timed out, no ACK."); - - // path to our contact is now possibly broken, fallback to Flood mode - recipient.out_path_len = -1; - - txt_send_timeout = 0; - } - the_mesh.loop(); } diff --git a/platformio.ini b/platformio.ini index da8017bf..3b1b6afd 100644 --- a/platformio.ini +++ b/platformio.ini @@ -90,23 +90,17 @@ lib_deps = ${Heltec_lora32_v3.lib_deps} adafruit/RTClib @ ^2.1.3 -[env:Heltec_v3_chat_alice] +[env:Heltec_v3_terminal_chat] extends = Heltec_lora32_v3 build_flags = ${Heltec_lora32_v3.build_flags} - -D RUN_AS_ALICE=true - -D MESH_PACKET_LOGGING=1 - -D MESH_DEBUG=1 -build_src_filter = ${Heltec_lora32_v3.build_src_filter} +<../examples/simple_secure_chat/main.cpp> - -[env:Heltec_v3_chat_bob] -extends = Heltec_lora32_v3 -build_flags = - ${Heltec_lora32_v3.build_flags} - -D RUN_AS_ALICE=false - -D MESH_PACKET_LOGGING=1 - -D MESH_DEBUG=1 + -D MAX_CONTACTS=100 +; -D MESH_PACKET_LOGGING=1 +; -D MESH_DEBUG=1 build_src_filter = ${Heltec_lora32_v3.build_src_filter} +<../examples/simple_secure_chat/main.cpp> +lib_deps = + ${Heltec_lora32_v3.lib_deps} + adafruit/RTClib @ ^2.1.3 [env:Heltec_v3_test_admin] extends = Heltec_lora32_v3 @@ -261,20 +255,14 @@ lib_deps = ${rak4631.lib_deps} adafruit/RTClib @ ^2.1.3 -[env:RAK_4631_chat_alice] +[env:RAK_4631_terminal_chat] extends = rak4631 build_flags = ${rak4631.build_flags} - -D RUN_AS_ALICE=true - -D MESH_PACKET_LOGGING=1 - -D MESH_DEBUG=1 -build_src_filter = ${rak4631.build_src_filter} +<../examples/simple_secure_chat/main.cpp> - -[env:RAK_4631_chat_bob] -extends = rak4631 -build_flags = - ${rak4631.build_flags} - -D RUN_AS_ALICE=false + -D MAX_CONTACTS=100 -D MESH_PACKET_LOGGING=1 -D MESH_DEBUG=1 build_src_filter = ${rak4631.build_src_filter} +<../examples/simple_secure_chat/main.cpp> +lib_deps = + ${rak4631.lib_deps} + adafruit/RTClib @ ^2.1.3 diff --git a/src/helpers/AdvertDataHelpers.cpp b/src/helpers/AdvertDataHelpers.cpp index 9a4aefb5..b6ad0bbf 100644 --- a/src/helpers/AdvertDataHelpers.cpp +++ b/src/helpers/AdvertDataHelpers.cpp @@ -51,3 +51,31 @@ _valid = true; } } + +#include + +void AdvertTimeHelper::formatRelativeTimeDiff(char dest[], int32_t seconds_from_now, bool short_fmt) { + const char *suffix; + if (seconds_from_now < 0) { + suffix = short_fmt ? "" : " ago"; + seconds_from_now = -seconds_from_now; + } else { + suffix = short_fmt ? "" : " from now"; + } + + if (seconds_from_now < 60) { + sprintf(dest, "%d secs %s", seconds_from_now, suffix); + } else { + int32_t mins = seconds_from_now / 60; + if (mins < 60) { + sprintf(dest, "%d mins %s", mins, suffix); + } else { + int32_t hours = mins / 60; + if (hours < 24) { + sprintf(dest, "%d hours %s", hours, suffix); + } else { + sprintf(dest, "%d days %s", hours / 24, suffix); + } + } + } +} \ No newline at end of file diff --git a/src/helpers/AdvertDataHelpers.h b/src/helpers/AdvertDataHelpers.h index 1c8f3517..a8a24a0a 100644 --- a/src/helpers/AdvertDataHelpers.h +++ b/src/helpers/AdvertDataHelpers.h @@ -53,3 +53,8 @@ public: double getLat() const { return ((double)_lat) / 1000000.0; } double getLon() const { return ((double)_lon) / 1000000.0; } }; + +class AdvertTimeHelper { +public: + static void formatRelativeTimeDiff(char dest[], int32_t seconds_from_now, bool short_fmt); +}; diff --git a/src/helpers/BaseChatMesh.cpp b/src/helpers/BaseChatMesh.cpp new file mode 100644 index 00000000..067496a4 --- /dev/null +++ b/src/helpers/BaseChatMesh.cpp @@ -0,0 +1,258 @@ +#include + +mesh::Packet* BaseChatMesh::createSelfAdvert(const char* name) { + uint8_t app_data[MAX_ADVERT_DATA_SIZE]; + uint8_t app_data_len; + { + AdvertDataBuilder builder(ADV_TYPE_CHAT, name); + app_data_len = builder.encodeTo(app_data); + } + + return createAdvert(self_id, app_data, app_data_len); +} + +void BaseChatMesh::onAdvertRecv(mesh::Packet* packet, const mesh::Identity& id, uint32_t timestamp, const uint8_t* app_data, size_t app_data_len) { + AdvertDataParser parser(app_data, app_data_len); + if (!(parser.isValid() && parser.hasName())) { + MESH_DEBUG_PRINTLN("onAdvertRecv: invalid app_data, or name is missing: len=%d", app_data_len); + return; + } + + ContactInfo* from = NULL; + for (int i = 0; i < num_contacts; i++) { + if (id.matches(contacts[i].id)) { // is from one of our contacts + from = &contacts[i]; + if (timestamp <= from->last_advert_timestamp) { // check for replay attacks!! + MESH_DEBUG_PRINTLN("onAdvertRecv: Possible replay attack, name: %s", from->name); + return; + } + break; + } + } + + bool is_new = false; + if (from == NULL) { + is_new = true; + if (num_contacts < MAX_CONTACTS) { + from = &contacts[num_contacts++]; + from->id = id; + from->out_path_len = -1; // initially out_path is unknown + // only need to calculate the shared_secret once, for better performance + self_id.calcSharedSecret(from->shared_secret, id); + } else { + MESH_DEBUG_PRINTLN("onAdvertRecv: contacts table is full!"); + return; + } + } + + // update + strncpy(from->name, parser.getName(), sizeof(from->name)-1); + from->name[sizeof(from->name)-1] = 0; + from->type = parser.getType(); + from->last_advert_timestamp = timestamp; + + onDiscoveredContact(*from, is_new); // let UI know +} + +int BaseChatMesh::searchPeersByHash(const uint8_t* hash) { + int n = 0; + for (int i = 0; i < num_contacts && n < MAX_SEARCH_RESULTS; i++) { + if (contacts[i].id.isHashMatch(hash)) { + matching_peer_indexes[n++] = i; // store the INDEXES of matching contacts (for subsequent 'peer' methods) + } + } + return n; +} + +void BaseChatMesh::getPeerSharedSecret(uint8_t* dest_secret, int peer_idx) { + int i = matching_peer_indexes[peer_idx]; + if (i >= 0 && i < num_contacts) { + // lookup pre-calculated shared_secret + memcpy(dest_secret, contacts[i].shared_secret, PUB_KEY_SIZE); + } else { + MESH_DEBUG_PRINTLN("getPeerSharedSecret: Invalid peer idx: %d", i); + } +} + +void BaseChatMesh::onPeerDataRecv(mesh::Packet* packet, uint8_t type, int sender_idx, const uint8_t* secret, uint8_t* data, size_t len) { + if (type == PAYLOAD_TYPE_TXT_MSG && len > 5) { + int i = matching_peer_indexes[sender_idx]; + if (i < 0 || i >= num_contacts) { + MESH_DEBUG_PRINTLN("onPeerDataRecv: Invalid sender idx: %d", i); + return; + } + + ContactInfo& from = contacts[i]; + + uint32_t timestamp; + memcpy(×tamp, data, 4); // timestamp (by sender's RTC clock - which could be wrong) + uint flags = data[4]; // message attempt number, and other flags + + // len can be > original length, but 'text' will be padded with zeroes + data[len] = 0; // need to make a C string again, with null terminator + + //if ( ! alreadyReceived timestamp ) { + if ((flags >> 2) == 0) { // plain text msg? + onMessageRecv(from, packet->isRouteFlood(), timestamp, (const char *) &data[5]); // let UI know + + uint32_t ack_hash; // calc truncated hash of the message timestamp + text + sender pub_key, to prove to sender that we got it + mesh::Utils::sha256((uint8_t *) &ack_hash, 4, data, 5 + strlen((char *)&data[5]), from.id.pub_key, PUB_KEY_SIZE); + + if (packet->isRouteFlood()) { + // let this sender know path TO here, so they can use sendDirect(), and ALSO encode the ACK + mesh::Packet* path = createPathReturn(from.id, secret, packet->path, packet->path_len, + PAYLOAD_TYPE_ACK, (uint8_t *) &ack_hash, 4); + if (path) sendFlood(path); + } else { + mesh::Packet* ack = createAck(ack_hash); + if (ack) { + if (from.out_path_len < 0) { + sendFlood(ack); + } else { + sendDirect(ack, from.out_path, from.out_path_len); + } + } + } + } else { + MESH_DEBUG_PRINTLN("onPeerDataRecv: unsupported message type: %u", (uint32_t) (flags >> 2)); + } + } +} + +bool BaseChatMesh::onPeerPathRecv(mesh::Packet* packet, int sender_idx, const uint8_t* secret, uint8_t* path, uint8_t path_len, uint8_t extra_type, uint8_t* extra, uint8_t extra_len) { + int i = matching_peer_indexes[sender_idx]; + if (i < 0 || i >= num_contacts) { + MESH_DEBUG_PRINTLN("onPeerPathRecv: Invalid sender idx: %d", i); + return false; + } + + ContactInfo& from = contacts[i]; + + // NOTE: for this impl, we just replace the current 'out_path' regardless, whenever sender sends us a new out_path. + // FUTURE: could store multiple out_paths per contact, and try to find which is the 'best'(?) + memcpy(from.out_path, path, from.out_path_len = path_len); // store a copy of path, for sendDirect() + + onContactPathUpdated(from); + + if (extra_type == PAYLOAD_TYPE_ACK && extra_len >= 4) { + // also got an encoded ACK! + if (processAck(extra)) { + txt_send_timeout = 0; // matched one we're waiting for, cancel timeout timer + } + } + return true; // send reciprocal path if necessary +} + +void BaseChatMesh::onAckRecv(mesh::Packet* packet, uint32_t ack_crc) { + if (processAck((uint8_t *)&ack_crc)) { + txt_send_timeout = 0; // matched one we're waiting for, cancel timeout timer + packet->markDoNotRetransmit(); // ACK was for this node, so don't retransmit + } +} + +mesh::Packet* BaseChatMesh::composeMsgPacket(const ContactInfo& recipient, uint8_t attempt, const char *text, uint32_t& expected_ack) { + int text_len = strlen(text); + if (text_len > MAX_TEXT_LEN) return NULL; + + uint8_t temp[5+MAX_TEXT_LEN+1]; + uint32_t timestamp = getRTCClock()->getCurrentTime(); + memcpy(temp, ×tamp, 4); // mostly an extra blob to help make packet_hash unique + temp[4] = (attempt & 3); + memcpy(&temp[5], text, text_len + 1); + + // calc expected ACK reply + mesh::Utils::sha256((uint8_t *)&expected_ack, 4, temp, 5 + text_len, self_id.pub_key, PUB_KEY_SIZE); + + return createDatagram(PAYLOAD_TYPE_TXT_MSG, recipient.id, recipient.shared_secret, temp, 5 + text_len); +} + +int BaseChatMesh::sendMessage(const ContactInfo& recipient, uint8_t attempt, const char* text, uint32_t& expected_ack) { + mesh::Packet* pkt = composeMsgPacket(recipient, attempt, text, expected_ack); + if (pkt == NULL) return MSG_SEND_FAILED; + + uint32_t t = _radio->getEstAirtimeFor(pkt->payload_len + pkt->path_len + 2); + + int rc; + if (recipient.out_path_len < 0) { + sendFlood(pkt); + txt_send_timeout = futureMillis(calcFloodTimeoutMillisFor(t)); + rc = MSG_SEND_SENT_FLOOD; + } else { + sendDirect(pkt, recipient.out_path, recipient.out_path_len); + txt_send_timeout = futureMillis(calcDirectTimeoutMillisFor(t, recipient.out_path_len)); + rc = MSG_SEND_SENT_DIRECT; + } + return rc; +} + +void BaseChatMesh::resetPathTo(ContactInfo& recipient) { + if (recipient.out_path_len >= 0) { + recipient.out_path_len = -1; + } +} + +static ContactInfo* table; // pass via global :-( + +static int cmp_adv_timestamp(const void *a, const void *b) { + int a_idx = *((int *)a); + int b_idx = *((int *)b); + if (table[b_idx].last_advert_timestamp > table[a_idx].last_advert_timestamp) return 1; + if (table[b_idx].last_advert_timestamp < table[a_idx].last_advert_timestamp) return -1; + return 0; +} + +void BaseChatMesh::scanRecentContacts(int last_n, ContactVisitor* visitor) { + for (int i = 0; i < num_contacts; i++) { // sort the INDEXES into contacts[] + sort_array[i] = i; + } + table = contacts; // pass via global *sigh* :-( + qsort(sort_array, num_contacts, sizeof(sort_array[0]), cmp_adv_timestamp); + + if (last_n == 0) { + last_n = num_contacts; // scan ALL + } else { + if (last_n > num_contacts) last_n = num_contacts; + } + for (int i = 0; i < last_n; i++) { + visitor->onContactVisit(contacts[sort_array[i]]); + } +} + +ContactInfo* BaseChatMesh::searchContactsByPrefix(const char* name_prefix) { + int len = strlen(name_prefix); + for (int i = 0; i < num_contacts; i++) { + auto c = &contacts[i]; + if (memcmp(c->name, name_prefix, len) == 0) return c; + } + return NULL; // not found +} + +bool BaseChatMesh::addContact(const ContactInfo& contact) { + if (num_contacts < MAX_CONTACTS) { + auto dest = &contacts[num_contacts++]; + *dest = contact; + + // calc the ECDH shared secret (just once for performance) + self_id.calcSharedSecret(dest->shared_secret, contact.id); + + return true; // success + } + return false; +} + +bool ContactsIterator::hasNext(const BaseChatMesh* mesh, ContactInfo& dest) { + if (next_idx >= mesh->num_contacts) return false; + + dest = mesh->contacts[next_idx++]; + return true; +} + +void BaseChatMesh::loop() { + Mesh::loop(); + + if (txt_send_timeout && millisHasNowPassed(txt_send_timeout)) { + // failed to get an ACK + onSendTimeout(); + txt_send_timeout = 0; + } +} \ No newline at end of file diff --git a/src/helpers/BaseChatMesh.h b/src/helpers/BaseChatMesh.h new file mode 100644 index 00000000..84312d15 --- /dev/null +++ b/src/helpers/BaseChatMesh.h @@ -0,0 +1,88 @@ +#pragma once + +#include // needed for PlatformIO +#include +#include + +#define MAX_TEXT_LEN (10*CIPHER_BLOCK_SIZE) // must be LESS than (MAX_PACKET_PAYLOAD - 4 - CIPHER_MAC_SIZE - 1) + +struct ContactInfo { + mesh::Identity id; + char name[32]; + uint8_t type; // on of ADV_TYPE_* + uint8_t flags; + int8_t out_path_len; + uint8_t out_path[MAX_PATH_SIZE]; + uint32_t last_advert_timestamp; + uint8_t shared_secret[PUB_KEY_SIZE]; +}; + +#define MAX_SEARCH_RESULTS 8 + +#define MSG_SEND_FAILED 0 +#define MSG_SEND_SENT_FLOOD 1 +#define MSG_SEND_SENT_DIRECT 2 + +class ContactVisitor { +public: + virtual void onContactVisit(const ContactInfo& contact) = 0; +}; + +class BaseChatMesh; + +class ContactsIterator { + int next_idx = 0; +public: + bool hasNext(const BaseChatMesh* mesh, ContactInfo& dest); +}; + +/** + * \brief abstract Mesh class for common 'chat' client + */ +class BaseChatMesh : public mesh::Mesh { + + friend class ContactsIterator; + + ContactInfo contacts[MAX_CONTACTS]; + int num_contacts; + int sort_array[MAX_CONTACTS]; + int matching_peer_indexes[MAX_SEARCH_RESULTS]; + unsigned long txt_send_timeout; + + mesh::Packet* composeMsgPacket(const ContactInfo& recipient, uint8_t attempt, const char *text, uint32_t& expected_ack); + +protected: + 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) + { + num_contacts = 0; + txt_send_timeout = 0; + } + + // 'UI' concepts, for sub-classes to implement + virtual void onDiscoveredContact(ContactInfo& contact, bool is_new) = 0; + virtual bool processAck(const uint8_t *data) = 0; + virtual void onContactPathUpdated(const ContactInfo& contact) = 0; + virtual void onMessageRecv(const ContactInfo& contact, bool was_flood, uint32_t sender_timestamp, const char *text) = 0; + virtual uint32_t calcFloodTimeoutMillisFor(uint32_t pkt_airtime_millis) const = 0; + virtual uint32_t calcDirectTimeoutMillisFor(uint32_t pkt_airtime_millis, uint8_t path_len) const = 0; + virtual void onSendTimeout() = 0; + + // Mesh overrides + void onAdvertRecv(mesh::Packet* packet, const mesh::Identity& id, uint32_t timestamp, const uint8_t* app_data, size_t app_data_len) override; + int searchPeersByHash(const uint8_t* hash) override; + void getPeerSharedSecret(uint8_t* dest_secret, int peer_idx) override; + void onPeerDataRecv(mesh::Packet* packet, uint8_t type, int sender_idx, const uint8_t* secret, uint8_t* data, size_t len) override; + bool onPeerPathRecv(mesh::Packet* packet, int sender_idx, const uint8_t* secret, uint8_t* path, uint8_t path_len, uint8_t extra_type, uint8_t* extra, uint8_t extra_len) override; + void onAckRecv(mesh::Packet* packet, uint32_t ack_crc) override; + +public: + mesh::Packet* createSelfAdvert(const char* name); + int sendMessage(const ContactInfo& recipient, uint8_t attempt, const char* text, uint32_t& expected_ack); + void resetPathTo(ContactInfo& recipient); + void scanRecentContacts(int last_n, ContactVisitor* visitor); + ContactInfo* searchContactsByPrefix(const char* name_prefix); + bool addContact(const ContactInfo& contact); + + void loop(); +}; diff --git a/src/helpers/IdentityStore.cpp b/src/helpers/IdentityStore.cpp index 1a344ad4..11838385 100644 --- a/src/helpers/IdentityStore.cpp +++ b/src/helpers/IdentityStore.cpp @@ -14,6 +14,25 @@ bool IdentityStore::load(const char *name, mesh::LocalIdentity& id) { return loaded; } +bool IdentityStore::load(const char *name, mesh::LocalIdentity& id, char display_name[], int max_name_sz) { + bool loaded = false; + char filename[40]; + sprintf(filename, "%s/%s.id", _dir, name); + if (_fs->exists(filename)) { + File file = _fs->open(filename); + if (file) { + loaded = id.readFrom(file); + + int n = min(32, max_name_sz); // up to 32 bytes + file.read((uint8_t *) display_name, n); + display_name[n - 1] = 0; // ensure null terminator + + file.close(); + } + } + return loaded; +} + bool IdentityStore::save(const char *name, const mesh::LocalIdentity& id) { char filename[40]; sprintf(filename, "%s/%s.id", _dir, name); @@ -31,3 +50,29 @@ bool IdentityStore::save(const char *name, const mesh::LocalIdentity& id) { } return false; } + +bool IdentityStore::save(const char *name, const mesh::LocalIdentity& id, const char display_name[]) { + char filename[40]; + sprintf(filename, "%s/%s.id", _dir, name); + +#if defined(NRF52_PLATFORM) + File file = _fs->open(filename, FILE_O_WRITE); + if (file) { file.seek(0); file.truncate(); } +#else + File file = _fs->open(filename, "w", true); +#endif + if (file) { + id.writeTo(file); + + uint8_t tmp[32]; + memset(tmp, 0, sizeof(tmp)); + int n = strlen(display_name); + if (n > sizeof(tmp)-1) n = sizeof(tmp)-1; + memcpy(tmp, display_name, n); + file.write(tmp, sizeof(tmp)); + + file.close(); + return true; + } + return false; +} diff --git a/src/helpers/IdentityStore.h b/src/helpers/IdentityStore.h index 098e1014..2a5363ea 100644 --- a/src/helpers/IdentityStore.h +++ b/src/helpers/IdentityStore.h @@ -20,5 +20,7 @@ public: void begin() { _fs->mkdir(_dir); } bool load(const char *name, mesh::LocalIdentity& id); + bool load(const char *name, mesh::LocalIdentity& id, char display_name[], int max_name_sz); bool save(const char *name, const mesh::LocalIdentity& id); + bool save(const char *name, const mesh::LocalIdentity& id, const char display_name[]); };