feat(firmware): Reticulum announce RX — decode + verify heard announces

- handleReticulumPacket: parse RNS packet header (HEADER_1/2, flags), extract
  ANNOUNCE payload, reconstruct signed_data, verify Ed25519 signature with the
  announced signing key, and confirm dest_hash = SHA256(name_hash ||
  SHA256(pubkey)[:16])[:16]; register the destination (pubkey/app_data table)
- "RXR:<hex>" serial hook injects raw packets for radio-free validation
- verified against real RNS-generated announces: valid one decodes with correct
  dest + app_data; tampered signature is rejected

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dorian 2026-07-01 19:22:42 +01:00
parent 422cb9b8da
commit a62d989c9e

View File

@ -514,6 +514,84 @@ static void sendReticulumAnnounce() {
radio.startReceive();
}
// Known Reticulum destinations we've heard announces from.
struct RnsDest { uint8_t hash[16]; uint8_t pub[64]; char app[24]; bool used; };
static const int RNS_MAX_DESTS = 6;
static RnsDest rnsDests[RNS_MAX_DESTS];
static int rnsDestRR = 0;
static void rnsStoreDest(const uint8_t* hash, const uint8_t* pub, const char* app) {
for (int i = 0; i < RNS_MAX_DESTS; i++)
if (rnsDests[i].used && memcmp(rnsDests[i].hash, hash, 16) == 0) return;
int slot = -1;
for (int i = 0; i < RNS_MAX_DESTS; i++) if (!rnsDests[i].used) { slot = i; break; }
if (slot < 0) { slot = rnsDestRR; rnsDestRR = (rnsDestRR + 1) % RNS_MAX_DESTS; }
rnsDests[slot].used = true;
memcpy(rnsDests[slot].hash, hash, 16); memcpy(rnsDests[slot].pub, pub, 64);
strncpy(rnsDests[slot].app, app, sizeof(rnsDests[slot].app) - 1);
rnsDests[slot].app[sizeof(rnsDests[slot].app) - 1] = 0;
}
// Parse a received Reticulum packet: decode + verify ANNOUNCE, register dest.
static void handleReticulumPacket(const uint8_t* buf, size_t len) {
if (len < 19) return;
uint8_t flags = buf[0];
uint8_t header_type = (flags >> 6) & 0x01;
uint8_t context_flag = (flags >> 5) & 0x01;
uint8_t ptype = flags & 0x03; // packet_type (2 bits)
size_t i = 2; // skip flags + hops
if (header_type == 0x01) i += 16; // HEADER_2 carries a transport_id
if (i + 16 + 1 > len) return;
const uint8_t* dest_hash = buf + i; i += 16;
i += 1; // context byte
const uint8_t* data = buf + i;
int data_len = (int)len - (int)i;
if (ptype != 0x01) return; // only ANNOUNCE for now
int ratch = context_flag ? 32 : 0;
if (data_len < 64 + 10 + 10 + ratch + 64) return;
const uint8_t* pub = data;
const uint8_t* name_hash = data + 64;
const uint8_t* random_hash = data + 74;
const uint8_t* ratchet = data + 84;
const uint8_t* signature = data + 84 + ratch;
const uint8_t* app_data = data + 84 + ratch + 64;
int app_len = data_len - (84 + ratch + 64);
// signed_data = dest_hash || pub || name_hash || random_hash || ratchet || app_data
uint8_t sd[16 + 64 + 10 + 10 + 32 + 64]; size_t sl = 0;
memcpy(sd + sl, dest_hash, 16); sl += 16;
memcpy(sd + sl, pub, 64); sl += 64;
memcpy(sd + sl, name_hash, 10); sl += 10;
memcpy(sd + sl, random_hash, 10); sl += 10;
if (ratch) { memcpy(sd + sl, ratchet, 32); sl += 32; }
int app_take = app_len; if (app_take > 64) app_take = 64;
if (app_take > 0) { memcpy(sd + sl, app_data, app_take); sl += app_take; }
// Verify the Ed25519 signature with the announced signing key (pub[32:64]).
if (!ed25519_verify(signature, sd, sl, pub + 32)) {
Serial.println(" [R] announce with bad signature — dropped");
return;
}
// Confirm dest hash = SHA256(name_hash || SHA256(pub)[:16])[:16].
uint8_t ih[16]; rnsSha256(pub, 64, ih, 16);
uint8_t nh_ih[26]; memcpy(nh_ih, name_hash, 10); memcpy(nh_ih + 10, ih, 16);
uint8_t expect[16]; rnsSha256(nh_ih, 26, expect, 16);
if (memcmp(expect, dest_hash, 16) != 0) {
Serial.println(" [R] announce dest-hash mismatch — dropped");
return;
}
char app[24] = {0};
int an = app_take < 23 ? app_take : 23; if (an > 0) memcpy(app, app_data, an);
rnsStoreDest(dest_hash, pub, app);
strncpy(lastMsg, app[0] ? app : "announce", sizeof(lastMsg) - 1);
lastMsg[sizeof(lastMsg) - 1] = 0; lastMsgNet = 'R'; lastMsgMs = millis();
Serial.printf(" decoded[R](announce): dest=");
for (int k = 0; k < 8; k++) Serial.printf("%02x", dest_hash[k]);
Serial.printf(" app_data=\"%s\"\n", app);
}
// --- 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
@ -651,6 +729,11 @@ static void drainPacket() {
if (CONFIGS[activeIdx].letter == 'C') {
handleMeshCorePacket(buf, len);
}
// Reticulum: decode + verify announces we hear.
if (CONFIGS[activeIdx].letter == 'R') {
handleReticulumPacket(buf, len);
}
}
// --- The trippy acid display ------------------------------------------------
@ -916,12 +999,29 @@ void setup() {
// "hello" -> send "hello" on all networks
// "T:hello" -> send only on Meshtastic (T / C / R prefix, case-insensitive)
static void pollSerialTx() {
static char buf[210]; static size_t n = 0;
static char buf[560]; static size_t n = 0; // large enough for RXR: hex inject
while (Serial.available()) {
char c = Serial.read();
if (c == '\r') continue;
if (c == '\n') {
buf[n] = 0;
// Test hook: "RXR:<hex>" injects raw bytes into the Reticulum RX path so
// the announce parser can be validated against RNS vectors without a radio.
if (n > 4 && strncmp(buf, "RXR:", 4) == 0) {
static uint8_t inj[256]; size_t il = 0;
for (size_t k = 4; buf[k] && buf[k + 1] && il < sizeof(inj); k += 2) {
auto hx = [](char ch) -> int {
if (ch >= '0' && ch <= '9') return ch - '0';
if (ch >= 'a' && ch <= 'f') return ch - 'a' + 10;
if (ch >= 'A' && ch <= 'F') return ch - 'A' + 10;
return 0; };
inj[il++] = (uint8_t)((hx(buf[k]) << 4) | hx(buf[k + 1]));
}
Serial.printf(" [inject] RNS packet %u bytes\n", (unsigned)il);
handleReticulumPacket(inj, il);
n = 0;
continue;
}
if (n > 0) {
const char* msg = buf;
strcpy(txNets, "TCR"); // default: all networks