From 8939b9524f0c5f405d8a4363e880bdaaaab7b0c6 Mon Sep 17 00:00:00 2001 From: Dorian Date: Wed, 1 Jul 2026 18:40:03 +0100 Subject: [PATCH] =?UTF-8?q?feat(firmware):=20MeshCore=20text=20TX=20?= =?UTF-8?q?=E2=80=94=20send=20encrypted=20DMs=20to=20contacts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - sendMeshCoreText: plaintext (timestamp + flags + text) -> encryptThenMAC (AES-128-ECB zero-padded + 2-byte HMAC-SHA256 prefix), wrapped as [header route=flood|type=TXT_MSG][path_len 0][dest_hash][src_hash][cipher] - serial "C:msg" now DMs the text to every heard MeshCore contact - verified end-to-end: bridge DM decrypts to plaintext on a real node Completes bidirectional MeshCore messaging (advert TX + DM RX + DM TX). Co-Authored-By: Claude Opus 4.8 (1M context) --- firmware/src/main.cpp | 50 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 49 insertions(+), 1 deletion(-) diff --git a/firmware/src/main.cpp b/firmware/src/main.cpp index ccc78cc..d32e64f 100644 --- a/firmware/src/main.cpp +++ b/firmware/src/main.cpp @@ -384,6 +384,48 @@ static void sendMeshCoreAdvert() { radio.startReceive(); } +// Send an encrypted MeshCore direct message to a contact (Ed25519 pubkey). +// Mirror of the RX path: plaintext = timestamp(4) + flags(1) + text, then +// encryptThenMAC = HMAC-SHA256(2) prefix + AES-128-ECB(zero-padded plaintext), +// wrapped as [header][path_len 0][dest_hash][src_hash][cipher]. +static void sendMeshCoreText(const uint8_t* dest_pub, const char* text) { + uint32_t ts = mcNow(); + uint8_t plain[176]; size_t pl = 0; + memcpy(plain + pl, &ts, 4); pl += 4; + plain[pl++] = 0x00; // flags: attempt 0, TXT_TYPE_PLAIN + size_t tl = strlen(text); if (tl > 140) tl = 140; + memcpy(plain + pl, text, tl); pl += tl; + + uint8_t secret[32]; + ed25519_key_exchange(secret, dest_pub, mcPrv); // ECDH shared secret + + // AES-128-ECB over the plaintext, zero-padded to a 16-byte boundary. + uint8_t cipher[2 + 176 + 16]; uint8_t* enc = cipher + 2; size_t el = 0; + AES128 aes; aes.setKey(secret, 16); + size_t off = 0; + while (pl - off >= 16) { aes.encryptBlock(enc + el, plain + off); el += 16; off += 16; } + if (pl - off > 0) { + uint8_t tmp[16]; memset(tmp, 0, 16); memcpy(tmp, plain + off, pl - off); + aes.encryptBlock(enc + el, tmp); el += 16; + } + // 2-byte truncated HMAC-SHA256 over the ciphertext (key = 32-byte secret). + SHA256 sha; sha.resetHMAC(secret, 32); sha.update(enc, el); + sha.finalizeHMAC(secret, 32, cipher, 2); + size_t clen = 2 + el; + + uint8_t pkt[4 + sizeof(cipher)]; size_t p = 0; + pkt[p++] = 0x0A; // route=flood(2) | type=TXT_MSG(2) + pkt[p++] = 0x00; // path_len = 0 + pkt[p++] = dest_pub[0]; // dest_hash + pkt[p++] = mcPub[0]; // src_hash + memcpy(pkt + p, cipher, clen); p += clen; + + radio.standby(); + int st = radio.transmit(pkt, p); + Serial.printf(" [C] TX dm -> tx=%d (%u bytes) : \"%s\"\n", st, (unsigned)p, text); + 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 @@ -818,8 +860,14 @@ static void flushTxOnWindow() { if (L == 'T') { sendMeshtasticText(txText); + } else if (L == 'C') { + // DM the text to every MeshCore contact we've heard an advert from. + int sent = 0; + for (int c = 0; c < MC_MAX_CONTACTS; c++) + if (mcContacts[c].used) { sendMeshCoreText(mcContacts[c].pub, txText); sent++; } + if (sent == 0) Serial.printf(" [C] no known contacts yet — nobody to DM\n"); } else { - // MeshCore / Reticulum TX framing not built yet — acknowledge, don't fake it. + // Reticulum TX not built yet — acknowledge, don't fake it. Serial.printf(" [%c] TX not implemented yet (msg: \"%s\")\n", L, txText); }