Add LXMF message receive: decrypt, parse, display on Messages screen

Implements OPPORTUNISTIC LXMF delivery receive — single-packet
encrypted messages without link establishment.

BeaconCrypto.h: beacon_crypto_decrypt() — reverse of encrypt.
  ECDH + HKDF + HMAC verify + AES-256-CBC decrypt + PKCS7 unpad.

LxmfBeacon.h: MsgpackReader for unpacking received messages.
  lxmf_parse_received() extracts source_hash, timestamp, title, content.
  lxmf_verify_signature() verifies Ed25519 against cached announce key.

MessageLog.h: Extended to detect DATA packets addressed to our
  lxmf_source_hash, decrypt, parse, and store content. Announce
  entries now cache sender's ed25519 public key for verification.

Gui.h: Messages screen shows LXMF content (green if verified,
  white if unverified) with sender name and time ago.
This commit is contained in:
GlassOnTin 2026-04-07 21:34:49 +01:00
commit 57b1bb7f7e
4 changed files with 323 additions and 20 deletions

View file

@ -155,5 +155,82 @@ static int beacon_crypto_encrypt(const uint8_t *plaintext, size_t pt_len,
return 32 + 16 + (int)padded_len + 32;
}
// Decrypt incoming RNS Token-format payload.
//
// Input layout: [ephemeral_pub:32][IV:16][ciphertext:var][HMAC:32]
// Returns plaintext length, or -1 on error (HMAC fail, decrypt fail, etc.)
//
// Crypto pipeline (reverse of beacon_crypto_encrypt):
// 1. Extract ephemeral public key
// 2. ECDH shared secret with OUR private key
// 3. HKDF-SHA256 → signing_key(32) + encryption_key(32)
// 4. Verify HMAC-SHA256(signing_key, IV || ciphertext)
// 5. AES-256-CBC decrypt
// 6. PKCS7 unpad
static int beacon_crypto_decrypt(const uint8_t *input, size_t input_len,
const uint8_t *our_x25519_sk,
const uint8_t *peer_identity_hash,
uint8_t *output, size_t output_cap) {
// Minimum: ephemeral(32) + IV(16) + 16-byte block + HMAC(32) = 96
if (input_len < 96) return -1;
const uint8_t *eph_pub = input;
const uint8_t *iv_pos = input + 32;
size_t ct_len = input_len - 32 - 16 - 32; // remove eph + IV + HMAC
const uint8_t *ct_pos = input + 32 + 16;
const uint8_t *hmac_in = input + input_len - 32;
if (ct_len == 0 || ct_len % 16 != 0) return -1;
if (ct_len > output_cap) return -1;
// 1. ECDH shared secret
uint8_t ss_bytes[32];
if (crypto_scalarmult_curve25519(ss_bytes, our_x25519_sk, eph_pub) != 0) return -1;
// 2. HKDF-SHA256: derive signing_key(32) + encryption_key(32)
uint8_t derived[64];
int ret = rns_hkdf(ss_bytes, 32, peer_identity_hash, 16, derived);
if (ret != 0) return -1;
uint8_t *signing_key = derived;
uint8_t *encryption_key = derived + 32;
// 3. Verify HMAC-SHA256(signing_key, IV || ciphertext)
uint8_t computed_hmac[32];
const mbedtls_md_info_t *md_info = mbedtls_md_info_from_type(MBEDTLS_MD_SHA256);
mbedtls_md_context_t md_ctx;
mbedtls_md_init(&md_ctx);
ret = mbedtls_md_setup(&md_ctx, md_info, 1);
if (ret == 0) ret = mbedtls_md_hmac_starts(&md_ctx, signing_key, 32);
if (ret == 0) ret = mbedtls_md_hmac_update(&md_ctx, iv_pos, 16);
if (ret == 0) ret = mbedtls_md_hmac_update(&md_ctx, ct_pos, ct_len);
if (ret == 0) ret = mbedtls_md_hmac_finish(&md_ctx, computed_hmac);
mbedtls_md_free(&md_ctx);
if (ret != 0) return -1;
if (memcmp(computed_hmac, hmac_in, 32) != 0) return -2; // HMAC mismatch
// 4. AES-256-CBC decrypt
uint8_t iv_copy[16];
memcpy(iv_copy, iv_pos, 16);
mbedtls_aes_context aes;
mbedtls_aes_init(&aes);
ret = mbedtls_aes_setkey_dec(&aes, encryption_key, 256);
if (ret != 0) { mbedtls_aes_free(&aes); return -1; }
ret = mbedtls_aes_crypt_cbc(&aes, MBEDTLS_AES_DECRYPT, ct_len,
iv_copy, ct_pos, output);
mbedtls_aes_free(&aes);
if (ret != 0) return -1;
// 5. PKCS7 unpad
uint8_t pad_val = output[ct_len - 1];
if (pad_val == 0 || pad_val > 16) return -3; // invalid padding
for (size_t i = 0; i < pad_val; i++) {
if (output[ct_len - 1 - i] != pad_val) return -3;
}
return (int)(ct_len - pad_val);
}
#endif
#endif

34
Gui.h
View file

@ -709,8 +709,13 @@ struct msg_entry_t {
uint8_t pkt_type;
uint8_t sender_hash[16];
char display_name[MSG_NAME_LEN];
char content[64]; // LXMF message content (first 63 chars)
uint8_t sender_ed25519_pub[32]; // cached from announce for signature verification
uint16_t pkt_len;
bool is_announce;
bool is_lxmf; // true if decrypted LXMF message
bool verified; // signature verified
bool has_pubkey; // sender_ed25519_pub is valid
};
extern msg_entry_t msg_log[];
extern uint8_t msg_log_head;
@ -1473,10 +1478,17 @@ static void gui_update_data() {
lv_obj_clear_flag(gui_msg_row[shown], LV_OBJ_FLAG_HIDDEN);
// Name — amber for announces, white for data
lv_label_set_text(gui_msg_name[shown], m.display_name);
lv_obj_set_style_text_color(gui_msg_name[shown],
lv_color_hex(m.is_announce ? GUI_COL_AMBER : GUI_COL_WHITE), 0);
// Name — green for verified LXMF, amber for announces, white for data
if (m.is_lxmf && m.content[0]) {
// LXMF message: show content as primary text
lv_label_set_text(gui_msg_name[shown], m.content);
lv_obj_set_style_text_color(gui_msg_name[shown],
lv_color_hex(m.verified ? GUI_COL_GREEN : GUI_COL_WHITE), 0);
} else {
lv_label_set_text(gui_msg_name[shown], m.display_name);
lv_obj_set_style_text_color(gui_msg_name[shown],
lv_color_hex(m.is_announce ? GUI_COL_AMBER : GUI_COL_WHITE), 0);
}
// RSSI with colour coding
lv_label_set_text_fmt(gui_msg_rssi[shown], "%d dBm", m.rssi);
@ -1484,11 +1496,17 @@ static void gui_update_data() {
(m.rssi > -100) ? GUI_COL_AMBER : GUI_COL_RED;
lv_obj_set_style_text_color(gui_msg_rssi[shown], lv_color_hex(rssi_col), 0);
// Time ago
// Time ago + sender name for LXMF messages
uint32_t ago = (millis() - m.timestamp) / 1000;
if (ago < 60) lv_label_set_text_fmt(gui_msg_time[shown], "%lus ago %dB", ago, m.pkt_len);
else if (ago < 3600) lv_label_set_text_fmt(gui_msg_time[shown], "%lum ago %dB", ago / 60, m.pkt_len);
else lv_label_set_text_fmt(gui_msg_time[shown], "%luh ago %dB", ago / 3600, m.pkt_len);
if (m.is_lxmf) {
if (ago < 60) lv_label_set_text_fmt(gui_msg_time[shown], "%s %lus ago", m.display_name, ago);
else if (ago < 3600) lv_label_set_text_fmt(gui_msg_time[shown], "%s %lum ago", m.display_name, ago / 60);
else lv_label_set_text_fmt(gui_msg_time[shown], "%s %luh ago", m.display_name, ago / 3600);
} else {
if (ago < 60) lv_label_set_text_fmt(gui_msg_time[shown], "%lus ago %dB", ago, m.pkt_len);
else if (ago < 3600) lv_label_set_text_fmt(gui_msg_time[shown], "%lum ago %dB", ago / 60, m.pkt_len);
else lv_label_set_text_fmt(gui_msg_time[shown], "%luh ago %dB", ago / 3600, m.pkt_len);
}
shown++;
}

View file

@ -446,6 +446,155 @@ static int lxmf_build_message(uint8_t *out, size_t out_cap,
return (int)total;
}
// ---- Minimal Msgpack Reader ----
// Read-only cursor over msgpack data. No allocation.
struct MsgpackReader {
const uint8_t *buf;
size_t pos;
size_t len;
MsgpackReader(const uint8_t *data, size_t sz) : buf(data), pos(0), len(sz) {}
bool has(size_t n) const { return pos + n <= len; }
uint8_t peek() const { return has(1) ? buf[pos] : 0xFF; }
uint8_t read_u8() { return has(1) ? buf[pos++] : 0; }
// Read fixarray length (0x90-0x9F)
int read_array_len() {
uint8_t b = read_u8();
if ((b & 0xF0) == 0x90) return b & 0x0F;
if (b == 0xDC && has(2)) { uint16_t n = (buf[pos]<<8)|buf[pos+1]; pos+=2; return n; }
return -1;
}
// Read uint (fixint, uint8, uint16, uint32)
uint32_t read_uint() {
uint8_t b = read_u8();
if (b <= 0x7F) return b;
if (b == 0xCC && has(1)) return buf[pos++];
if (b == 0xCD && has(2)) { uint16_t v=(buf[pos]<<8)|buf[pos+1]; pos+=2; return v; }
if (b == 0xCE && has(4)) { uint32_t v=(buf[pos]<<24)|(buf[pos+1]<<16)|(buf[pos+2]<<8)|buf[pos+3]; pos+=4; return v; }
return 0;
}
// Read binary/string, returns pointer and sets length. Doesn't copy.
const uint8_t *read_bin(size_t *out_len) {
uint8_t b = read_u8();
size_t n = 0;
if ((b & 0xE0) == 0xA0) n = b & 0x1F; // fixstr
else if (b == 0xC4 && has(1)) n = buf[pos++]; // bin8
else if (b == 0xC5 && has(2)) { n=(buf[pos]<<8)|buf[pos+1]; pos+=2; } // bin16
else if (b == 0xD9 && has(1)) n = buf[pos++]; // str8
else { *out_len = 0; return NULL; }
if (!has(n)) { *out_len = 0; return NULL; }
const uint8_t *ptr = buf + pos;
pos += n;
*out_len = n;
return ptr;
}
// Skip one msgpack value
void skip() {
uint8_t b = peek();
if (b <= 0x7F || b >= 0xE0 || b == 0xC0 || b == 0xC2 || b == 0xC3) { pos++; return; }
if ((b & 0xF0) == 0x90) { int n = read_array_len(); for (int i=0;i<n;i++) skip(); return; }
if ((b & 0xF0) == 0x80) { pos++; int n = b & 0x0F; for (int i=0;i<n*2;i++) skip(); return; }
size_t dummy; read_bin(&dummy); // handles str/bin
}
// Read fixmap length
int read_map_len() {
uint8_t b = read_u8();
if ((b & 0xF0) == 0x80) return b & 0x0F;
if (b == 0xDE && has(2)) { uint16_t n=(buf[pos]<<8)|buf[pos+1]; pos+=2; return n; }
return -1;
}
};
// ---- LXMF Message RX Parser ----
// Parse decrypted LXMF plaintext: source_hash(16) + signature(64) + msgpack
// Returns true if parsed successfully. Fills title_out and content_out.
struct LxmfParsed {
uint8_t source_hash[16];
uint32_t timestamp;
char title[32];
char content[64];
bool verified; // signature verified against cached announce key
};
static bool lxmf_parse_received(const uint8_t *plaintext, size_t pt_len,
LxmfParsed *out) {
if (pt_len < 16 + 64 + 4) return false; // minimum: hash + sig + tiny msgpack
memcpy(out->source_hash, plaintext, 16);
// signature at plaintext+16, 64 bytes (verified later with sender's pubkey)
const uint8_t *msgpack_data = plaintext + 16 + 64;
size_t msgpack_len = pt_len - 16 - 64;
MsgpackReader r(msgpack_data, msgpack_len);
// Expect fixarray(4): [timestamp, title, content, fields]
int arr_len = r.read_array_len();
if (arr_len < 3) return false;
// [0] timestamp
out->timestamp = r.read_uint();
// [1] title
size_t title_len = 0;
const uint8_t *title_ptr = r.read_bin(&title_len);
if (title_ptr && title_len > 0) {
size_t n = title_len < sizeof(out->title)-1 ? title_len : sizeof(out->title)-1;
memcpy(out->title, title_ptr, n);
out->title[n] = '\0';
} else {
out->title[0] = '\0';
}
// [2] content
size_t content_len = 0;
const uint8_t *content_ptr = r.read_bin(&content_len);
if (content_ptr && content_len > 0) {
size_t n = content_len < sizeof(out->content)-1 ? content_len : sizeof(out->content)-1;
memcpy(out->content, content_ptr, n);
out->content[n] = '\0';
} else {
out->content[0] = '\0';
}
out->verified = false; // caller must verify with sender's pubkey
return true;
}
// Verify LXMF signature using sender's Ed25519 public key
static bool lxmf_verify_signature(const uint8_t *plaintext, size_t pt_len,
const uint8_t *sender_ed25519_pub) {
if (pt_len < 16 + 64 + 4) return false;
const uint8_t *signature = plaintext + 16;
const uint8_t *msgpack_data = plaintext + 16 + 64;
size_t msgpack_len = pt_len - 16 - 64;
// Reconstruct signed_part: dest_hash + source_hash + msgpack + SHA256(same)
// dest_hash = our lxmf_source_hash, source_hash = plaintext[0:16]
static uint8_t hashed_part[512];
size_t hp_len = 16 + 16 + msgpack_len;
if (hp_len > sizeof(hashed_part) - 32) return false;
memcpy(hashed_part, lxmf_source_hash, 16); // dest = us
memcpy(hashed_part + 16, plaintext, 16); // source
memcpy(hashed_part + 32, msgpack_data, msgpack_len);
uint8_t message_hash[32];
sha256_once(hashed_part, hp_len, message_hash);
static uint8_t signed_part[512 + 32];
memcpy(signed_part, hashed_part, hp_len);
memcpy(signed_part + hp_len, message_hash, 32);
size_t sp_len = hp_len + 32;
return crypto_sign_ed25519_verify_detached(signature, signed_part, sp_len,
sender_ed25519_pub) == 0;
}
// ---- RNS Announce Packet Construction ----
// Builds a complete RNS announce packet in tbuf for transmission.
//

View file

@ -115,20 +115,11 @@ static void msg_log_parse_packet(const uint8_t *pkt, uint16_t len,
const uint8_t *dest_hash = pkt + 2; // bytes 2-17
if (pkt_type == RNS_TYPE_ANNOUNCE && len > 167) {
// ANNOUNCE packet structure (HEADER_1, 19-byte header):
// [flags(1) hops(1) dest_hash(16) context(1)]
// [pub_keys(64) = x25519(32) + ed25519(32)]
// [name_hash(10)]
// [random_hash(10)]
// [signature(64)]
// [app_data(variable) = display name]
// Compute identity hash from public keys
const uint8_t *pub_keys = pkt + 19; // 64 bytes
// ANNOUNCE: extract identity, display name, and cache public keys
const uint8_t *pub_keys = pkt + 19; // x25519(32) + ed25519(32)
uint8_t identity_hash[32];
mbedtls_sha256(pub_keys, 64, identity_hash, 0);
// Extract display name from app_data
char name[MSG_NAME_LEN] = {0};
int app_data_offset = 19 + 64 + 10 + 10 + 64; // = 167
int app_data_len = len - app_data_offset;
@ -138,8 +129,76 @@ static void msg_log_parse_packet(const uint8_t *pkt, uint16_t len,
}
msg_log_add(identity_hash, name, rssi, snr, pkt_type, len, true);
// Cache sender's ed25519 public key for signature verification
int idx = msg_log_find_sender(identity_hash);
if (idx >= 0) {
memcpy(msg_log[idx].sender_ed25519_pub, pub_keys + 32, 32);
msg_log[idx].has_pubkey = true;
}
} else if (pkt_type == RNS_TYPE_DATA && len > 19 + 96) {
// DATA packet — check if addressed to us (LXMF delivery)
extern uint8_t lxmf_source_hash[16];
extern uint8_t lxmf_x25519_sk[32];
extern bool lxmf_identity_configured;
if (lxmf_identity_configured && memcmp(dest_hash, lxmf_source_hash, 16) == 0) {
// Addressed to our LXMF delivery destination — try to decrypt
const uint8_t *encrypted = pkt + 19; // after RNS header
size_t enc_len = len - 19;
// We need the sender's identity hash for HKDF salt.
// For OPPORTUNISTIC messages, we use our OWN identity hash as salt
// (the sender encrypted TO us, using our identity hash as salt)
extern uint8_t lxmf_identity_hash[16];
static uint8_t plaintext[512];
int pt_len = beacon_crypto_decrypt(encrypted, enc_len,
lxmf_x25519_sk,
lxmf_identity_hash,
plaintext, sizeof(plaintext));
if (pt_len > 0) {
// Decryption succeeded — parse LXMF message
LxmfParsed parsed;
if (lxmf_parse_received(plaintext, pt_len, &parsed)) {
// Look up sender's name and pubkey from announce cache
char *sender_name = NULL;
uint8_t *sender_pub = NULL;
int sender_idx = msg_log_find_sender(parsed.source_hash);
if (sender_idx >= 0) {
sender_name = msg_log[sender_idx].display_name;
if (msg_log[sender_idx].has_pubkey)
sender_pub = msg_log[sender_idx].sender_ed25519_pub;
}
// Add as LXMF message entry
msg_log_add(parsed.source_hash, sender_name, rssi, snr,
pkt_type, len, false);
// Store content in the new entry
int new_idx = msg_log_find_sender(parsed.source_hash);
if (new_idx >= 0) {
strncpy(msg_log[new_idx].content, parsed.content, 63);
msg_log[new_idx].content[63] = '\0';
msg_log[new_idx].is_lxmf = true;
if (sender_pub) {
msg_log[new_idx].verified =
lxmf_verify_signature(plaintext, pt_len, sender_pub);
}
}
Serial.printf("[lxmf_rx] from %02x%02x: \"%s\"\n",
parsed.source_hash[0], parsed.source_hash[1], parsed.content);
}
}
} else {
// DATA not for us — log generically
msg_log_add(dest_hash, NULL, rssi, snr, pkt_type, len, false);
}
} else {
// DATA or short packet — log with dest_hash
// Other packet — log with dest_hash
msg_log_add(dest_hash, NULL, rssi, snr, pkt_type, len, false);
}
}