Browse Source

* simple_secure_chat now with a proper CLI
* new: BaseChatMesh class, for abstract chat client

Scott Powell 1 year ago
parent
commit
00a9e93754

+ 269 - 277
examples/simple_secure_chat/main.cpp

@@ -1,13 +1,20 @@
 #include <Arduino.h>   // needed for PlatformIO
 #include <Mesh.h>
 
+#if defined(NRF52_PLATFORM)
+  #include <InternalFileSystem.h>
+#elif defined(ESP32)
+  #include <SPIFFS.h>
+#endif
+
 #define RADIOLIB_STATIC_ONLY 1
 #include <RadioLib.h>
 #include <helpers/RadioLibWrappers.h>
 #include <helpers/ArduinoHelpers.h>
 #include <helpers/StaticPoolPacketManager.h>
 #include <helpers/SimpleMeshTables.h>
-#include <helpers/AdvertDataHelpers.h>
+#include <helpers/IdentityStore.h>
+#include <RTClib.h>
 
 /* ---------------------------------- 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 <helpers/BaseChatMesh.h>
+
+#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 <helpers/HeltecV3Board.h>
   #include <helpers/CustomSX1262Wrapper.h>
   static HeltecV3Board board;
+#elif defined(ARDUINO_XIAO_ESP32C3)
+  #include <helpers/XiaoC3Board.h>
+  #include <helpers/CustomSX1262Wrapper.h>
+  #include <helpers/CustomSX1268Wrapper.h>
+  static XiaoC3Board board;
+#elif defined(SEEED_XIAO_S3)
+  #include <helpers/ESP32Board.h>
+  #include <helpers/CustomSX1262Wrapper.h>
+  static ESP32Board board;
 #elif defined(RAK_4631)
   #include <helpers/RAK4631Board.h>
   #include <helpers/CustomSX1262Wrapper.h>
@@ -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
-
-#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;
-  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];
-};
-
-class MyMesh : public mesh::Mesh {
-public:
-  ContactInfo contacts[MAX_CONTACTS];
-  int num_contacts;
-
-  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
-
-      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++;
-    }
-  }
-
-protected:
-  int  matching_peer_indexes[MAX_SEARCH_RESULTS];
-
-  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;
+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];
+
+  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
   }
 
-  #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);
+  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;
+
+          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);
+
+          if (!success) break;  // EOF
+
+          c.id = mesh::Identity(pub_key);
+          if (!addContact(c)) full = true;
         }
-        return;
+        file.close();
       }
     }
-    // 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 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(&timestamp, 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);
-          }
-        }
+  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();
     }
   }
 
-  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);
+protected:
+  void onDiscoveredContact(ContactInfo& contact, bool is_new) override {
+    // TODO: if not in favs,  prompt to add as fav(?)
 
-    // 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()
+    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();
 
-    if (extra_type == PAYLOAD_TYPE_ACK && extra_len >= 4) {
-      // also got an encoded ACK!
-      processAck(extra);
-    }
-    return true;  // send reciprocal path if necessary
+    saveContacts();
   }
 
-  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
-    }
+  void onContactPathUpdated(const ContactInfo& contact) override {
+    Serial.printf("PATH to: %s, path_len=%d\n", contact.name, (int32_t) contact.out_path_len);
+    saveContacts();
   }
 
-  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, &timestamp, 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();
+    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);
+    }
 
-    return createDatagram(PAYLOAD_TYPE_TXT_MSG, recipient.id, recipient.shared_secret, temp, 5 + text_len);
+    loadContacts();
   }
 
-  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);
-    }
- 
-    mesh::Packet* adv = createAdvert(self_id, app_data, app_data_len);
-    if (adv) {
-      sendFlood(adv, 800);  // add slight delay
-      Serial.println("   (advert sent).");
+  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 <your name>");
+      Serial.println("   clock");
+      Serial.println("   list {n}");
+      Serial.println("   to <recipient name or prefix>");
+      Serial.println("   to");
+      Serial.println("   send <text>");
+      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();
 }

+ 12 - 24
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]
-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]
+[env:RAK_4631_terminal_chat]
 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

+ 28 - 0
src/helpers/AdvertDataHelpers.cpp

@@ -51,3 +51,31 @@
       _valid = true;
     }
   }
+
+#include <Arduino.h>
+
+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);
+      }
+    }
+  }
+}

+ 5 - 0
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);
+};

+ 258 - 0
src/helpers/BaseChatMesh.cpp

@@ -0,0 +1,258 @@
+#include <helpers/BaseChatMesh.h>
+
+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(&timestamp, 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, &timestamp, 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;
+  }
+}

+ 88 - 0
src/helpers/BaseChatMesh.h

@@ -0,0 +1,88 @@
+#pragma once
+
+#include <Arduino.h>   // needed for PlatformIO
+#include <Mesh.h>
+#include <helpers/AdvertDataHelpers.h>
+
+#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();
+};

+ 45 - 0
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;
+}

+ 2 - 0
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[]);
 };