feat(firmware): MeshCore text TX — send encrypted DMs to contacts

- 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) <noreply@anthropic.com>
This commit is contained in:
Dorian 2026-07-01 18:40:03 +01:00
parent 38c146c477
commit 8939b9524f

View File

@ -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);
}