feat(firmware): MeshCore message RX — decrypt DMs addressed to us

- vendor orlp/ed25519 (lib/ed25519, MIT — the exact lib MeshCore uses) for
  Curve25519 ECDH key exchange; switch identity/advert signing to it (same
  seed -> same pubkey, so peers still recognise us; sigs are byte-identical)
- store pubkeys from heard MeshCore adverts (contacts table) so a DM's
  1-byte src_hash can be resolved to a full sender key
- decode PAYLOAD_TYPE_TXT_MSG: ECDH shared secret, HMAC-SHA256 MAC check,
  AES-128-ECB decrypt, parse timestamp+text, drive the message modal
- verified end-to-end: real MeshCore node -> "hello from HP Pro Desk" decoded

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-07-01 18:30:20 +01:00
co-authored by Claude Opus 4.8
parent 8e5b4c97d7
commit 38c146c477
19 changed files with 5112 additions and 8 deletions
+122 -8
View File
@@ -26,8 +26,12 @@
#include "mbedtls/aes.h"
#include "esp_mac.h"
#include "esp_random.h"
#include <Ed25519.h> // rweather/Crypto — MeshCore identity + advert signing
#include <Preferences.h> // NVS-backed persistent Ed25519 key
#include <AES.h> // rweather/Crypto — AES-128-ECB for MeshCore DM cipher
#include <SHA256.h> // rweather/Crypto — HMAC-SHA256 for MeshCore packet MAC
#include <Preferences.h> // NVS-backed persistent identity seed
extern "C" {
#include "ed_25519.h" // orlp/ed25519 (vendored, lib/ed25519) — identity, sign, ECDH
}
// --- Heltec V3 SX1262 pin map (from the Heltec V3 schematic) ----------------
static const int PIN_LORA_NSS = 8;
@@ -321,7 +325,8 @@ static void sendMeshtasticText(const char* text) {
// signed message = pub_key || timestamp || app_data (verified in MeshCore
// Mesh.cpp onRecvPacket PAYLOAD_TYPE_ADVERT). app_data = flags(0x81 = chat
// node + has-name) followed by the UTF-8 name.
static uint8_t mcPrv[32]; // Ed25519 private key (seed)
static uint8_t mcSeed[32]; // persistent 32-byte identity seed (NVS)
static uint8_t mcPrv[64]; // orlp expanded private key, derived from seed
static uint8_t mcPub[32]; // Ed25519 public key = MeshCore identity
static uint32_t lastMcAdvertMs = 0;
static const uint32_t MC_ADVERT_INTERVAL_MS = 30000;
@@ -333,13 +338,15 @@ static uint32_t mcNow() { return MC_EPOCH_BASE + millis() / 1000; }
static void initMeshCoreIdentity() {
Preferences prefs;
prefs.begin("meshcore", false);
if (prefs.getBytes("prv", mcPrv, 32) != 32) {
Ed25519::generatePrivateKey(mcPrv); // uses the Crypto lib CSPRNG
prefs.putBytes("prv", mcPrv, 32);
// NVS key "prv" holds the 32-byte seed (unchanged from the first identity so
// peers that already added us keep recognising the same public key).
if (prefs.getBytes("prv", mcSeed, 32) != 32) {
for (int i = 0; i < 32; i++) mcSeed[i] = (uint8_t)esp_random(); // HW CSPRNG
prefs.putBytes("prv", mcSeed, 32);
Serial.println("MeshCore: generated new Ed25519 identity");
}
prefs.end();
Ed25519::derivePublicKey(mcPub, mcPrv);
ed25519_create_keypair(mcPub, mcPrv, mcSeed); // seed -> (pub, expanded prv)
Serial.print("MeshCore identity pub: ");
for (int i = 0; i < 32; i++) Serial.printf("%02x", mcPub[i]);
Serial.println();
@@ -358,7 +365,7 @@ static void sendMeshCoreAdvert() {
memcpy(message + ml, app_data, ad); ml += ad;
uint8_t sig[64];
Ed25519::sign(sig, mcPrv, mcPub, message, ml);
ed25519_sign(sig, message, ml, mcPub, mcPrv);
uint8_t pkt[2 + 32 + 4 + 64 + 40]; size_t p = 0;
pkt[p++] = 0x12; // route=flood(2) | type=advert(4)
@@ -377,6 +384,108 @@ static void sendMeshCoreAdvert() {
radio.startReceive();
}
// --- MeshCore contacts (heard adverts) + DM receive ------------------------
// To decrypt a direct message we need the SENDER's full public key. MeshCore
// packets only carry a 1-byte src_hash (pubkey prefix), so we remember pubkeys
// from the adverts we hear and match on that hash.
struct McContact { uint8_t pub[32]; char name[24]; bool used; };
static const int MC_MAX_CONTACTS = 8;
static McContact mcContacts[MC_MAX_CONTACTS];
static int mcContactRR = 0; // round-robin eviction cursor
static void mcStoreContact(const uint8_t* pub, const char* name) {
for (int i = 0; i < MC_MAX_CONTACTS; i++) // update existing
if (mcContacts[i].used && memcmp(mcContacts[i].pub, pub, 32) == 0) {
strncpy(mcContacts[i].name, name, sizeof(mcContacts[i].name) - 1);
return;
}
for (int i = 0; i < MC_MAX_CONTACTS; i++) // fill a free slot
if (!mcContacts[i].used) {
mcContacts[i].used = true; memcpy(mcContacts[i].pub, pub, 32);
strncpy(mcContacts[i].name, name, sizeof(mcContacts[i].name) - 1);
return;
}
int i = mcContactRR; mcContactRR = (mcContactRR + 1) % MC_MAX_CONTACTS; // evict
memcpy(mcContacts[i].pub, pub, 32);
strncpy(mcContacts[i].name, name, sizeof(mcContacts[i].name) - 1);
mcContacts[i].name[sizeof(mcContacts[i].name) - 1] = 0;
}
// MeshCore Utils::MACThenDecrypt — verify a 2-byte truncated HMAC-SHA256 over
// the ciphertext (key = 32-byte shared secret), then AES-128-ECB decrypt (key =
// first 16 bytes). Returns plaintext length (multiple of 16) or 0 on bad MAC.
static int mcMACThenDecrypt(const uint8_t* secret, uint8_t* dest,
const uint8_t* src, int src_len) {
if (src_len <= 2 || (src_len - 2) % 16 != 0) return 0;
uint8_t hmac[2];
SHA256 sha;
sha.resetHMAC(secret, 32);
sha.update(src + 2, src_len - 2);
sha.finalizeHMAC(secret, 32, hmac, 2);
if (memcmp(hmac, src, 2) != 0) return 0; // MAC mismatch
AES128 aes; aes.setKey(secret, 16);
int n = src_len - 2; const uint8_t* sp = src + 2; uint8_t* dp = dest;
for (int o = 0; o < n; o += 16) { aes.decryptBlock(dp, sp); dp += 16; sp += 16; }
return n;
}
// Parse a received MeshCore packet: store adverts, decrypt DMs addressed to us.
static void handleMeshCorePacket(const uint8_t* buf, size_t len) {
if (len < 2) return;
uint8_t header = buf[0];
uint8_t route = header & 0x03;
uint8_t ptype = (header >> 2) & 0x0F;
size_t i = 1;
if (route == 0x00 || route == 0x03) i += 4; // transport codes
if (i >= len) return;
uint8_t plb = buf[i++];
uint8_t hsize = (plb >> 6) + 1, hcount = plb & 0x3F;
i += (size_t)hsize * hcount; // skip path hashes
if (i >= len) return;
const uint8_t* payload = buf + i;
int payload_len = (int)len - (int)i;
if (ptype == 0x04) { // ADVERT
if (payload_len < 32 + 4 + 64 + 1) return;
char name[24] = {0};
const uint8_t* app = payload + 32 + 4 + 64;
int app_len = payload_len - (32 + 4 + 64);
if (app_len > 1) { int nl = app_len - 1; if (nl > 23) nl = 23; memcpy(name, app + 1, nl); }
mcStoreContact(payload, name);
strncpy(lastMsg, name[0] ? name : "advert", sizeof(lastMsg) - 1);
lastMsg[sizeof(lastMsg) - 1] = 0; lastMsgNet = 'C'; lastMsgMs = millis();
Serial.printf(" decoded[C](advert): %s\n", name);
return;
}
if (ptype == 0x02) { // TXT_MSG (direct message)
if (payload_len < 2 + 2 + 16) return;
uint8_t dest_hash = payload[0], src_hash = payload[1];
if (dest_hash != mcPub[0]) return; // not addressed to us
const uint8_t* macData = payload + 2;
int macLen = payload_len - 2;
for (int c = 0; c < MC_MAX_CONTACTS; c++) {
if (!mcContacts[c].used || mcContacts[c].pub[0] != src_hash) continue;
uint8_t secret[32];
ed25519_key_exchange(secret, mcContacts[c].pub, mcPrv); // ECDH
uint8_t plain[192];
int n = mcMACThenDecrypt(secret, plain, macData, macLen);
if (n > 5) {
plain[n < (int)sizeof(plain) ? n : (int)sizeof(plain) - 1] = 0;
uint8_t txt_type = plain[4] & 0x03;
const char* text = (const char*)&plain[5];
if (txt_type == 0) { // TXT_TYPE_PLAIN
strncpy(lastMsg, text, sizeof(lastMsg) - 1); lastMsg[sizeof(lastMsg) - 1] = 0;
lastMsgNet = 'C'; lastMsgMs = millis();
Serial.printf(" decoded[C](dm from %s): %s\n", mcContacts[c].name, text);
triggerMessageModal("meshCore", mcContacts[c].name, text);
}
return;
}
}
Serial.printf(" [C] DM for us but no matching contact (src_hash=%02x)\n", src_hash);
}
}
static void drainPacket() {
size_t len = radio.getPacketLength();
uint8_t buf[256];
@@ -407,6 +516,11 @@ static void drainPacket() {
}
}
}
// MeshCore: store heard adverts, decrypt direct messages addressed to us.
if (CONFIGS[activeIdx].letter == 'C') {
handleMeshCorePacket(buf, len);
}
}
// --- The trippy acid display ------------------------------------------------