From 26bdb41f16a358ed27bc4248b9dde44c10355cd2 Mon Sep 17 00:00:00 2001 From: Wessel Nieboer Date: Thu, 12 Feb 2026 01:01:56 +0100 Subject: [PATCH 01/11] Add ChaChaPoly AEAD-4 decryption support (Phase 1) Add ChaCha20-Poly1305 AEAD decryption with 4-byte auth tag for peer messages and group channels, falling back to ECB for backward compatibility. Sending remains ECB-only in this phase. - Per-message key derivation: HMAC-SHA256(secret, nonce||dest||src) - Direction-dependent keys prevent bidirectional keystream reuse - 12-byte IV from nonce + dest_hash + src_hash - Advertise AEAD capability via feat1 bit 0 in adverts - Track peer AEAD support in ContactInfo.flags - Seed aead_nonce from HW RNG on contact creation and load --- src/Mesh.cpp | 39 ++++++++++-- src/MeshCore.h | 6 ++ src/Utils.cpp | 115 +++++++++++++++++++++++++++++++++++ src/Utils.h | 23 +++++++ src/helpers/BaseChatMesh.cpp | 12 ++++ src/helpers/CommonCLI.cpp | 3 + src/helpers/ContactInfo.h | 1 + 7 files changed, 193 insertions(+), 6 deletions(-) diff --git a/src/Mesh.cpp b/src/Mesh.cpp index 0548c9073..5be2eecbf 100644 --- a/src/Mesh.cpp +++ b/src/Mesh.cpp @@ -151,9 +151,19 @@ DispatcherAction Mesh::onRecvPacket(Packet* pkt) { uint8_t secret[PUB_KEY_SIZE]; getPeerSharedSecret(secret, j); - // decrypt, checking MAC is valid uint8_t data[MAX_PACKET_PAYLOAD]; - int len = Utils::MACThenDecrypt(secret, data, macAndData, pkt->payload_len - i); + int macAndDataLen = pkt->payload_len - i; + + // Try ECB first (Phase 1: all senders use ECB), then AEAD-4 fallback. + // IMPORTANT: Phase 2 MUST swap to AEAD-first. ECB-first has a 1/65536 + // false-positive rate on AEAD packets (nonce bytes matching truncated HMAC), + // producing garbage plaintext. AEAD-first has only 1/2^32 false-positive on + // ECB packets, which is negligible. + int len = Utils::MACThenDecrypt(secret, data, macAndData, macAndDataLen); + if (len <= 0) { + uint8_t assoc[3] = { pkt->header, dest_hash, src_hash }; + len = Utils::aeadDecrypt(secret, data, macAndData, macAndDataLen, assoc, 3, dest_hash, src_hash); + } if (len > 0) { // success! if (pkt->getPayloadType() == PAYLOAD_TYPE_PATH) { int k = 0; @@ -201,9 +211,16 @@ DispatcherAction Mesh::onRecvPacket(Packet* pkt) { uint8_t secret[PUB_KEY_SIZE]; self_id.calcSharedSecret(secret, sender); - // decrypt, checking MAC is valid uint8_t data[MAX_PACKET_PAYLOAD]; - int len = Utils::MACThenDecrypt(secret, data, macAndData, pkt->payload_len - i); + int macAndDataLen = pkt->payload_len - i; + + // Try ECB first (Phase 1), then AEAD-4 fallback. + // Phase 2 MUST swap to AEAD-first (see peer message comment above). + int len = Utils::MACThenDecrypt(secret, data, macAndData, macAndDataLen); + if (len <= 0) { + uint8_t assoc[2] = { pkt->header, dest_hash }; + len = Utils::aeadDecrypt(secret, data, macAndData, macAndDataLen, assoc, 2, dest_hash, 0); + } if (len > 0) { // success! onAnonDataRecv(pkt, secret, sender, data, len); pkt->markDoNotRetransmit(); @@ -227,9 +244,19 @@ DispatcherAction Mesh::onRecvPacket(Packet* pkt) { int num = searchChannelsByHash(&channel_hash, channels, 4); // for each matching channel, try to decrypt data for (int j = 0; j < num; j++) { - // decrypt, checking MAC is valid uint8_t data[MAX_PACKET_PAYLOAD]; - int len = Utils::MACThenDecrypt(channels[j].secret, data, macAndData, pkt->payload_len - i); + int macAndDataLen = pkt->payload_len - i; + + // Try ECB first (Phase 1), then AEAD-4 fallback. + // Phase 2 MUST swap to AEAD-first (see peer message comment above). + // Note: group channels share a key, so nonce collisions across senders can leak + // P1 XOR P2 for colliding message pairs (no key recovery). Bounded risk, mainly + // worthwhile for public/hashtag channels where the PSK is already widely known. + int len = Utils::MACThenDecrypt(channels[j].secret, data, macAndData, macAndDataLen); + if (len <= 0) { + uint8_t assoc[2] = { pkt->header, channel_hash }; + len = Utils::aeadDecrypt(channels[j].secret, data, macAndData, macAndDataLen, assoc, 2, channel_hash, 0); + } if (len > 0) { // success! onGroupDataRecv(pkt, pkt->getPayloadType(), channels[j], data, len); break; diff --git a/src/MeshCore.h b/src/MeshCore.h index f194cdeb4..75aa86ab2 100644 --- a/src/MeshCore.h +++ b/src/MeshCore.h @@ -16,6 +16,12 @@ #define CIPHER_MAC_SIZE 2 #define PATH_HASH_SIZE 1 +// AEAD-4 (ChaChaPoly) encryption +#define AEAD_TAG_SIZE 4 +#define AEAD_NONCE_SIZE 2 +#define CONTACT_FLAG_AEAD 0x02 // bit 1 of ContactInfo.flags (bit 0 = favourite) +#define FEAT1_AEAD_SUPPORT 0x0001 // bit 0 of feat1 uint16_t + #define MAX_PACKET_PAYLOAD 184 #define MAX_PATH_SIZE 64 #define MAX_TRANS_UNIT 255 diff --git a/src/Utils.cpp b/src/Utils.cpp index 186c8720a..a0c98b880 100644 --- a/src/Utils.cpp +++ b/src/Utils.cpp @@ -1,6 +1,7 @@ #include "Utils.h" #include #include +#include #ifdef ARDUINO #include @@ -87,6 +88,120 @@ int Utils::MACThenDecrypt(const uint8_t* shared_secret, uint8_t* dest, const uin return 0; // invalid HMAC } +/* + * AEAD-4: ChaCha20-Poly1305 authenticated encryption with 4-byte tag. + * + * Wire format (replaces ECB's [HMAC:2][ciphertext:N*16]): + * [nonce:2] [ciphertext:M] [tag:4] (M = exact plaintext length) + * + * Key derivation (per-message, eliminates nonce-reuse catastrophe): + * msg_key[32] = HMAC-SHA256(shared_secret[32], nonce_hi || nonce_lo || dest_hash || src_hash) + * Including hashes makes keys direction-dependent: Alice->Bob and Bob->Alice derive + * different keys even with the same nonce (for 255/256 peer pairs; the 1/256 where + * dest_hash == src_hash remains a residual risk inherent to 1-byte hashes). + * + * IV construction (12 bytes, from on-wire fields): + * iv[12] = { nonce_hi, nonce_lo, dest_hash, src_hash, 0, 0, 0, 0, 0, 0, 0, 0 } + * + * Associated data (authenticated but not encrypted): + * Peer msgs: header || dest_hash || src_hash + * Anon reqs: header || dest_hash + * Group msgs: header || channel_hash + * + * Nonce: 16-bit counter per peer, seeded from HW RNG on boot. With per-message + * key derivation, even a nonce collision (across reboots) only leaks P1 XOR P2 + * for that message pair — no key recovery, no impact on other messages. + * + * Group channels: all members share the same key, so cross-sender nonce + * collisions are possible (~300 msgs for 50% chance with random nonces). + * Damage is bounded (message pair leak, no key recovery). + */ +int Utils::aeadEncrypt(const uint8_t* shared_secret, + uint8_t* dest, + const uint8_t* src, int src_len, + const uint8_t* assoc_data, int assoc_len, + uint16_t nonce_counter, + uint8_t dest_hash, uint8_t src_hash) { + if (src_len <= 0) return 0; + + // Write nonce to output + dest[0] = (uint8_t)(nonce_counter >> 8); + dest[1] = (uint8_t)(nonce_counter & 0xFF); + + // Derive per-message key: HMAC-SHA256(shared_secret, nonce || dest_hash || src_hash) + // Including hashes makes the key direction-dependent, preventing keystream reuse + // when Alice->Bob and Bob->Alice use the same nonce (255/256 peer pairs). + uint8_t msg_key[32]; + { + uint8_t kdf_input[AEAD_NONCE_SIZE + 2] = { dest[0], dest[1], dest_hash, src_hash }; + SHA256 sha; + sha.resetHMAC(shared_secret, PUB_KEY_SIZE); + sha.update(kdf_input, sizeof(kdf_input)); + sha.finalizeHMAC(shared_secret, PUB_KEY_SIZE, msg_key, 32); + } + + // Build 12-byte IV from on-wire fields + uint8_t iv[12]; + iv[0] = dest[0]; // nonce_hi + iv[1] = dest[1]; // nonce_lo + iv[2] = dest_hash; + iv[3] = src_hash; + memset(&iv[4], 0, 8); + + ChaChaPoly cipher; + cipher.setKey(msg_key, 32); + cipher.setIV(iv, 12); + cipher.addAuthData(assoc_data, assoc_len); + cipher.encrypt(dest + AEAD_NONCE_SIZE, src, src_len); + cipher.computeTag(dest + AEAD_NONCE_SIZE + src_len, AEAD_TAG_SIZE); + cipher.clear(); + memset(msg_key, 0, 32); + + return AEAD_NONCE_SIZE + src_len + AEAD_TAG_SIZE; +} + +int Utils::aeadDecrypt(const uint8_t* shared_secret, + uint8_t* dest, + const uint8_t* src, int src_len, + const uint8_t* assoc_data, int assoc_len, + uint8_t dest_hash, uint8_t src_hash) { + // Minimum: nonce(2) + at least 1 byte ciphertext + tag(4) + if (src_len < AEAD_NONCE_SIZE + 1 + AEAD_TAG_SIZE) return 0; + + int ct_len = src_len - AEAD_NONCE_SIZE - AEAD_TAG_SIZE; + + // Derive per-message key: HMAC-SHA256(shared_secret, nonce || dest_hash || src_hash) + uint8_t msg_key[32]; + { + uint8_t kdf_input[AEAD_NONCE_SIZE + 2] = { src[0], src[1], dest_hash, src_hash }; + SHA256 sha; + sha.resetHMAC(shared_secret, PUB_KEY_SIZE); + sha.update(kdf_input, sizeof(kdf_input)); + sha.finalizeHMAC(shared_secret, PUB_KEY_SIZE, msg_key, 32); + } + + // Build 12-byte IV from on-wire fields + uint8_t iv[12]; + iv[0] = src[0]; // nonce_hi + iv[1] = src[1]; // nonce_lo + iv[2] = dest_hash; + iv[3] = src_hash; + memset(&iv[4], 0, 8); + + ChaChaPoly cipher; + cipher.setKey(msg_key, 32); + cipher.setIV(iv, 12); + cipher.addAuthData(assoc_data, assoc_len); + cipher.decrypt(dest, src + AEAD_NONCE_SIZE, ct_len); + + bool valid = cipher.checkTag(src + AEAD_NONCE_SIZE + ct_len, AEAD_TAG_SIZE); + cipher.clear(); + memset(msg_key, 0, 32); + if (!valid) memset(dest, 0, ct_len); + + return valid ? ct_len : 0; +} + static const char hex_chars[] = "0123456789ABCDEF"; void Utils::toHex(char* dest, const uint8_t* src, size_t len) { diff --git a/src/Utils.h b/src/Utils.h index 5736b8747..dba1989e1 100644 --- a/src/Utils.h +++ b/src/Utils.h @@ -54,6 +54,29 @@ class Utils { */ static int MACThenDecrypt(const uint8_t* shared_secret, uint8_t* dest, const uint8_t* src, int src_len); + /** + * \brief Encrypt with ChaChaPoly AEAD. Derives per-message key via HMAC-SHA256(shared_secret, nonce || dest_hash || src_hash). + * Output: [nonce:2][ciphertext:src_len][tag:4] + * \returns total output length (AEAD_NONCE_SIZE + src_len + AEAD_TAG_SIZE), or 0 on failure + */ + static int aeadEncrypt(const uint8_t* shared_secret, + uint8_t* dest, + const uint8_t* src, int src_len, + const uint8_t* assoc_data, int assoc_len, + uint16_t nonce_counter, + uint8_t dest_hash, uint8_t src_hash); + + /** + * \brief Decrypt with ChaChaPoly AEAD. Derives per-message key via HMAC-SHA256(shared_secret, nonce || dest_hash || src_hash). + * Input: [nonce:2][ciphertext:M][tag:4] + * \returns plaintext length, or 0 if tag verification fails + */ + static int aeadDecrypt(const uint8_t* shared_secret, + uint8_t* dest, + const uint8_t* src, int src_len, + const uint8_t* assoc_data, int assoc_len, + uint8_t dest_hash, uint8_t src_hash); + /** * \brief converts 'src' bytes with given length to Hex representation, and null terminates. */ diff --git a/src/helpers/BaseChatMesh.cpp b/src/helpers/BaseChatMesh.cpp index 6de7469d0..01e0d3d55 100644 --- a/src/helpers/BaseChatMesh.cpp +++ b/src/helpers/BaseChatMesh.cpp @@ -21,6 +21,7 @@ mesh::Packet* BaseChatMesh::createSelfAdvert(const char* name) { uint8_t app_data_len; { AdvertDataBuilder builder(ADV_TYPE_CHAT, name); + builder.setFeat1(FEAT1_AEAD_SUPPORT); app_data_len = builder.encodeTo(app_data); } @@ -32,6 +33,7 @@ mesh::Packet* BaseChatMesh::createSelfAdvert(const char* name, double lat, doubl uint8_t app_data_len; { AdvertDataBuilder builder(ADV_TYPE_CHAT, name, lat, lon); + builder.setFeat1(FEAT1_AEAD_SUPPORT); app_data_len = builder.encodeTo(app_data); } @@ -101,6 +103,10 @@ void BaseChatMesh::populateContactFromAdvert(ContactInfo& ci, const mesh::Identi } ci.last_advert_timestamp = timestamp; ci.lastmod = getRTCClock()->getCurrentTime(); + getRNG()->random((uint8_t*)&ci.aead_nonce, sizeof(ci.aead_nonce)); // seed AEAD nonce from HW RNG + if (parser.getFeat1() & FEAT1_AEAD_SUPPORT) { + ci.flags |= CONTACT_FLAG_AEAD; + } } void BaseChatMesh::onAdvertRecv(mesh::Packet* packet, const mesh::Identity& id, uint32_t timestamp, const uint8_t* app_data, size_t app_data_len) { @@ -165,6 +171,11 @@ void BaseChatMesh::onAdvertRecv(mesh::Packet* packet, const mesh::Identity& id, } from->last_advert_timestamp = timestamp; from->lastmod = getRTCClock()->getCurrentTime(); + if (parser.getFeat1() & FEAT1_AEAD_SUPPORT) { + from->flags |= CONTACT_FLAG_AEAD; + } else { + from->flags &= ~CONTACT_FLAG_AEAD; + } onDiscoveredContact(*from, is_new, packet->path_len, packet->path); // let UI know } @@ -762,6 +773,7 @@ bool BaseChatMesh::addContact(const ContactInfo& contact) { if (dest) { *dest = contact; dest->shared_secret_valid = false; // mark shared_secret as needing calculation + getRNG()->random((uint8_t*)&dest->aead_nonce, sizeof(dest->aead_nonce)); // always seed fresh from HW RNG return true; // success } return false; diff --git a/src/helpers/CommonCLI.cpp b/src/helpers/CommonCLI.cpp index 6dcf7018e..beef8edaf 100644 --- a/src/helpers/CommonCLI.cpp +++ b/src/helpers/CommonCLI.cpp @@ -183,12 +183,15 @@ void CommonCLI::savePrefs() { uint8_t CommonCLI::buildAdvertData(uint8_t node_type, uint8_t* app_data) { if (_prefs->advert_loc_policy == ADVERT_LOC_NONE) { AdvertDataBuilder builder(node_type, _prefs->node_name); + builder.setFeat1(FEAT1_AEAD_SUPPORT); return builder.encodeTo(app_data); } else if (_prefs->advert_loc_policy == ADVERT_LOC_SHARE) { AdvertDataBuilder builder(node_type, _prefs->node_name, _sensors->node_lat, _sensors->node_lon); + builder.setFeat1(FEAT1_AEAD_SUPPORT); return builder.encodeTo(app_data); } else { AdvertDataBuilder builder(node_type, _prefs->node_name, _prefs->node_lat, _prefs->node_lon); + builder.setFeat1(FEAT1_AEAD_SUPPORT); return builder.encodeTo(app_data); } } diff --git a/src/helpers/ContactInfo.h b/src/helpers/ContactInfo.h index eff07741a..c0dd23a59 100644 --- a/src/helpers/ContactInfo.h +++ b/src/helpers/ContactInfo.h @@ -15,6 +15,7 @@ struct ContactInfo { uint32_t lastmod; // by OUR clock int32_t gps_lat, gps_lon; // 6 dec places uint32_t sync_since; + uint16_t aead_nonce; // per-peer AEAD nonce counter for DMs (not used for group messages), seeded from HW RNG const uint8_t* getSharedSecret(const mesh::LocalIdentity& self_id) const { if (!shared_secret_valid) { From e224e5cf04c6213f426b1ddff8517ba8a90cf74a Mon Sep 17 00:00:00 2001 From: Wessel Nieboer Date: Thu, 12 Feb 2026 01:34:33 +0100 Subject: [PATCH 02/11] Enable AEAD-4 sending to peers that advertise support Send ChaChaPoly-encrypted messages to peers with CONTACT_FLAG_AEAD set, and try AEAD decode first for those peers (avoiding 1/65536 ECB false-positive). Legacy peers continue to use ECB in both directions. - Add aead_nonce parameter to createDatagram/createPathReturn (default 0 = ECB) - Add getPeerFlags/getPeerNextAeadNonce virtual methods for decode-order selection - Add ContactInfo::nextAeadNonce() helper (returns nonce++ if AEAD, 0 otherwise) - Update all BaseChatMesh send paths to pass nonce for AEAD-capable peers - Adaptive decode order: AEAD-first for known AEAD peers, ECB-first for others --- src/Mesh.cpp | 49 ++++++++++++++++++++++++------------ src/Mesh.h | 8 +++--- src/helpers/BaseChatMesh.cpp | 38 ++++++++++++++++++++-------- src/helpers/BaseChatMesh.h | 2 ++ src/helpers/ContactInfo.h | 9 ++++++- 5 files changed, 75 insertions(+), 31 deletions(-) diff --git a/src/Mesh.cpp b/src/Mesh.cpp index 5be2eecbf..4a966592e 100644 --- a/src/Mesh.cpp +++ b/src/Mesh.cpp @@ -154,15 +154,16 @@ DispatcherAction Mesh::onRecvPacket(Packet* pkt) { uint8_t data[MAX_PACKET_PAYLOAD]; int macAndDataLen = pkt->payload_len - i; - // Try ECB first (Phase 1: all senders use ECB), then AEAD-4 fallback. - // IMPORTANT: Phase 2 MUST swap to AEAD-first. ECB-first has a 1/65536 - // false-positive rate on AEAD packets (nonce bytes matching truncated HMAC), - // producing garbage plaintext. AEAD-first has only 1/2^32 false-positive on - // ECB packets, which is negligible. - int len = Utils::MACThenDecrypt(secret, data, macAndData, macAndDataLen); - if (len <= 0) { - uint8_t assoc[3] = { pkt->header, dest_hash, src_hash }; + // Try-both decode: AEAD-first for peers known to support it (avoids 1/65536 + // ECB false-positive on AEAD packets), ECB-first for unknown/legacy peers. + uint8_t assoc[3] = { pkt->header, dest_hash, src_hash }; + int len; + if (getPeerFlags(j) & CONTACT_FLAG_AEAD) { len = Utils::aeadDecrypt(secret, data, macAndData, macAndDataLen, assoc, 3, dest_hash, src_hash); + if (len <= 0) len = Utils::MACThenDecrypt(secret, data, macAndData, macAndDataLen); + } else { + len = Utils::MACThenDecrypt(secret, data, macAndData, macAndDataLen); + if (len <= 0) len = Utils::aeadDecrypt(secret, data, macAndData, macAndDataLen, assoc, 3, dest_hash, src_hash); } if (len > 0) { // success! if (pkt->getPayloadType() == PAYLOAD_TYPE_PATH) { @@ -175,7 +176,7 @@ DispatcherAction Mesh::onRecvPacket(Packet* pkt) { if (onPeerPathRecv(pkt, j, secret, path, path_len, extra_type, extra, extra_len)) { if (pkt->isRouteFlood()) { // send a reciprocal return path to sender, but send DIRECTLY! - mesh::Packet* rpath = createPathReturn(&src_hash, secret, pkt->path, pkt->path_len, 0, NULL, 0); + mesh::Packet* rpath = createPathReturn(&src_hash, secret, pkt->path, pkt->path_len, 0, NULL, 0, getPeerNextAeadNonce(j)); if (rpath) sendDirect(rpath, path, path_len, 500); } } @@ -459,13 +460,13 @@ Packet* Mesh::createAdvert(const LocalIdentity& id, const uint8_t* app_data, siz #define MAX_COMBINED_PATH (MAX_PACKET_PAYLOAD - 2 - CIPHER_BLOCK_SIZE) -Packet* Mesh::createPathReturn(const Identity& dest, const uint8_t* secret, const uint8_t* path, uint8_t path_len, uint8_t extra_type, const uint8_t*extra, size_t extra_len) { +Packet* Mesh::createPathReturn(const Identity& dest, const uint8_t* secret, const uint8_t* path, uint8_t path_len, uint8_t extra_type, const uint8_t*extra, size_t extra_len, uint16_t aead_nonce) { uint8_t dest_hash[PATH_HASH_SIZE]; dest.copyHashTo(dest_hash); - return createPathReturn(dest_hash, secret, path, path_len, extra_type, extra, extra_len); + return createPathReturn(dest_hash, secret, path, path_len, extra_type, extra, extra_len, aead_nonce); } -Packet* Mesh::createPathReturn(const uint8_t* dest_hash, const uint8_t* secret, const uint8_t* path, uint8_t path_len, uint8_t extra_type, const uint8_t*extra, size_t extra_len) { +Packet* Mesh::createPathReturn(const uint8_t* dest_hash, const uint8_t* secret, const uint8_t* path, uint8_t path_len, uint8_t extra_type, const uint8_t*extra, size_t extra_len, uint16_t aead_nonce) { if (path_len + extra_len + 5 > MAX_COMBINED_PATH) return NULL; // too long!! Packet* packet = obtainNewPacket(); @@ -494,7 +495,14 @@ Packet* Mesh::createPathReturn(const uint8_t* dest_hash, const uint8_t* secret, getRNG()->random(&data[data_len], 4); data_len += 4; } - len += Utils::encryptThenMAC(secret, &packet->payload[len], data, data_len); + if (aead_nonce) { + uint8_t dh = packet->payload[0]; + uint8_t sh = packet->payload[1]; + uint8_t assoc[3] = { packet->header, dh, sh }; + len += Utils::aeadEncrypt(secret, &packet->payload[len], data, data_len, assoc, 3, aead_nonce, dh, sh); + } else { + len += Utils::encryptThenMAC(secret, &packet->payload[len], data, data_len); + } } packet->payload_len = len; @@ -502,9 +510,10 @@ Packet* Mesh::createPathReturn(const uint8_t* dest_hash, const uint8_t* secret, return packet; } -Packet* Mesh::createDatagram(uint8_t type, const Identity& dest, const uint8_t* secret, const uint8_t* data, size_t data_len) { +Packet* Mesh::createDatagram(uint8_t type, const Identity& dest, const uint8_t* secret, const uint8_t* data, size_t data_len, uint16_t aead_nonce) { if (type == PAYLOAD_TYPE_TXT_MSG || type == PAYLOAD_TYPE_REQ || type == PAYLOAD_TYPE_RESPONSE) { - if (data_len + CIPHER_MAC_SIZE + CIPHER_BLOCK_SIZE-1 > MAX_PACKET_PAYLOAD) return NULL; + size_t max_overhead = aead_nonce ? (AEAD_NONCE_SIZE + AEAD_TAG_SIZE) : (CIPHER_MAC_SIZE + CIPHER_BLOCK_SIZE-1); + if (data_len + max_overhead > MAX_PACKET_PAYLOAD) return NULL; } else { return NULL; // invalid type } @@ -519,7 +528,15 @@ Packet* Mesh::createDatagram(uint8_t type, const Identity& dest, const uint8_t* int len = 0; len += dest.copyHashTo(&packet->payload[len]); // dest hash len += self_id.copyHashTo(&packet->payload[len]); // src hash - len += Utils::encryptThenMAC(secret, &packet->payload[len], data, data_len); + + if (aead_nonce) { + uint8_t dest_hash = packet->payload[0]; + uint8_t src_hash = packet->payload[1]; + uint8_t assoc[3] = { packet->header, dest_hash, src_hash }; + len += Utils::aeadEncrypt(secret, &packet->payload[len], data, data_len, assoc, 3, aead_nonce, dest_hash, src_hash); + } else { + len += Utils::encryptThenMAC(secret, &packet->payload[len], data, data_len); + } packet->payload_len = len; diff --git a/src/Mesh.h b/src/Mesh.h index 00f7ed00f..627ae34eb 100644 --- a/src/Mesh.h +++ b/src/Mesh.h @@ -82,6 +82,8 @@ class Mesh : public Dispatcher { * \param peer_idx index of peer, [0..n) where n is what searchPeersByHash() returned */ virtual void getPeerSharedSecret(uint8_t* dest_secret, int peer_idx) { } + virtual uint8_t getPeerFlags(int peer_idx) { return 0; } + virtual uint16_t getPeerNextAeadNonce(int peer_idx) { return 0; } /** * \brief A (now decrypted) data packet has been received (by a known peer). @@ -182,13 +184,13 @@ class Mesh : public Dispatcher { RTCClock* getRTCClock() const { return _rtc; } Packet* createAdvert(const LocalIdentity& id, const uint8_t* app_data=NULL, size_t app_data_len=0); - Packet* createDatagram(uint8_t type, const Identity& dest, const uint8_t* secret, const uint8_t* data, size_t len); + Packet* createDatagram(uint8_t type, const Identity& dest, const uint8_t* secret, const uint8_t* data, size_t len, uint16_t aead_nonce=0); Packet* createAnonDatagram(uint8_t type, const LocalIdentity& sender, const Identity& dest, const uint8_t* secret, const uint8_t* data, size_t data_len); Packet* createGroupDatagram(uint8_t type, const GroupChannel& channel, const uint8_t* data, size_t data_len); Packet* createAck(uint32_t ack_crc); Packet* createMultiAck(uint32_t ack_crc, uint8_t remaining); - Packet* createPathReturn(const uint8_t* dest_hash, const uint8_t* secret, const uint8_t* path, uint8_t path_len, uint8_t extra_type, const uint8_t*extra, size_t extra_len); - Packet* createPathReturn(const Identity& dest, const uint8_t* secret, const uint8_t* path, uint8_t path_len, uint8_t extra_type, const uint8_t*extra, size_t extra_len); + Packet* createPathReturn(const uint8_t* dest_hash, const uint8_t* secret, const uint8_t* path, uint8_t path_len, uint8_t extra_type, const uint8_t*extra, size_t extra_len, uint16_t aead_nonce=0); + Packet* createPathReturn(const Identity& dest, const uint8_t* secret, const uint8_t* path, uint8_t path_len, uint8_t extra_type, const uint8_t*extra, size_t extra_len, uint16_t aead_nonce=0); Packet* createRawData(const uint8_t* data, size_t len); Packet* createTrace(uint32_t tag, uint32_t auth_code, uint8_t flags = 0); Packet* createControlData(const uint8_t* data, size_t len); diff --git a/src/helpers/BaseChatMesh.cpp b/src/helpers/BaseChatMesh.cpp index 01e0d3d55..5360fbfb5 100644 --- a/src/helpers/BaseChatMesh.cpp +++ b/src/helpers/BaseChatMesh.cpp @@ -199,6 +199,22 @@ void BaseChatMesh::getPeerSharedSecret(uint8_t* dest_secret, int peer_idx) { } } +uint8_t BaseChatMesh::getPeerFlags(int peer_idx) { + int i = matching_peer_indexes[peer_idx]; + if (i >= 0 && i < num_contacts) { + return contacts[i].flags; + } + return 0; +} + +uint16_t BaseChatMesh::getPeerNextAeadNonce(int peer_idx) { + int i = matching_peer_indexes[peer_idx]; + if (i >= 0 && i < num_contacts) { + return contacts[i].nextAeadNonce(); + } + return 0; +} + void BaseChatMesh::onPeerDataRecv(mesh::Packet* packet, uint8_t type, int sender_idx, const uint8_t* secret, uint8_t* data, size_t len) { int i = matching_peer_indexes[sender_idx]; if (i < 0 || i >= num_contacts) { @@ -226,7 +242,7 @@ void BaseChatMesh::onPeerDataRecv(mesh::Packet* packet, uint8_t type, int sender if (packet->isRouteFlood()) { // let this sender know path TO here, so they can use sendDirect(), and ALSO encode the ACK mesh::Packet* path = createPathReturn(from.id, secret, packet->path, packet->path_len, - PAYLOAD_TYPE_ACK, (uint8_t *) &ack_hash, 4); + PAYLOAD_TYPE_ACK, (uint8_t *) &ack_hash, 4, from.nextAeadNonce()); if (path) sendFloodScoped(from, path, TXT_ACK_DELAY); } else { sendAckTo(from, ack_hash); @@ -237,7 +253,7 @@ void BaseChatMesh::onPeerDataRecv(mesh::Packet* packet, uint8_t type, int sender if (packet->isRouteFlood()) { // let this sender know path TO here, so they can use sendDirect() (NOTE: no ACK as extra) - mesh::Packet* path = createPathReturn(from.id, secret, packet->path, packet->path_len, 0, NULL, 0); + mesh::Packet* path = createPathReturn(from.id, secret, packet->path, packet->path_len, 0, NULL, 0, from.nextAeadNonce()); if (path) sendFloodScoped(from, path); } } else if (flags == TXT_TYPE_SIGNED_PLAIN) { @@ -253,7 +269,7 @@ void BaseChatMesh::onPeerDataRecv(mesh::Packet* packet, uint8_t type, int sender if (packet->isRouteFlood()) { // let this sender know path TO here, so they can use sendDirect(), and ALSO encode the ACK mesh::Packet* path = createPathReturn(from.id, secret, packet->path, packet->path_len, - PAYLOAD_TYPE_ACK, (uint8_t *) &ack_hash, 4); + PAYLOAD_TYPE_ACK, (uint8_t *) &ack_hash, 4, from.nextAeadNonce()); if (path) sendFloodScoped(from, path, TXT_ACK_DELAY); } else { sendAckTo(from, ack_hash); @@ -269,10 +285,10 @@ void BaseChatMesh::onPeerDataRecv(mesh::Packet* packet, uint8_t type, int sender if (packet->isRouteFlood()) { // let this sender know path TO here, so they can use sendDirect(), and ALSO encode the response mesh::Packet* path = createPathReturn(from.id, secret, packet->path, packet->path_len, - PAYLOAD_TYPE_RESPONSE, temp_buf, reply_len); + PAYLOAD_TYPE_RESPONSE, temp_buf, reply_len, from.nextAeadNonce()); if (path) sendFloodScoped(from, path, SERVER_RESPONSE_DELAY); } else { - mesh::Packet* reply = createDatagram(PAYLOAD_TYPE_RESPONSE, from.id, secret, temp_buf, reply_len); + mesh::Packet* reply = createDatagram(PAYLOAD_TYPE_RESPONSE, from.id, secret, temp_buf, reply_len, from.nextAeadNonce()); if (reply) { if (from.out_path_len >= 0) { // we have an out_path, so send DIRECT sendDirect(reply, from.out_path, from.out_path_len, SERVER_RESPONSE_DELAY); @@ -338,7 +354,7 @@ void BaseChatMesh::onAckRecv(mesh::Packet* packet, uint32_t ack_crc) { void BaseChatMesh::handleReturnPathRetry(const ContactInfo& contact, const uint8_t* path, uint8_t path_len) { // NOTE: simplest impl is just to re-send a reciprocal return path to sender (DIRECTLY) // override this method in various firmwares, if there's a better strategy - mesh::Packet* rpath = createPathReturn(contact.id, contact.getSharedSecret(self_id), path, path_len, 0, NULL, 0); + mesh::Packet* rpath = createPathReturn(contact.id, contact.getSharedSecret(self_id), path, path_len, 0, NULL, 0, contact.nextAeadNonce()); if (rpath) sendDirect(rpath, contact.out_path, contact.out_path_len, 3000); // 3 second delay } @@ -387,7 +403,7 @@ mesh::Packet* BaseChatMesh::composeMsgPacket(const ContactInfo& recipient, uint3 temp[len++] = attempt; // hide attempt number at tail end of payload } - return createDatagram(PAYLOAD_TYPE_TXT_MSG, recipient.id, recipient.getSharedSecret(self_id), temp, len); + return createDatagram(PAYLOAD_TYPE_TXT_MSG, recipient.id, recipient.getSharedSecret(self_id), temp, len, recipient.nextAeadNonce()); } int BaseChatMesh::sendMessage(const ContactInfo& recipient, uint32_t timestamp, uint8_t attempt, const char* text, uint32_t& expected_ack, uint32_t& est_timeout) { @@ -418,7 +434,7 @@ int BaseChatMesh::sendCommandData(const ContactInfo& recipient, uint32_t timest temp[4] = (attempt & 3) | (TXT_TYPE_CLI_DATA << 2); memcpy(&temp[5], text, text_len + 1); - auto pkt = createDatagram(PAYLOAD_TYPE_TXT_MSG, recipient.id, recipient.getSharedSecret(self_id), temp, 5 + text_len); + auto pkt = createDatagram(PAYLOAD_TYPE_TXT_MSG, recipient.id, recipient.getSharedSecret(self_id), temp, 5 + text_len, recipient.nextAeadNonce()); if (pkt == NULL) return MSG_SEND_FAILED; uint32_t t = _radio->getEstAirtimeFor(pkt->getRawLength()); @@ -559,7 +575,7 @@ int BaseChatMesh::sendRequest(const ContactInfo& recipient, const uint8_t* req_ memcpy(temp, &tag, 4); // mostly an extra blob to help make packet_hash unique memcpy(&temp[4], req_data, data_len); - pkt = createDatagram(PAYLOAD_TYPE_REQ, recipient.id, recipient.getSharedSecret(self_id), temp, 4 + data_len); + pkt = createDatagram(PAYLOAD_TYPE_REQ, recipient.id, recipient.getSharedSecret(self_id), temp, 4 + data_len, recipient.nextAeadNonce()); } if (pkt) { uint32_t t = _radio->getEstAirtimeFor(pkt->getRawLength()); @@ -586,7 +602,7 @@ int BaseChatMesh::sendRequest(const ContactInfo& recipient, uint8_t req_type, u memset(&temp[5], 0, 4); // reserved (possibly for 'since' param) getRNG()->random(&temp[9], 4); // random blob to help make packet-hash unique - pkt = createDatagram(PAYLOAD_TYPE_REQ, recipient.id, recipient.getSharedSecret(self_id), temp, sizeof(temp)); + pkt = createDatagram(PAYLOAD_TYPE_REQ, recipient.id, recipient.getSharedSecret(self_id), temp, sizeof(temp), recipient.nextAeadNonce()); } if (pkt) { uint32_t t = _radio->getEstAirtimeFor(pkt->getRawLength()); @@ -709,7 +725,7 @@ void BaseChatMesh::checkConnections() { // calc expected ACK reply mesh::Utils::sha256((uint8_t *)&connections[i].expected_ack, 4, data, 9, self_id.pub_key, PUB_KEY_SIZE); - auto pkt = createDatagram(PAYLOAD_TYPE_REQ, contact->id, contact->getSharedSecret(self_id), data, 9); + auto pkt = createDatagram(PAYLOAD_TYPE_REQ, contact->id, contact->getSharedSecret(self_id), data, 9, contact->nextAeadNonce()); if (pkt) { sendDirect(pkt, contact->out_path, contact->out_path_len); } diff --git a/src/helpers/BaseChatMesh.h b/src/helpers/BaseChatMesh.h index fd391b980..516375efa 100644 --- a/src/helpers/BaseChatMesh.h +++ b/src/helpers/BaseChatMesh.h @@ -125,6 +125,8 @@ class BaseChatMesh : public mesh::Mesh { void onAdvertRecv(mesh::Packet* packet, const mesh::Identity& id, uint32_t timestamp, const uint8_t* app_data, size_t app_data_len) override; int searchPeersByHash(const uint8_t* hash) override; void getPeerSharedSecret(uint8_t* dest_secret, int peer_idx) override; + uint8_t getPeerFlags(int peer_idx) override; + uint16_t getPeerNextAeadNonce(int peer_idx) override; void onPeerDataRecv(mesh::Packet* packet, uint8_t type, int sender_idx, const uint8_t* secret, uint8_t* data, size_t len) override; bool onPeerPathRecv(mesh::Packet* packet, int sender_idx, const uint8_t* secret, uint8_t* path, uint8_t path_len, uint8_t extra_type, uint8_t* extra, uint8_t extra_len) override; void onAckRecv(mesh::Packet* packet, uint32_t ack_crc) override; diff --git a/src/helpers/ContactInfo.h b/src/helpers/ContactInfo.h index c0dd23a59..1d66424c0 100644 --- a/src/helpers/ContactInfo.h +++ b/src/helpers/ContactInfo.h @@ -15,7 +15,14 @@ struct ContactInfo { uint32_t lastmod; // by OUR clock int32_t gps_lat, gps_lon; // 6 dec places uint32_t sync_since; - uint16_t aead_nonce; // per-peer AEAD nonce counter for DMs (not used for group messages), seeded from HW RNG + mutable uint16_t aead_nonce; // per-peer AEAD nonce counter for DMs (not used for group messages), seeded from HW RNG + + // Returns next AEAD nonce (post-increment) if peer supports AEAD, 0 otherwise. + // When 0, callers use ECB encryption. + uint16_t nextAeadNonce() const { + if (flags & CONTACT_FLAG_AEAD) return ++aead_nonce; + return 0; + } const uint8_t* getSharedSecret(const mesh::LocalIdentity& self_id) const { if (!shared_secret_valid) { From 6526793dbccac6e0e73d7c3e762c807b2eeeafa0 Mon Sep 17 00:00:00 2001 From: Wessel Nieboer Date: Thu, 12 Feb 2026 01:48:42 +0100 Subject: [PATCH 03/11] Fix AEAD-4 payload size check and nonce wrap-around --- src/Mesh.cpp | 3 ++- src/helpers/ContactInfo.h | 5 ++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/Mesh.cpp b/src/Mesh.cpp index 4a966592e..50a108557 100644 --- a/src/Mesh.cpp +++ b/src/Mesh.cpp @@ -512,8 +512,9 @@ Packet* Mesh::createPathReturn(const uint8_t* dest_hash, const uint8_t* secret, Packet* Mesh::createDatagram(uint8_t type, const Identity& dest, const uint8_t* secret, const uint8_t* data, size_t data_len, uint16_t aead_nonce) { if (type == PAYLOAD_TYPE_TXT_MSG || type == PAYLOAD_TYPE_REQ || type == PAYLOAD_TYPE_RESPONSE) { + size_t hash_prefix = PATH_HASH_SIZE * 2; // dest_hash + src_hash size_t max_overhead = aead_nonce ? (AEAD_NONCE_SIZE + AEAD_TAG_SIZE) : (CIPHER_MAC_SIZE + CIPHER_BLOCK_SIZE-1); - if (data_len + max_overhead > MAX_PACKET_PAYLOAD) return NULL; + if (data_len + hash_prefix + max_overhead > MAX_PACKET_PAYLOAD) return NULL; } else { return NULL; // invalid type } diff --git a/src/helpers/ContactInfo.h b/src/helpers/ContactInfo.h index 1d66424c0..7372f5a14 100644 --- a/src/helpers/ContactInfo.h +++ b/src/helpers/ContactInfo.h @@ -20,7 +20,10 @@ struct ContactInfo { // Returns next AEAD nonce (post-increment) if peer supports AEAD, 0 otherwise. // When 0, callers use ECB encryption. uint16_t nextAeadNonce() const { - if (flags & CONTACT_FLAG_AEAD) return ++aead_nonce; + if (flags & CONTACT_FLAG_AEAD) { + if (++aead_nonce == 0) ++aead_nonce; // skip 0 (sentinel for ECB) + return aead_nonce; + } return 0; } From 7637e640622db2de7d4b7cc16960e077743c6039 Mon Sep 17 00:00:00 2001 From: Wessel Nieboer Date: Thu, 12 Feb 2026 02:16:56 +0100 Subject: [PATCH 04/11] =?UTF-8?q?Fix=20AEAD-4=20assoc=20data=20mismatch=20?= =?UTF-8?q?=E2=80=94=20route=20type=20bits=20set=20after=20encryption?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The header's route type bits (PH_ROUTE_MASK) are zero when createDatagram/createPathReturn encrypt with AEAD, but get changed to ROUTE_TYPE_FLOOD (1) or ROUTE_TYPE_DIRECT (2) by sendFlood/sendDirect afterwards. The receiver builds assoc from the received header (with route bits set), so the tag check always fails and every AEAD packet is silently dropped. Mask out route type bits in assoc data on all 5 encrypt/decrypt sites. Also track AEAD decode success to enable peer capability auto-detection. --- src/Mesh.cpp | 21 ++++++++++++++------- src/Mesh.h | 1 + 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/src/Mesh.cpp b/src/Mesh.cpp index 50a108557..281726957 100644 --- a/src/Mesh.cpp +++ b/src/Mesh.cpp @@ -156,16 +156,23 @@ DispatcherAction Mesh::onRecvPacket(Packet* pkt) { // Try-both decode: AEAD-first for peers known to support it (avoids 1/65536 // ECB false-positive on AEAD packets), ECB-first for unknown/legacy peers. - uint8_t assoc[3] = { pkt->header, dest_hash, src_hash }; + // Mask out route type bits — they are set after encryption and vary per hop. + uint8_t assoc[3] = { (uint8_t)(pkt->header & ~PH_ROUTE_MASK), dest_hash, src_hash }; int len; + bool decoded_aead = false; if (getPeerFlags(j) & CONTACT_FLAG_AEAD) { len = Utils::aeadDecrypt(secret, data, macAndData, macAndDataLen, assoc, 3, dest_hash, src_hash); - if (len <= 0) len = Utils::MACThenDecrypt(secret, data, macAndData, macAndDataLen); + if (len > 0) decoded_aead = true; + else len = Utils::MACThenDecrypt(secret, data, macAndData, macAndDataLen); } else { len = Utils::MACThenDecrypt(secret, data, macAndData, macAndDataLen); - if (len <= 0) len = Utils::aeadDecrypt(secret, data, macAndData, macAndDataLen, assoc, 3, dest_hash, src_hash); + if (len <= 0) { + len = Utils::aeadDecrypt(secret, data, macAndData, macAndDataLen, assoc, 3, dest_hash, src_hash); + if (len > 0) decoded_aead = true; + } } if (len > 0) { // success! + if (decoded_aead) onPeerAeadDetected(j); if (pkt->getPayloadType() == PAYLOAD_TYPE_PATH) { int k = 0; uint8_t path_len = data[k++]; @@ -219,7 +226,7 @@ DispatcherAction Mesh::onRecvPacket(Packet* pkt) { // Phase 2 MUST swap to AEAD-first (see peer message comment above). int len = Utils::MACThenDecrypt(secret, data, macAndData, macAndDataLen); if (len <= 0) { - uint8_t assoc[2] = { pkt->header, dest_hash }; + uint8_t assoc[2] = { (uint8_t)(pkt->header & ~PH_ROUTE_MASK), dest_hash }; len = Utils::aeadDecrypt(secret, data, macAndData, macAndDataLen, assoc, 2, dest_hash, 0); } if (len > 0) { // success! @@ -255,7 +262,7 @@ DispatcherAction Mesh::onRecvPacket(Packet* pkt) { // worthwhile for public/hashtag channels where the PSK is already widely known. int len = Utils::MACThenDecrypt(channels[j].secret, data, macAndData, macAndDataLen); if (len <= 0) { - uint8_t assoc[2] = { pkt->header, channel_hash }; + uint8_t assoc[2] = { (uint8_t)(pkt->header & ~PH_ROUTE_MASK), channel_hash }; len = Utils::aeadDecrypt(channels[j].secret, data, macAndData, macAndDataLen, assoc, 2, channel_hash, 0); } if (len > 0) { // success! @@ -498,7 +505,7 @@ Packet* Mesh::createPathReturn(const uint8_t* dest_hash, const uint8_t* secret, if (aead_nonce) { uint8_t dh = packet->payload[0]; uint8_t sh = packet->payload[1]; - uint8_t assoc[3] = { packet->header, dh, sh }; + uint8_t assoc[3] = { (uint8_t)(packet->header & ~PH_ROUTE_MASK), dh, sh }; len += Utils::aeadEncrypt(secret, &packet->payload[len], data, data_len, assoc, 3, aead_nonce, dh, sh); } else { len += Utils::encryptThenMAC(secret, &packet->payload[len], data, data_len); @@ -533,7 +540,7 @@ Packet* Mesh::createDatagram(uint8_t type, const Identity& dest, const uint8_t* if (aead_nonce) { uint8_t dest_hash = packet->payload[0]; uint8_t src_hash = packet->payload[1]; - uint8_t assoc[3] = { packet->header, dest_hash, src_hash }; + uint8_t assoc[3] = { (uint8_t)(packet->header & ~PH_ROUTE_MASK), dest_hash, src_hash }; len += Utils::aeadEncrypt(secret, &packet->payload[len], data, data_len, assoc, 3, aead_nonce, dest_hash, src_hash); } else { len += Utils::encryptThenMAC(secret, &packet->payload[len], data, data_len); diff --git a/src/Mesh.h b/src/Mesh.h index 627ae34eb..11eab1359 100644 --- a/src/Mesh.h +++ b/src/Mesh.h @@ -84,6 +84,7 @@ class Mesh : public Dispatcher { virtual void getPeerSharedSecret(uint8_t* dest_secret, int peer_idx) { } virtual uint8_t getPeerFlags(int peer_idx) { return 0; } virtual uint16_t getPeerNextAeadNonce(int peer_idx) { return 0; } + virtual void onPeerAeadDetected(int peer_idx) { } /** * \brief A (now decrypted) data packet has been received (by a known peer). From 27f0c2be72ca3a90ed2afe60caf3110859289da6 Mon Sep 17 00:00:00 2001 From: Wessel Nieboer Date: Fri, 13 Feb 2026 00:04:49 +0100 Subject: [PATCH 05/11] Support AEAD responses in repeater/room/sensor --- examples/simple_repeater/MyMesh.cpp | 31 ++++++++++++++++++++--- examples/simple_repeater/MyMesh.h | 3 +++ examples/simple_room_server/MyMesh.cpp | 33 +++++++++++++++++++++--- examples/simple_room_server/MyMesh.h | 3 +++ examples/simple_sensor/SensorMesh.cpp | 35 ++++++++++++++++++++++---- examples/simple_sensor/SensorMesh.h | 3 +++ src/helpers/ClientACL.h | 9 +++++++ 7 files changed, 105 insertions(+), 12 deletions(-) diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index 65e0cee52..a66f9f905 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -565,6 +565,31 @@ void MyMesh::getPeerSharedSecret(uint8_t *dest_secret, int peer_idx) { } } +uint8_t MyMesh::getPeerFlags(int peer_idx) { + int i = matching_peer_indexes[peer_idx]; + if (i >= 0 && i < acl.getNumClients()) + return acl.getClientByIdx(i)->flags; + return 0; +} + +uint16_t MyMesh::getPeerNextAeadNonce(int peer_idx) { + int i = matching_peer_indexes[peer_idx]; + if (i >= 0 && i < acl.getNumClients()) + return acl.getClientByIdx(i)->nextAeadNonce(); + return 0; +} + +void MyMesh::onPeerAeadDetected(int peer_idx) { + int i = matching_peer_indexes[peer_idx]; + if (i >= 0 && i < acl.getNumClients()) { + auto c = acl.getClientByIdx(i); + if (!(c->flags & CONTACT_FLAG_AEAD)) { + c->flags |= CONTACT_FLAG_AEAD; + getRNG()->random((uint8_t*)&c->aead_nonce, sizeof(c->aead_nonce)); + } + } +} + static bool isShare(const mesh::Packet *packet) { if (packet->hasTransportCodes()) { return packet->transport_codes[0] == 0 && packet->transport_codes[1] == 0; // codes { 0, 0 } means 'send to nowhere' @@ -608,11 +633,11 @@ void MyMesh::onPeerDataRecv(mesh::Packet *packet, uint8_t type, int sender_idx, if (packet->isRouteFlood()) { // let this sender know path TO here, so they can use sendDirect(), and ALSO encode the response mesh::Packet *path = createPathReturn(client->id, secret, packet->path, packet->path_len, - PAYLOAD_TYPE_RESPONSE, reply_data, reply_len); + PAYLOAD_TYPE_RESPONSE, reply_data, reply_len, client->nextAeadNonce()); if (path) sendFlood(path, SERVER_RESPONSE_DELAY); } else { mesh::Packet *reply = - createDatagram(PAYLOAD_TYPE_RESPONSE, client->id, secret, reply_data, reply_len); + createDatagram(PAYLOAD_TYPE_RESPONSE, client->id, secret, reply_data, reply_len, client->nextAeadNonce()); if (reply) { if (client->out_path_len >= 0) { // we have an out_path, so send DIRECT sendDirect(reply, client->out_path, client->out_path_len, SERVER_RESPONSE_DELAY); @@ -673,7 +698,7 @@ void MyMesh::onPeerDataRecv(mesh::Packet *packet, uint8_t type, int sender_idx, memcpy(temp, ×tamp, 4); // mostly an extra blob to help make packet_hash unique temp[4] = (TXT_TYPE_CLI_DATA << 2); // NOTE: legacy was: TXT_TYPE_PLAIN - auto reply = createDatagram(PAYLOAD_TYPE_TXT_MSG, client->id, secret, temp, 5 + text_len); + auto reply = createDatagram(PAYLOAD_TYPE_TXT_MSG, client->id, secret, temp, 5 + text_len, client->nextAeadNonce()); if (reply) { if (client->out_path_len < 0) { sendFlood(reply, CLI_REPLY_DELAY_MILLIS); diff --git a/examples/simple_repeater/MyMesh.h b/examples/simple_repeater/MyMesh.h index 7a51b4a97..0dc33b918 100644 --- a/examples/simple_repeater/MyMesh.h +++ b/examples/simple_repeater/MyMesh.h @@ -163,6 +163,9 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks { void onAnonDataRecv(mesh::Packet* packet, const uint8_t* secret, const mesh::Identity& sender, uint8_t* data, size_t len) override; int searchPeersByHash(const uint8_t* hash) override; void getPeerSharedSecret(uint8_t* dest_secret, int peer_idx) override; + uint8_t getPeerFlags(int peer_idx) override; + uint16_t getPeerNextAeadNonce(int peer_idx) override; + void onPeerAeadDetected(int peer_idx) override; void onAdvertRecv(mesh::Packet* packet, const mesh::Identity& id, uint32_t timestamp, const uint8_t* app_data, size_t app_data_len); void onPeerDataRecv(mesh::Packet* packet, uint8_t type, int sender_idx, const uint8_t* secret, uint8_t* data, size_t len) override; bool onPeerPathRecv(mesh::Packet* packet, int sender_idx, const uint8_t* secret, uint8_t* path, uint8_t path_len, uint8_t extra_type, uint8_t* extra, uint8_t extra_len) override; diff --git a/examples/simple_room_server/MyMesh.cpp b/examples/simple_room_server/MyMesh.cpp index 598b14de6..257933e7f 100644 --- a/examples/simple_room_server/MyMesh.cpp +++ b/examples/simple_room_server/MyMesh.cpp @@ -71,7 +71,7 @@ void MyMesh::pushPostToClient(ClientInfo *client, PostInfo &post) { mesh::Utils::sha256((uint8_t *)&client->extra.room.pending_ack, 4, reply_data, len, client->id.pub_key, PUB_KEY_SIZE); client->extra.room.push_post_timestamp = post.post_timestamp; - auto reply = createDatagram(PAYLOAD_TYPE_TXT_MSG, client->id, client->shared_secret, reply_data, len); + auto reply = createDatagram(PAYLOAD_TYPE_TXT_MSG, client->id, client->shared_secret, reply_data, len, client->nextAeadNonce()); if (reply) { if (client->out_path_len < 0) { sendFlood(reply); @@ -387,6 +387,31 @@ void MyMesh::getPeerSharedSecret(uint8_t *dest_secret, int peer_idx) { } } +uint8_t MyMesh::getPeerFlags(int peer_idx) { + int i = matching_peer_indexes[peer_idx]; + if (i >= 0 && i < acl.getNumClients()) + return acl.getClientByIdx(i)->flags; + return 0; +} + +uint16_t MyMesh::getPeerNextAeadNonce(int peer_idx) { + int i = matching_peer_indexes[peer_idx]; + if (i >= 0 && i < acl.getNumClients()) + return acl.getClientByIdx(i)->nextAeadNonce(); + return 0; +} + +void MyMesh::onPeerAeadDetected(int peer_idx) { + int i = matching_peer_indexes[peer_idx]; + if (i >= 0 && i < acl.getNumClients()) { + auto c = acl.getClientByIdx(i); + if (!(c->flags & CONTACT_FLAG_AEAD)) { + c->flags |= CONTACT_FLAG_AEAD; + getRNG()->random((uint8_t*)&c->aead_nonce, sizeof(c->aead_nonce)); + } + } +} + void MyMesh::onPeerDataRecv(mesh::Packet *packet, uint8_t type, int sender_idx, const uint8_t *secret, uint8_t *data, size_t len) { int i = matching_peer_indexes[sender_idx]; @@ -480,7 +505,7 @@ void MyMesh::onPeerDataRecv(mesh::Packet *packet, uint8_t type, int sender_idx, // mesh::Utils::sha256((uint8_t *)&expected_ack_crc, 4, temp, 5 + text_len, self_id.pub_key, // PUB_KEY_SIZE); - auto reply = createDatagram(PAYLOAD_TYPE_TXT_MSG, client->id, secret, temp, 5 + text_len); + auto reply = createDatagram(PAYLOAD_TYPE_TXT_MSG, client->id, secret, temp, 5 + text_len, client->nextAeadNonce()); if (reply) { if (client->out_path_len < 0) { sendFlood(reply, delay_millis + SERVER_RESPONSE_DELAY); @@ -537,10 +562,10 @@ void MyMesh::onPeerDataRecv(mesh::Packet *packet, uint8_t type, int sender_idx, if (packet->isRouteFlood()) { // let this sender know path TO here, so they can use sendDirect(), and ALSO encode the response mesh::Packet *path = createPathReturn(client->id, secret, packet->path, packet->path_len, - PAYLOAD_TYPE_RESPONSE, reply_data, reply_len); + PAYLOAD_TYPE_RESPONSE, reply_data, reply_len, client->nextAeadNonce()); if (path) sendFlood(path, SERVER_RESPONSE_DELAY); } else { - mesh::Packet *reply = createDatagram(PAYLOAD_TYPE_RESPONSE, client->id, secret, reply_data, reply_len); + mesh::Packet *reply = createDatagram(PAYLOAD_TYPE_RESPONSE, client->id, secret, reply_data, reply_len, client->nextAeadNonce()); if (reply) { if (client->out_path_len >= 0) { // we have an out_path, so send DIRECT sendDirect(reply, client->out_path, client->out_path_len, SERVER_RESPONSE_DELAY); diff --git a/examples/simple_room_server/MyMesh.h b/examples/simple_room_server/MyMesh.h index b4529e776..92c8c4a3e 100644 --- a/examples/simple_room_server/MyMesh.h +++ b/examples/simple_room_server/MyMesh.h @@ -148,6 +148,9 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks { void onAnonDataRecv(mesh::Packet* packet, const uint8_t* secret, const mesh::Identity& sender, uint8_t* data, size_t len) override; int searchPeersByHash(const uint8_t* hash) override ; void getPeerSharedSecret(uint8_t* dest_secret, int peer_idx) override; + uint8_t getPeerFlags(int peer_idx) override; + uint16_t getPeerNextAeadNonce(int peer_idx) override; + void onPeerAeadDetected(int peer_idx) override; void onPeerDataRecv(mesh::Packet* packet, uint8_t type, int sender_idx, const uint8_t* secret, uint8_t* data, size_t len) override; bool onPeerPathRecv(mesh::Packet* packet, int sender_idx, const uint8_t* secret, uint8_t* path, uint8_t path_len, uint8_t extra_type, uint8_t* extra, uint8_t extra_len) override; void onAckRecv(mesh::Packet* packet, uint32_t ack_crc) override; diff --git a/examples/simple_sensor/SensorMesh.cpp b/examples/simple_sensor/SensorMesh.cpp index f05fb245c..6741ba5ea 100644 --- a/examples/simple_sensor/SensorMesh.cpp +++ b/examples/simple_sensor/SensorMesh.cpp @@ -256,7 +256,7 @@ void SensorMesh::sendAlert(const ClientInfo* c, Trigger* t) { mesh::Utils::sha256((uint8_t *)&t->expected_acks[t->attempt], 4, data, 5 + text_len, self_id.pub_key, PUB_KEY_SIZE); t->attempt++; - auto pkt = createDatagram(PAYLOAD_TYPE_TXT_MSG, c->id, c->shared_secret, data, 5 + text_len); + auto pkt = createDatagram(PAYLOAD_TYPE_TXT_MSG, c->id, c->shared_secret, data, 5 + text_len, c->nextAeadNonce()); if (pkt) { if (c->out_path_len >= 0) { // we have an out_path, so send DIRECT sendDirect(pkt, c->out_path, c->out_path_len); @@ -496,6 +496,31 @@ void SensorMesh::getPeerSharedSecret(uint8_t* dest_secret, int peer_idx) { } } +uint8_t SensorMesh::getPeerFlags(int peer_idx) { + int i = matching_peer_indexes[peer_idx]; + if (i >= 0 && i < acl.getNumClients()) + return acl.getClientByIdx(i)->flags; + return 0; +} + +uint16_t SensorMesh::getPeerNextAeadNonce(int peer_idx) { + int i = matching_peer_indexes[peer_idx]; + if (i >= 0 && i < acl.getNumClients()) + return acl.getClientByIdx(i)->nextAeadNonce(); + return 0; +} + +void SensorMesh::onPeerAeadDetected(int peer_idx) { + int i = matching_peer_indexes[peer_idx]; + if (i >= 0 && i < acl.getNumClients()) { + auto c = acl.getClientByIdx(i); + if (!(c->flags & CONTACT_FLAG_AEAD)) { + c->flags |= CONTACT_FLAG_AEAD; + getRNG()->random((uint8_t*)&c->aead_nonce, sizeof(c->aead_nonce)); + } + } +} + void SensorMesh::sendAckTo(const ClientInfo& dest, uint32_t ack_hash) { if (dest.out_path_len < 0) { mesh::Packet* ack = createAck(ack_hash); @@ -536,10 +561,10 @@ void SensorMesh::onPeerDataRecv(mesh::Packet* packet, uint8_t type, int sender_i if (packet->isRouteFlood()) { // let this sender know path TO here, so they can use sendDirect(), and ALSO encode the response mesh::Packet* path = createPathReturn(from->id, secret, packet->path, packet->path_len, - PAYLOAD_TYPE_RESPONSE, reply_data, reply_len); + PAYLOAD_TYPE_RESPONSE, reply_data, reply_len, from->nextAeadNonce()); if (path) sendFlood(path, SERVER_RESPONSE_DELAY); } else { - mesh::Packet* reply = createDatagram(PAYLOAD_TYPE_RESPONSE, from->id, secret, reply_data, reply_len); + mesh::Packet* reply = createDatagram(PAYLOAD_TYPE_RESPONSE, from->id, secret, reply_data, reply_len, from->nextAeadNonce()); if (reply) { if (from->out_path_len >= 0) { // we have an out_path, so send DIRECT sendDirect(reply, from->out_path, from->out_path_len, SERVER_RESPONSE_DELAY); @@ -566,7 +591,7 @@ void SensorMesh::onPeerDataRecv(mesh::Packet* packet, uint8_t type, int sender_i if (packet->isRouteFlood()) { // let this sender know path TO here, so they can use sendDirect(), and ALSO encode the ACK mesh::Packet* path = createPathReturn(from->id, secret, packet->path, packet->path_len, - PAYLOAD_TYPE_ACK, (uint8_t *) &ack_hash, 4); + PAYLOAD_TYPE_ACK, (uint8_t *) &ack_hash, 4, from->nextAeadNonce()); if (path) sendFlood(path, TXT_ACK_DELAY); } else { sendAckTo(*from, ack_hash); @@ -594,7 +619,7 @@ void SensorMesh::onPeerDataRecv(mesh::Packet* packet, uint8_t type, int sender_i memcpy(temp, ×tamp, 4); // mostly an extra blob to help make packet_hash unique temp[4] = (TXT_TYPE_CLI_DATA << 2); - auto reply = createDatagram(PAYLOAD_TYPE_TXT_MSG, from->id, secret, temp, 5 + text_len); + auto reply = createDatagram(PAYLOAD_TYPE_TXT_MSG, from->id, secret, temp, 5 + text_len, from->nextAeadNonce()); if (reply) { if (from->out_path_len < 0) { sendFlood(reply, CLI_REPLY_DELAY_MILLIS); diff --git a/examples/simple_sensor/SensorMesh.h b/examples/simple_sensor/SensorMesh.h index 4bc0d784e..69ee38666 100644 --- a/examples/simple_sensor/SensorMesh.h +++ b/examples/simple_sensor/SensorMesh.h @@ -123,6 +123,9 @@ class SensorMesh : public mesh::Mesh, public CommonCLICallbacks { void onAnonDataRecv(mesh::Packet* packet, const uint8_t* secret, const mesh::Identity& sender, uint8_t* data, size_t len) override; int searchPeersByHash(const uint8_t* hash) override; void getPeerSharedSecret(uint8_t* dest_secret, int peer_idx) override; + uint8_t getPeerFlags(int peer_idx) override; + uint16_t getPeerNextAeadNonce(int peer_idx) override; + void onPeerAeadDetected(int peer_idx) override; void onPeerDataRecv(mesh::Packet* packet, uint8_t type, int sender_idx, const uint8_t* secret, uint8_t* data, size_t len) override; bool onPeerPathRecv(mesh::Packet* packet, int sender_idx, const uint8_t* secret, uint8_t* path, uint8_t path_len, uint8_t extra_type, uint8_t* extra, uint8_t extra_len) override; void onControlDataRecv(mesh::Packet* packet) override; diff --git a/src/helpers/ClientACL.h b/src/helpers/ClientACL.h index dfbc3fce1..c1f3afad8 100644 --- a/src/helpers/ClientACL.h +++ b/src/helpers/ClientACL.h @@ -13,6 +13,8 @@ struct ClientInfo { mesh::Identity id; uint8_t permissions; + uint8_t flags; // transient — includes CONTACT_FLAG_AEAD + mutable uint16_t aead_nonce; // transient — per-peer nonce counter int8_t out_path_len; uint8_t out_path[MAX_PATH_SIZE]; uint8_t shared_secret[PUB_KEY_SIZE]; @@ -28,6 +30,13 @@ struct ClientInfo { } room; } extra; + uint16_t nextAeadNonce() const { + if (flags & CONTACT_FLAG_AEAD) { + if (++aead_nonce == 0) ++aead_nonce; // skip 0 (means ECB) + return aead_nonce; + } + return 0; + } bool isAdmin() const { return (permissions & PERM_ACL_ROLE_MASK) == PERM_ACL_ADMIN; } }; From dc751e7e6248be13da1a247a49d2cffbc7959063 Mon Sep 17 00:00:00 2001 From: Wessel Nieboer Date: Fri, 13 Feb 2026 00:26:42 +0100 Subject: [PATCH 06/11] Harden AEAD-4 bounds checks and add nonce wrap logging - Fix potential unsigned overflow in createDatagram size check by subtracting constants from MAX_PACKET_PAYLOAD instead of adding to data_len - Add upper-bound validation on src_len and assoc_len in aeadEncrypt and aeadDecrypt - Log peer name on AEAD nonce wraparound for debug builds --- src/Mesh.cpp | 3 ++- src/Utils.cpp | 6 ++++-- src/helpers/ContactInfo.h | 5 ++++- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/src/Mesh.cpp b/src/Mesh.cpp index 281726957..d8b3b93b1 100644 --- a/src/Mesh.cpp +++ b/src/Mesh.cpp @@ -521,7 +521,8 @@ Packet* Mesh::createDatagram(uint8_t type, const Identity& dest, const uint8_t* if (type == PAYLOAD_TYPE_TXT_MSG || type == PAYLOAD_TYPE_REQ || type == PAYLOAD_TYPE_RESPONSE) { size_t hash_prefix = PATH_HASH_SIZE * 2; // dest_hash + src_hash size_t max_overhead = aead_nonce ? (AEAD_NONCE_SIZE + AEAD_TAG_SIZE) : (CIPHER_MAC_SIZE + CIPHER_BLOCK_SIZE-1); - if (data_len + hash_prefix + max_overhead > MAX_PACKET_PAYLOAD) return NULL; + size_t max_payload_data = MAX_PACKET_PAYLOAD - hash_prefix - max_overhead; + if (data_len > max_payload_data) return NULL; } else { return NULL; // invalid type } diff --git a/src/Utils.cpp b/src/Utils.cpp index a0c98b880..82882b2c4 100644 --- a/src/Utils.cpp +++ b/src/Utils.cpp @@ -122,7 +122,8 @@ int Utils::aeadEncrypt(const uint8_t* shared_secret, const uint8_t* assoc_data, int assoc_len, uint16_t nonce_counter, uint8_t dest_hash, uint8_t src_hash) { - if (src_len <= 0) return 0; + if (src_len <= 0 || src_len > MAX_PACKET_PAYLOAD) return 0; + if (assoc_len < 0 || assoc_len > MAX_PACKET_PAYLOAD) return 0; // Write nonce to output dest[0] = (uint8_t)(nonce_counter >> 8); @@ -166,7 +167,8 @@ int Utils::aeadDecrypt(const uint8_t* shared_secret, const uint8_t* assoc_data, int assoc_len, uint8_t dest_hash, uint8_t src_hash) { // Minimum: nonce(2) + at least 1 byte ciphertext + tag(4) - if (src_len < AEAD_NONCE_SIZE + 1 + AEAD_TAG_SIZE) return 0; + if (src_len < AEAD_NONCE_SIZE + 1 + AEAD_TAG_SIZE || src_len > MAX_PACKET_PAYLOAD) return 0; + if (assoc_len < 0 || assoc_len > MAX_PACKET_PAYLOAD) return 0; int ct_len = src_len - AEAD_NONCE_SIZE - AEAD_TAG_SIZE; diff --git a/src/helpers/ContactInfo.h b/src/helpers/ContactInfo.h index 7372f5a14..3d20ff7a1 100644 --- a/src/helpers/ContactInfo.h +++ b/src/helpers/ContactInfo.h @@ -21,7 +21,10 @@ struct ContactInfo { // When 0, callers use ECB encryption. uint16_t nextAeadNonce() const { if (flags & CONTACT_FLAG_AEAD) { - if (++aead_nonce == 0) ++aead_nonce; // skip 0 (sentinel for ECB) + if (++aead_nonce == 0) { + ++aead_nonce; // skip 0 (sentinel for ECB) + MESH_DEBUG_PRINTLN("AEAD nonce wrapped for peer: %s", name); + } return aead_nonce; } return 0; From 11f6abbc22453d0b96574ae1b13364ee90835876 Mon Sep 17 00:00:00 2001 From: Wessel Nieboer Date: Fri, 13 Feb 2026 00:38:26 +0100 Subject: [PATCH 07/11] Add comment about ECB path failure --- src/Utils.cpp | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/Utils.cpp b/src/Utils.cpp index 82882b2c4..d7b1b324a 100644 --- a/src/Utils.cpp +++ b/src/Utils.cpp @@ -85,7 +85,9 @@ int Utils::MACThenDecrypt(const uint8_t* shared_secret, uint8_t* dest, const uin if (memcmp(hmac, src, CIPHER_MAC_SIZE) == 0) { return decrypt(shared_secret, dest, src + CIPHER_MAC_SIZE, src_len - CIPHER_MAC_SIZE); } - return 0; // invalid HMAC + // No need to zero dest on failure — MAC is checked before decryption, + // so dest is never written to when authentication fails. + return 0; } /* From ebe2a53cfa20a32161ba77eebb9bdae95e58975d Mon Sep 17 00:00:00 2001 From: Wessel Nieboer Date: Fri, 13 Feb 2026 15:26:30 +0100 Subject: [PATCH 08/11] Persist AEAD-4 nonces to flash across reboots Prevent nonce reuse after reboots by persisting per-peer nonce counters to a dedicated /nonces (companion) or /s_nonces (server) file. On dirty reset (power-on, watchdog, brownout), nonces are bumped by NONCE_BOOT_BUMP (100) to cover any unpersisted messages. Clean wakes (deep sleep, software restart) load nonces as-is. - Add nonce persistence to BaseChatMesh (companion) and ClientACL (server) - Add wasDirtyReset() helper to ArduinoHelpers.h for platform-specific reset reason detection (ESP32/NRF52) - Add onBeforeReboot() callback to CommonCLI for pre-reboot nonce flush - Wire nonce persistence into all firmware variants: companion radio, repeater, room server, and sensor - Only clear dirty flag on successful file write --- examples/companion_radio/DataStore.cpp | 30 ++++++++++ examples/companion_radio/DataStore.h | 4 ++ examples/companion_radio/MyMesh.cpp | 19 +++++++ examples/companion_radio/MyMesh.h | 4 ++ examples/simple_repeater/MyMesh.cpp | 26 +++++++-- examples/simple_repeater/MyMesh.h | 4 ++ examples/simple_room_server/MyMesh.cpp | 28 +++++++-- examples/simple_room_server/MyMesh.h | 4 ++ examples/simple_sensor/SensorMesh.cpp | 30 +++++++--- examples/simple_sensor/SensorMesh.h | 4 ++ src/MeshCore.h | 4 ++ src/helpers/ArduinoHelpers.h | 15 +++++ src/helpers/BaseChatMesh.cpp | 78 +++++++++++++++++++++----- src/helpers/BaseChatMesh.h | 17 ++++++ src/helpers/ClientACL.cpp | 76 +++++++++++++++++++++++++ src/helpers/ClientACL.h | 24 +++++++- src/helpers/CommonCLI.cpp | 2 + src/helpers/CommonCLI.h | 4 ++ 18 files changed, 339 insertions(+), 34 deletions(-) diff --git a/examples/companion_radio/DataStore.cpp b/examples/companion_radio/DataStore.cpp index c0f2c0212..141ea54d9 100644 --- a/examples/companion_radio/DataStore.cpp +++ b/examples/companion_radio/DataStore.cpp @@ -373,6 +373,36 @@ void DataStore::saveChannels(DataStoreHost* host) { } } +void DataStore::loadNonces(DataStoreHost* host) { + File file = openRead(_getContactsChannelsFS(), "/nonces"); + if (file) { + uint8_t rec[6]; // 4-byte pub_key prefix + 2-byte nonce + while (file.read(rec, 6) == 6) { + uint16_t nonce; + memcpy(&nonce, &rec[4], 2); + host->onNonceLoaded(rec, nonce); + } + file.close(); + } +} + +bool DataStore::saveNonces(DataStoreHost* host) { + File file = openWrite(_getContactsChannelsFS(), "/nonces"); + if (file) { + int idx = 0; + uint8_t pub_key_prefix[4]; + uint16_t nonce; + while (host->getNonceForSave(idx, pub_key_prefix, &nonce)) { + file.write(pub_key_prefix, 4); + file.write((uint8_t*)&nonce, 2); + idx++; + } + file.close(); + return true; + } + return false; +} + #if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) #define MAX_ADVERT_PKT_LEN (2 + 32 + PUB_KEY_SIZE + 4 + SIGNATURE_SIZE + MAX_ADVERT_DATA_SIZE) diff --git a/examples/companion_radio/DataStore.h b/examples/companion_radio/DataStore.h index 58b4d5d28..d2ec9167e 100644 --- a/examples/companion_radio/DataStore.h +++ b/examples/companion_radio/DataStore.h @@ -11,6 +11,8 @@ class DataStoreHost { virtual bool getContactForSave(uint32_t idx, ContactInfo& contact) =0; virtual bool onChannelLoaded(uint8_t channel_idx, const ChannelDetails& ch) =0; virtual bool getChannelForSave(uint8_t channel_idx, ChannelDetails& ch) =0; + virtual bool onNonceLoaded(const uint8_t* pub_key_prefix, uint16_t nonce) { return false; } + virtual bool getNonceForSave(int idx, uint8_t* pub_key_prefix, uint16_t* nonce) { return false; } }; class DataStore { @@ -39,6 +41,8 @@ class DataStore { void saveContacts(DataStoreHost* host); void loadChannels(DataStoreHost* host); void saveChannels(DataStoreHost* host); + void loadNonces(DataStoreHost* host); + bool saveNonces(DataStoreHost* host); void migrateToSecondaryFS(); uint8_t getBlobByKey(const uint8_t key[], int key_len, uint8_t dest_buf[]); bool putBlobByKey(const uint8_t key[], int key_len, const uint8_t src_buf[], uint8_t len); diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index a9ac1cf0a..b231d824a 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -788,6 +788,7 @@ MyMesh::MyMesh(mesh::Radio &radio, mesh::RNG &rng, mesh::RTCClock &rtc, SimpleMe next_ack_idx = 0; sign_data = NULL; dirty_contacts_expiry = 0; + next_nonce_persist = 0; memset(advert_paths, 0, sizeof(advert_paths)); memset(send_scope.key, 0, sizeof(send_scope.key)); @@ -864,6 +865,14 @@ void MyMesh::begin(bool has_display) { resetContacts(); _store->loadContacts(this); bootstrapRTCfromContacts(); + + // Load persisted AEAD nonces and apply boot bump if needed + _store->loadNonces(this); + bool dirty_reset = wasDirtyReset(board); + finalizeNonceLoad(dirty_reset); + if (dirty_reset) saveNonces(); // persist bumped nonces immediately + next_nonce_persist = futureMillis(60000); + addChannel("Public", PUBLIC_GROUP_PSK); // pre-configure Andy's public channel _store->loadChannels(this); @@ -1275,6 +1284,7 @@ void MyMesh::handleCmdFrame(size_t len) { if (dirty_contacts_expiry) { // is there are pending dirty contacts write needed? saveContacts(); } + if (isNonceDirty()) saveNonces(); board.reboot(); } else if (cmd_frame[0] == CMD_GET_BATT_AND_STORAGE) { uint8_t reply[11]; @@ -1916,6 +1926,7 @@ void MyMesh::checkCLIRescueCmd() { } } else if (strcmp(cli_command, "reboot") == 0) { + if (isNonceDirty()) saveNonces(); board.reboot(); // doesn't return } else { Serial.println(" Error: unknown command"); @@ -1967,6 +1978,14 @@ void MyMesh::loop() { dirty_contacts_expiry = 0; } + // periodic AEAD nonce persistence + if (next_nonce_persist && millisHasNowPassed(next_nonce_persist)) { + if (isNonceDirty()) { + saveNonces(); + } + next_nonce_persist = futureMillis(60000); + } + #ifdef DISPLAY_CLASS if (_ui) _ui->setHasConnection(_serial->isConnected()); #endif diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index 95265a19a..e80e94028 100644 --- a/examples/companion_radio/MyMesh.h +++ b/examples/companion_radio/MyMesh.h @@ -151,6 +151,8 @@ class MyMesh : public BaseChatMesh, public DataStoreHost { bool getContactForSave(uint32_t idx, ContactInfo& contact) override { return getContactByIdx(idx, contact); } bool onChannelLoaded(uint8_t channel_idx, const ChannelDetails& ch) override { return setChannel(channel_idx, ch); } bool getChannelForSave(uint8_t channel_idx, ChannelDetails& ch) override { return getChannel(channel_idx, ch); } + bool onNonceLoaded(const uint8_t* pub_key_prefix, uint16_t nonce) override { return applyLoadedNonce(pub_key_prefix, nonce); } + bool getNonceForSave(int idx, uint8_t* pub_key_prefix, uint16_t* nonce) override { return getNonceEntry(idx, pub_key_prefix, nonce); } void clearPendingReqs() { pending_login = pending_status = pending_telemetry = pending_discovery = pending_req = 0; @@ -180,6 +182,7 @@ class MyMesh : public BaseChatMesh, public DataStoreHost { // helpers, short-cuts void saveChannels() { _store->saveChannels(this); } void saveContacts() { _store->saveContacts(this); } + void saveNonces() { if (_store->saveNonces(this)) clearNonceDirty(); } DataStore* _store; NodePrefs _prefs; @@ -201,6 +204,7 @@ class MyMesh : public BaseChatMesh, public DataStoreHost { uint8_t *sign_data; uint32_t sign_data_len; unsigned long dirty_contacts_expiry; + unsigned long next_nonce_persist; TransportKey send_scope; diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index a66f9f905..699312546 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -575,7 +575,7 @@ uint8_t MyMesh::getPeerFlags(int peer_idx) { uint16_t MyMesh::getPeerNextAeadNonce(int peer_idx) { int i = matching_peer_indexes[peer_idx]; if (i >= 0 && i < acl.getNumClients()) - return acl.getClientByIdx(i)->nextAeadNonce(); + return acl.nextAeadNonceFor(*acl.getClientByIdx(i)); return 0; } @@ -585,7 +585,10 @@ void MyMesh::onPeerAeadDetected(int peer_idx) { auto c = acl.getClientByIdx(i); if (!(c->flags & CONTACT_FLAG_AEAD)) { c->flags |= CONTACT_FLAG_AEAD; - getRNG()->random((uint8_t*)&c->aead_nonce, sizeof(c->aead_nonce)); + if (c->aead_nonce == 0) { // no persisted nonce — seed from RNG to avoid deterministic start + getRNG()->random((uint8_t*)&c->aead_nonce, sizeof(c->aead_nonce)); + if (c->aead_nonce == 0) c->aead_nonce = 1; + } } } } @@ -633,11 +636,11 @@ void MyMesh::onPeerDataRecv(mesh::Packet *packet, uint8_t type, int sender_idx, if (packet->isRouteFlood()) { // let this sender know path TO here, so they can use sendDirect(), and ALSO encode the response mesh::Packet *path = createPathReturn(client->id, secret, packet->path, packet->path_len, - PAYLOAD_TYPE_RESPONSE, reply_data, reply_len, client->nextAeadNonce()); + PAYLOAD_TYPE_RESPONSE, reply_data, reply_len, acl.nextAeadNonceFor(*client)); if (path) sendFlood(path, SERVER_RESPONSE_DELAY); } else { mesh::Packet *reply = - createDatagram(PAYLOAD_TYPE_RESPONSE, client->id, secret, reply_data, reply_len, client->nextAeadNonce()); + createDatagram(PAYLOAD_TYPE_RESPONSE, client->id, secret, reply_data, reply_len, acl.nextAeadNonceFor(*client)); if (reply) { if (client->out_path_len >= 0) { // we have an out_path, so send DIRECT sendDirect(reply, client->out_path, client->out_path_len, SERVER_RESPONSE_DELAY); @@ -698,7 +701,7 @@ void MyMesh::onPeerDataRecv(mesh::Packet *packet, uint8_t type, int sender_idx, memcpy(temp, ×tamp, 4); // mostly an extra blob to help make packet_hash unique temp[4] = (TXT_TYPE_CLI_DATA << 2); // NOTE: legacy was: TXT_TYPE_PLAIN - auto reply = createDatagram(PAYLOAD_TYPE_TXT_MSG, client->id, secret, temp, 5 + text_len, client->nextAeadNonce()); + auto reply = createDatagram(PAYLOAD_TYPE_TXT_MSG, client->id, secret, temp, 5 + text_len, acl.nextAeadNonceFor(*client)); if (reply) { if (client->out_path_len < 0) { sendFlood(reply, CLI_REPLY_DELAY_MILLIS); @@ -783,6 +786,7 @@ MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondCloc uptime_millis = 0; next_local_advert = next_flood_advert = 0; dirty_contacts_expiry = 0; + next_nonce_persist = 0; set_radio_at = revert_radio_at = 0; _logging = false; region_load_active = false; @@ -834,6 +838,12 @@ void MyMesh::begin(FILESYSTEM *fs) { // load persisted prefs _cli.loadPrefs(_fs); acl.load(_fs, self_id); + acl.setRNG(getRNG()); + acl.loadNonces(); + bool dirty_reset = wasDirtyReset(board); + acl.finalizeNonceLoad(dirty_reset); + if (dirty_reset) acl.saveNonces(); // persist bumped nonces immediately + next_nonce_persist = futureMillis(60000); // TODO: key_store.begin(); region_map.load(_fs); @@ -1236,6 +1246,12 @@ void MyMesh::loop() { dirty_contacts_expiry = 0; } + // persist dirty AEAD nonces + if (next_nonce_persist && millisHasNowPassed(next_nonce_persist)) { + if (acl.isNonceDirty()) { acl.saveNonces(); } + next_nonce_persist = futureMillis(60000); + } + // update uptime uint32_t now = millis(); uptime_millis += now - last_millis; diff --git a/examples/simple_repeater/MyMesh.h b/examples/simple_repeater/MyMesh.h index 0dc33b918..e57c38f3e 100644 --- a/examples/simple_repeater/MyMesh.h +++ b/examples/simple_repeater/MyMesh.h @@ -99,6 +99,7 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks { RateLimiter discover_limiter, anon_limiter; bool region_load_active; unsigned long dirty_contacts_expiry; + unsigned long next_nonce_persist; #if MAX_NEIGHBOURS NeighbourInfo neighbours[MAX_NEIGHBOURS]; #endif @@ -187,6 +188,9 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks { void savePrefs() override { _cli.savePrefs(_fs); } + void onBeforeReboot() override { + if (acl.isNonceDirty()) acl.saveNonces(); + } void applyTempRadioParams(float freq, float bw, uint8_t sf, uint8_t cr, int timeout_mins) override; bool formatFileSystem() override; diff --git a/examples/simple_room_server/MyMesh.cpp b/examples/simple_room_server/MyMesh.cpp index 257933e7f..46bf747a9 100644 --- a/examples/simple_room_server/MyMesh.cpp +++ b/examples/simple_room_server/MyMesh.cpp @@ -71,7 +71,7 @@ void MyMesh::pushPostToClient(ClientInfo *client, PostInfo &post) { mesh::Utils::sha256((uint8_t *)&client->extra.room.pending_ack, 4, reply_data, len, client->id.pub_key, PUB_KEY_SIZE); client->extra.room.push_post_timestamp = post.post_timestamp; - auto reply = createDatagram(PAYLOAD_TYPE_TXT_MSG, client->id, client->shared_secret, reply_data, len, client->nextAeadNonce()); + auto reply = createDatagram(PAYLOAD_TYPE_TXT_MSG, client->id, client->shared_secret, reply_data, len, acl.nextAeadNonceFor(*client)); if (reply) { if (client->out_path_len < 0) { sendFlood(reply); @@ -397,7 +397,7 @@ uint8_t MyMesh::getPeerFlags(int peer_idx) { uint16_t MyMesh::getPeerNextAeadNonce(int peer_idx) { int i = matching_peer_indexes[peer_idx]; if (i >= 0 && i < acl.getNumClients()) - return acl.getClientByIdx(i)->nextAeadNonce(); + return acl.nextAeadNonceFor(*acl.getClientByIdx(i)); return 0; } @@ -407,7 +407,10 @@ void MyMesh::onPeerAeadDetected(int peer_idx) { auto c = acl.getClientByIdx(i); if (!(c->flags & CONTACT_FLAG_AEAD)) { c->flags |= CONTACT_FLAG_AEAD; - getRNG()->random((uint8_t*)&c->aead_nonce, sizeof(c->aead_nonce)); + if (c->aead_nonce == 0) { // no persisted nonce — seed from RNG to avoid deterministic start + getRNG()->random((uint8_t*)&c->aead_nonce, sizeof(c->aead_nonce)); + if (c->aead_nonce == 0) c->aead_nonce = 1; + } } } } @@ -505,7 +508,7 @@ void MyMesh::onPeerDataRecv(mesh::Packet *packet, uint8_t type, int sender_idx, // mesh::Utils::sha256((uint8_t *)&expected_ack_crc, 4, temp, 5 + text_len, self_id.pub_key, // PUB_KEY_SIZE); - auto reply = createDatagram(PAYLOAD_TYPE_TXT_MSG, client->id, secret, temp, 5 + text_len, client->nextAeadNonce()); + auto reply = createDatagram(PAYLOAD_TYPE_TXT_MSG, client->id, secret, temp, 5 + text_len, acl.nextAeadNonceFor(*client)); if (reply) { if (client->out_path_len < 0) { sendFlood(reply, delay_millis + SERVER_RESPONSE_DELAY); @@ -562,10 +565,10 @@ void MyMesh::onPeerDataRecv(mesh::Packet *packet, uint8_t type, int sender_idx, if (packet->isRouteFlood()) { // let this sender know path TO here, so they can use sendDirect(), and ALSO encode the response mesh::Packet *path = createPathReturn(client->id, secret, packet->path, packet->path_len, - PAYLOAD_TYPE_RESPONSE, reply_data, reply_len, client->nextAeadNonce()); + PAYLOAD_TYPE_RESPONSE, reply_data, reply_len, acl.nextAeadNonceFor(*client)); if (path) sendFlood(path, SERVER_RESPONSE_DELAY); } else { - mesh::Packet *reply = createDatagram(PAYLOAD_TYPE_RESPONSE, client->id, secret, reply_data, reply_len, client->nextAeadNonce()); + mesh::Packet *reply = createDatagram(PAYLOAD_TYPE_RESPONSE, client->id, secret, reply_data, reply_len, acl.nextAeadNonceFor(*client)); if (reply) { if (client->out_path_len >= 0) { // we have an out_path, so send DIRECT sendDirect(reply, client->out_path, client->out_path_len, SERVER_RESPONSE_DELAY); @@ -617,6 +620,7 @@ MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondCloc uptime_millis = 0; next_local_advert = next_flood_advert = 0; dirty_contacts_expiry = 0; + next_nonce_persist = 0; _logging = false; set_radio_at = revert_radio_at = 0; @@ -663,6 +667,12 @@ void MyMesh::begin(FILESYSTEM *fs) { _cli.loadPrefs(_fs); acl.load(_fs, self_id); + acl.setRNG(getRNG()); + acl.loadNonces(); + bool dirty_reset = wasDirtyReset(board); + acl.finalizeNonceLoad(dirty_reset); + if (dirty_reset) acl.saveNonces(); // persist bumped nonces immediately + next_nonce_persist = futureMillis(60000); radio_set_params(_prefs.freq, _prefs.bw, _prefs.sf, _prefs.cr); radio_set_tx_power(_prefs.tx_power_dbm); @@ -910,6 +920,12 @@ void MyMesh::loop() { dirty_contacts_expiry = 0; } + // persist dirty AEAD nonces + if (next_nonce_persist && millisHasNowPassed(next_nonce_persist)) { + if (acl.isNonceDirty()) { acl.saveNonces(); } + next_nonce_persist = futureMillis(60000); + } + // TODO: periodically check for OLD/inactive entries in known_clients[], and evict // update uptime diff --git a/examples/simple_room_server/MyMesh.h b/examples/simple_room_server/MyMesh.h index 92c8c4a3e..c29529a03 100644 --- a/examples/simple_room_server/MyMesh.h +++ b/examples/simple_room_server/MyMesh.h @@ -97,6 +97,7 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks { ClientACL acl; CommonCLI _cli; unsigned long dirty_contacts_expiry; + unsigned long next_nonce_persist; uint8_t reply_data[MAX_PACKET_PAYLOAD]; unsigned long next_push; uint16_t _num_posted, _num_post_pushes; @@ -177,6 +178,9 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks { void savePrefs() override { _cli.savePrefs(_fs); } + void onBeforeReboot() override { + if (acl.isNonceDirty()) acl.saveNonces(); + } void applyTempRadioParams(float freq, float bw, uint8_t sf, uint8_t cr, int timeout_mins) override; bool formatFileSystem() override; diff --git a/examples/simple_sensor/SensorMesh.cpp b/examples/simple_sensor/SensorMesh.cpp index 6741ba5ea..ba55e7654 100644 --- a/examples/simple_sensor/SensorMesh.cpp +++ b/examples/simple_sensor/SensorMesh.cpp @@ -256,7 +256,7 @@ void SensorMesh::sendAlert(const ClientInfo* c, Trigger* t) { mesh::Utils::sha256((uint8_t *)&t->expected_acks[t->attempt], 4, data, 5 + text_len, self_id.pub_key, PUB_KEY_SIZE); t->attempt++; - auto pkt = createDatagram(PAYLOAD_TYPE_TXT_MSG, c->id, c->shared_secret, data, 5 + text_len, c->nextAeadNonce()); + auto pkt = createDatagram(PAYLOAD_TYPE_TXT_MSG, c->id, c->shared_secret, data, 5 + text_len, acl.nextAeadNonceFor(*c)); if (pkt) { if (c->out_path_len >= 0) { // we have an out_path, so send DIRECT sendDirect(pkt, c->out_path, c->out_path_len); @@ -506,7 +506,7 @@ uint8_t SensorMesh::getPeerFlags(int peer_idx) { uint16_t SensorMesh::getPeerNextAeadNonce(int peer_idx) { int i = matching_peer_indexes[peer_idx]; if (i >= 0 && i < acl.getNumClients()) - return acl.getClientByIdx(i)->nextAeadNonce(); + return acl.nextAeadNonceFor(*acl.getClientByIdx(i)); return 0; } @@ -516,7 +516,10 @@ void SensorMesh::onPeerAeadDetected(int peer_idx) { auto c = acl.getClientByIdx(i); if (!(c->flags & CONTACT_FLAG_AEAD)) { c->flags |= CONTACT_FLAG_AEAD; - getRNG()->random((uint8_t*)&c->aead_nonce, sizeof(c->aead_nonce)); + if (c->aead_nonce == 0) { // no persisted nonce — seed from RNG to avoid deterministic start + getRNG()->random((uint8_t*)&c->aead_nonce, sizeof(c->aead_nonce)); + if (c->aead_nonce == 0) c->aead_nonce = 1; + } } } } @@ -561,10 +564,10 @@ void SensorMesh::onPeerDataRecv(mesh::Packet* packet, uint8_t type, int sender_i if (packet->isRouteFlood()) { // let this sender know path TO here, so they can use sendDirect(), and ALSO encode the response mesh::Packet* path = createPathReturn(from->id, secret, packet->path, packet->path_len, - PAYLOAD_TYPE_RESPONSE, reply_data, reply_len, from->nextAeadNonce()); + PAYLOAD_TYPE_RESPONSE, reply_data, reply_len, acl.nextAeadNonceFor(*from)); if (path) sendFlood(path, SERVER_RESPONSE_DELAY); } else { - mesh::Packet* reply = createDatagram(PAYLOAD_TYPE_RESPONSE, from->id, secret, reply_data, reply_len, from->nextAeadNonce()); + mesh::Packet* reply = createDatagram(PAYLOAD_TYPE_RESPONSE, from->id, secret, reply_data, reply_len, acl.nextAeadNonceFor(*from)); if (reply) { if (from->out_path_len >= 0) { // we have an out_path, so send DIRECT sendDirect(reply, from->out_path, from->out_path_len, SERVER_RESPONSE_DELAY); @@ -591,7 +594,7 @@ void SensorMesh::onPeerDataRecv(mesh::Packet* packet, uint8_t type, int sender_i if (packet->isRouteFlood()) { // let this sender know path TO here, so they can use sendDirect(), and ALSO encode the ACK mesh::Packet* path = createPathReturn(from->id, secret, packet->path, packet->path_len, - PAYLOAD_TYPE_ACK, (uint8_t *) &ack_hash, 4, from->nextAeadNonce()); + PAYLOAD_TYPE_ACK, (uint8_t *) &ack_hash, 4, acl.nextAeadNonceFor(*from)); if (path) sendFlood(path, TXT_ACK_DELAY); } else { sendAckTo(*from, ack_hash); @@ -619,7 +622,7 @@ void SensorMesh::onPeerDataRecv(mesh::Packet* packet, uint8_t type, int sender_i memcpy(temp, ×tamp, 4); // mostly an extra blob to help make packet_hash unique temp[4] = (TXT_TYPE_CLI_DATA << 2); - auto reply = createDatagram(PAYLOAD_TYPE_TXT_MSG, from->id, secret, temp, 5 + text_len, from->nextAeadNonce()); + auto reply = createDatagram(PAYLOAD_TYPE_TXT_MSG, from->id, secret, temp, 5 + text_len, acl.nextAeadNonceFor(*from)); if (reply) { if (from->out_path_len < 0) { sendFlood(reply, CLI_REPLY_DELAY_MILLIS); @@ -724,6 +727,7 @@ SensorMesh::SensorMesh(mesh::MainBoard& board, mesh::Radio& radio, mesh::Millise { next_local_advert = next_flood_advert = 0; dirty_contacts_expiry = 0; + next_nonce_persist = 0; last_read_time = 0; num_alert_tasks = 0; set_radio_at = revert_radio_at = 0; @@ -762,6 +766,12 @@ void SensorMesh::begin(FILESYSTEM* fs) { _cli.loadPrefs(_fs); acl.load(_fs, self_id); + acl.setRNG(getRNG()); + acl.loadNonces(); + bool dirty_reset = wasDirtyReset(board); + acl.finalizeNonceLoad(dirty_reset); + if (dirty_reset) acl.saveNonces(); // persist bumped nonces immediately + next_nonce_persist = futureMillis(60000); radio_set_params(_prefs.freq, _prefs.bw, _prefs.sf, _prefs.cr); radio_set_tx_power(_prefs.tx_power_dbm); @@ -969,4 +979,10 @@ void SensorMesh::loop() { acl.save(_fs); dirty_contacts_expiry = 0; } + + // persist dirty AEAD nonces + if (next_nonce_persist && millisHasNowPassed(next_nonce_persist)) { + if (acl.isNonceDirty()) { acl.saveNonces(); } + next_nonce_persist = futureMillis(60000); + } } diff --git a/examples/simple_sensor/SensorMesh.h b/examples/simple_sensor/SensorMesh.h index 69ee38666..09d79a6fb 100644 --- a/examples/simple_sensor/SensorMesh.h +++ b/examples/simple_sensor/SensorMesh.h @@ -59,6 +59,9 @@ class SensorMesh : public mesh::Mesh, public CommonCLICallbacks { const char* getNodeName() { return _prefs.node_name; } NodePrefs* getNodePrefs() { return &_prefs; } void savePrefs() override { _cli.savePrefs(_fs); } + void onBeforeReboot() override { + if (acl.isNonceDirty()) acl.saveNonces(); + } bool formatFileSystem() override; void sendSelfAdvertisement(int delay_millis, bool flood) override; void updateAdvertTimer() override; @@ -140,6 +143,7 @@ class SensorMesh : public mesh::Mesh, public CommonCLICallbacks { CommonCLI _cli; uint8_t reply_data[MAX_PACKET_PAYLOAD]; unsigned long dirty_contacts_expiry; + unsigned long next_nonce_persist; CayenneLPP telemetry; uint32_t last_read_time; int matching_peer_indexes[MAX_SEARCH_RESULTS]; diff --git a/src/MeshCore.h b/src/MeshCore.h index 75aa86ab2..651b68d4a 100644 --- a/src/MeshCore.h +++ b/src/MeshCore.h @@ -22,6 +22,10 @@ #define CONTACT_FLAG_AEAD 0x02 // bit 1 of ContactInfo.flags (bit 0 = favourite) #define FEAT1_AEAD_SUPPORT 0x0001 // bit 0 of feat1 uint16_t +// AEAD nonce persistence +#define NONCE_PERSIST_INTERVAL 50 // persist every N messages per peer +#define NONCE_BOOT_BUMP 100 // add this on load after dirty boot (must be >= 2 * PERSIST_INTERVAL) + #define MAX_PACKET_PAYLOAD 184 #define MAX_PATH_SIZE 64 #define MAX_TRANS_UNIT 255 diff --git a/src/helpers/ArduinoHelpers.h b/src/helpers/ArduinoHelpers.h index 97596daa3..9af0277e8 100644 --- a/src/helpers/ArduinoHelpers.h +++ b/src/helpers/ArduinoHelpers.h @@ -1,6 +1,7 @@ #pragma once #include +#include #include class VolatileRTCClock : public mesh::RTCClock { @@ -33,3 +34,17 @@ class StdRNG : public mesh::RNG { } } }; + +// Returns true for dirty resets (power-on, watchdog, brownout, panic). +// Returns false for clean wakes (deep sleep, software restart). +inline bool wasDirtyReset(mesh::MainBoard& board) { +#if defined(ESP32) + esp_reset_reason_t rst = esp_reset_reason(); + return (rst != ESP_RST_DEEPSLEEP && rst != ESP_RST_SW); +#elif defined(NRF52_PLATFORM) + return !(board.getResetReason() & POWER_RESETREAS_SREQ_Msk); +#else + (void)board; + return true; +#endif +} diff --git a/src/helpers/BaseChatMesh.cpp b/src/helpers/BaseChatMesh.cpp index 5360fbfb5..4cc4714b3 100644 --- a/src/helpers/BaseChatMesh.cpp +++ b/src/helpers/BaseChatMesh.cpp @@ -9,6 +9,50 @@ #define TXT_ACK_DELAY 200 #endif +uint16_t BaseChatMesh::nextAeadNonceFor(const ContactInfo& contact) { + uint16_t nonce = contact.nextAeadNonce(); + if (nonce != 0) { + int idx = &contact - contacts; + if (idx >= 0 && idx < num_contacts && + (uint16_t)(contact.aead_nonce - nonce_at_last_persist[idx]) >= NONCE_PERSIST_INTERVAL) { + nonce_dirty = true; + } + } + return nonce; +} + +bool BaseChatMesh::applyLoadedNonce(const uint8_t* pub_key_prefix, uint16_t nonce) { + for (int i = 0; i < num_contacts; i++) { + if (memcmp(contacts[i].id.pub_key, pub_key_prefix, 4) == 0) { + contacts[i].aead_nonce = nonce; + return true; + } + } + return false; +} + +void BaseChatMesh::finalizeNonceLoad(bool needs_bump) { + for (int i = 0; i < num_contacts; i++) { + if (needs_bump) { + uint16_t old = contacts[i].aead_nonce; + contacts[i].aead_nonce += NONCE_BOOT_BUMP; + if (contacts[i].aead_nonce == 0) contacts[i].aead_nonce = 1; + if (contacts[i].aead_nonce < old) { + MESH_DEBUG_PRINTLN("AEAD nonce wrapped after boot bump for peer: %s", contacts[i].name); + } + } + nonce_at_last_persist[i] = contacts[i].aead_nonce; + } + nonce_dirty = false; +} + +bool BaseChatMesh::getNonceEntry(int idx, uint8_t* pub_key_prefix, uint16_t* nonce) { + if (idx >= num_contacts) return false; + memcpy(pub_key_prefix, contacts[idx].id.pub_key, 4); + *nonce = contacts[idx].aead_nonce; + return true; +} + void BaseChatMesh::sendFloodScoped(const ContactInfo& recipient, mesh::Packet* pkt, uint32_t delay_millis) { sendFlood(pkt, delay_millis); } @@ -104,6 +148,7 @@ void BaseChatMesh::populateContactFromAdvert(ContactInfo& ci, const mesh::Identi ci.last_advert_timestamp = timestamp; ci.lastmod = getRTCClock()->getCurrentTime(); getRNG()->random((uint8_t*)&ci.aead_nonce, sizeof(ci.aead_nonce)); // seed AEAD nonce from HW RNG + if (ci.aead_nonce == 0) ci.aead_nonce = 1; if (parser.getFeat1() & FEAT1_AEAD_SUPPORT) { ci.flags |= CONTACT_FLAG_AEAD; } @@ -157,7 +202,8 @@ void BaseChatMesh::onAdvertRecv(mesh::Packet* packet, const mesh::Identity& id, return; } - populateContactFromAdvert(*from, id, parser, timestamp); + populateContactFromAdvert(*from, id, parser, timestamp); // seeds aead_nonce from RNG + nonce_at_last_persist[from - contacts] = from->aead_nonce; from->sync_since = 0; from->shared_secret_valid = false; } @@ -210,7 +256,7 @@ uint8_t BaseChatMesh::getPeerFlags(int peer_idx) { uint16_t BaseChatMesh::getPeerNextAeadNonce(int peer_idx) { int i = matching_peer_indexes[peer_idx]; if (i >= 0 && i < num_contacts) { - return contacts[i].nextAeadNonce(); + return nextAeadNonceFor(contacts[i]); } return 0; } @@ -242,7 +288,7 @@ void BaseChatMesh::onPeerDataRecv(mesh::Packet* packet, uint8_t type, int sender if (packet->isRouteFlood()) { // let this sender know path TO here, so they can use sendDirect(), and ALSO encode the ACK mesh::Packet* path = createPathReturn(from.id, secret, packet->path, packet->path_len, - PAYLOAD_TYPE_ACK, (uint8_t *) &ack_hash, 4, from.nextAeadNonce()); + PAYLOAD_TYPE_ACK, (uint8_t *) &ack_hash, 4, nextAeadNonceFor(from)); if (path) sendFloodScoped(from, path, TXT_ACK_DELAY); } else { sendAckTo(from, ack_hash); @@ -253,7 +299,7 @@ void BaseChatMesh::onPeerDataRecv(mesh::Packet* packet, uint8_t type, int sender if (packet->isRouteFlood()) { // let this sender know path TO here, so they can use sendDirect() (NOTE: no ACK as extra) - mesh::Packet* path = createPathReturn(from.id, secret, packet->path, packet->path_len, 0, NULL, 0, from.nextAeadNonce()); + mesh::Packet* path = createPathReturn(from.id, secret, packet->path, packet->path_len, 0, NULL, 0, nextAeadNonceFor(from)); if (path) sendFloodScoped(from, path); } } else if (flags == TXT_TYPE_SIGNED_PLAIN) { @@ -269,7 +315,7 @@ void BaseChatMesh::onPeerDataRecv(mesh::Packet* packet, uint8_t type, int sender if (packet->isRouteFlood()) { // let this sender know path TO here, so they can use sendDirect(), and ALSO encode the ACK mesh::Packet* path = createPathReturn(from.id, secret, packet->path, packet->path_len, - PAYLOAD_TYPE_ACK, (uint8_t *) &ack_hash, 4, from.nextAeadNonce()); + PAYLOAD_TYPE_ACK, (uint8_t *) &ack_hash, 4, nextAeadNonceFor(from)); if (path) sendFloodScoped(from, path, TXT_ACK_DELAY); } else { sendAckTo(from, ack_hash); @@ -285,10 +331,10 @@ void BaseChatMesh::onPeerDataRecv(mesh::Packet* packet, uint8_t type, int sender if (packet->isRouteFlood()) { // let this sender know path TO here, so they can use sendDirect(), and ALSO encode the response mesh::Packet* path = createPathReturn(from.id, secret, packet->path, packet->path_len, - PAYLOAD_TYPE_RESPONSE, temp_buf, reply_len, from.nextAeadNonce()); + PAYLOAD_TYPE_RESPONSE, temp_buf, reply_len, nextAeadNonceFor(from)); if (path) sendFloodScoped(from, path, SERVER_RESPONSE_DELAY); } else { - mesh::Packet* reply = createDatagram(PAYLOAD_TYPE_RESPONSE, from.id, secret, temp_buf, reply_len, from.nextAeadNonce()); + mesh::Packet* reply = createDatagram(PAYLOAD_TYPE_RESPONSE, from.id, secret, temp_buf, reply_len, nextAeadNonceFor(from)); if (reply) { if (from.out_path_len >= 0) { // we have an out_path, so send DIRECT sendDirect(reply, from.out_path, from.out_path_len, SERVER_RESPONSE_DELAY); @@ -354,7 +400,7 @@ void BaseChatMesh::onAckRecv(mesh::Packet* packet, uint32_t ack_crc) { void BaseChatMesh::handleReturnPathRetry(const ContactInfo& contact, const uint8_t* path, uint8_t path_len) { // NOTE: simplest impl is just to re-send a reciprocal return path to sender (DIRECTLY) // override this method in various firmwares, if there's a better strategy - mesh::Packet* rpath = createPathReturn(contact.id, contact.getSharedSecret(self_id), path, path_len, 0, NULL, 0, contact.nextAeadNonce()); + mesh::Packet* rpath = createPathReturn(contact.id, contact.getSharedSecret(self_id), path, path_len, 0, NULL, 0, nextAeadNonceFor(contact)); if (rpath) sendDirect(rpath, contact.out_path, contact.out_path_len, 3000); // 3 second delay } @@ -403,7 +449,7 @@ mesh::Packet* BaseChatMesh::composeMsgPacket(const ContactInfo& recipient, uint3 temp[len++] = attempt; // hide attempt number at tail end of payload } - return createDatagram(PAYLOAD_TYPE_TXT_MSG, recipient.id, recipient.getSharedSecret(self_id), temp, len, recipient.nextAeadNonce()); + return createDatagram(PAYLOAD_TYPE_TXT_MSG, recipient.id, recipient.getSharedSecret(self_id), temp, len, nextAeadNonceFor(recipient)); } int BaseChatMesh::sendMessage(const ContactInfo& recipient, uint32_t timestamp, uint8_t attempt, const char* text, uint32_t& expected_ack, uint32_t& est_timeout) { @@ -434,7 +480,7 @@ int BaseChatMesh::sendCommandData(const ContactInfo& recipient, uint32_t timest temp[4] = (attempt & 3) | (TXT_TYPE_CLI_DATA << 2); memcpy(&temp[5], text, text_len + 1); - auto pkt = createDatagram(PAYLOAD_TYPE_TXT_MSG, recipient.id, recipient.getSharedSecret(self_id), temp, 5 + text_len, recipient.nextAeadNonce()); + auto pkt = createDatagram(PAYLOAD_TYPE_TXT_MSG, recipient.id, recipient.getSharedSecret(self_id), temp, 5 + text_len, nextAeadNonceFor(recipient)); if (pkt == NULL) return MSG_SEND_FAILED; uint32_t t = _radio->getEstAirtimeFor(pkt->getRawLength()); @@ -575,7 +621,7 @@ int BaseChatMesh::sendRequest(const ContactInfo& recipient, const uint8_t* req_ memcpy(temp, &tag, 4); // mostly an extra blob to help make packet_hash unique memcpy(&temp[4], req_data, data_len); - pkt = createDatagram(PAYLOAD_TYPE_REQ, recipient.id, recipient.getSharedSecret(self_id), temp, 4 + data_len, recipient.nextAeadNonce()); + pkt = createDatagram(PAYLOAD_TYPE_REQ, recipient.id, recipient.getSharedSecret(self_id), temp, 4 + data_len, nextAeadNonceFor(recipient)); } if (pkt) { uint32_t t = _radio->getEstAirtimeFor(pkt->getRawLength()); @@ -602,7 +648,7 @@ int BaseChatMesh::sendRequest(const ContactInfo& recipient, uint8_t req_type, u memset(&temp[5], 0, 4); // reserved (possibly for 'since' param) getRNG()->random(&temp[9], 4); // random blob to help make packet-hash unique - pkt = createDatagram(PAYLOAD_TYPE_REQ, recipient.id, recipient.getSharedSecret(self_id), temp, sizeof(temp), recipient.nextAeadNonce()); + pkt = createDatagram(PAYLOAD_TYPE_REQ, recipient.id, recipient.getSharedSecret(self_id), temp, sizeof(temp), nextAeadNonceFor(recipient)); } if (pkt) { uint32_t t = _radio->getEstAirtimeFor(pkt->getRawLength()); @@ -725,7 +771,7 @@ void BaseChatMesh::checkConnections() { // calc expected ACK reply mesh::Utils::sha256((uint8_t *)&connections[i].expected_ack, 4, data, 9, self_id.pub_key, PUB_KEY_SIZE); - auto pkt = createDatagram(PAYLOAD_TYPE_REQ, contact->id, contact->getSharedSecret(self_id), data, 9, contact->nextAeadNonce()); + auto pkt = createDatagram(PAYLOAD_TYPE_REQ, contact->id, contact->getSharedSecret(self_id), data, 9, nextAeadNonceFor(*contact)); if (pkt) { sendDirect(pkt, contact->out_path, contact->out_path_len); } @@ -787,9 +833,12 @@ ContactInfo* BaseChatMesh::lookupContactByPubKey(const uint8_t* pub_key, int pre bool BaseChatMesh::addContact(const ContactInfo& contact) { ContactInfo* dest = allocateContactSlot(); if (dest) { + int idx = dest - contacts; *dest = contact; dest->shared_secret_valid = false; // mark shared_secret as needing calculation getRNG()->random((uint8_t*)&dest->aead_nonce, sizeof(dest->aead_nonce)); // always seed fresh from HW RNG + if (dest->aead_nonce == 0) dest->aead_nonce = 1; + nonce_at_last_persist[idx] = dest->aead_nonce; return true; // success } return false; @@ -802,10 +851,11 @@ bool BaseChatMesh::removeContact(ContactInfo& contact) { } if (idx >= num_contacts) return false; // not found - // remove from contacts array + // remove from contacts array and parallel nonce tracking num_contacts--; while (idx < num_contacts) { contacts[idx] = contacts[idx + 1]; + nonce_at_last_persist[idx] = nonce_at_last_persist[idx + 1]; idx++; } return true; // Success diff --git a/src/helpers/BaseChatMesh.h b/src/helpers/BaseChatMesh.h index 516375efa..77c984129 100644 --- a/src/helpers/BaseChatMesh.h +++ b/src/helpers/BaseChatMesh.h @@ -71,6 +71,10 @@ class BaseChatMesh : public mesh::Mesh { uint8_t temp_buf[MAX_TRANS_UNIT]; ConnectionInfo connections[MAX_CONNECTIONS]; + // Nonce persistence state (parallel to contacts[]) + uint16_t nonce_at_last_persist[MAX_CONTACTS]; + bool nonce_dirty; + mesh::Packet* composeMsgPacket(const ContactInfo& recipient, uint32_t timestamp, uint8_t attempt, const char *text, uint32_t& expected_ack); void sendAckTo(const ContactInfo& dest, uint32_t ack_hash); @@ -86,6 +90,8 @@ class BaseChatMesh : public mesh::Mesh { txt_send_timeout = 0; _pendingLoopback = NULL; memset(connections, 0, sizeof(connections)); + memset(nonce_at_last_persist, 0, sizeof(nonce_at_last_persist)); + nonce_dirty = false; } void bootstrapRTCfromContacts(); @@ -121,6 +127,17 @@ class BaseChatMesh : public mesh::Mesh { virtual int getBlobByKey(const uint8_t key[], int key_len, uint8_t dest_buf[]) { return 0; } // not implemented virtual bool putBlobByKey(const uint8_t key[], int key_len, const uint8_t src_buf[], int len) { return false; } + // AEAD nonce persistence helpers + uint16_t nextAeadNonceFor(const ContactInfo& contact); // wraps nextAeadNonce() with dirty-check + bool applyLoadedNonce(const uint8_t* pub_key_prefix, uint16_t nonce); + void finalizeNonceLoad(bool needs_bump); + bool getNonceEntry(int idx, uint8_t* pub_key_prefix, uint16_t* nonce); + bool isNonceDirty() const { return nonce_dirty; } + void clearNonceDirty() { + for (int i = 0; i < num_contacts; i++) nonce_at_last_persist[i] = contacts[i].aead_nonce; + nonce_dirty = false; + } + // Mesh overrides void onAdvertRecv(mesh::Packet* packet, const mesh::Identity& id, uint32_t timestamp, const uint8_t* app_data, size_t app_data_len) override; int searchPeersByHash(const uint8_t* hash) override; diff --git a/src/helpers/ClientACL.cpp b/src/helpers/ClientACL.cpp index 55b70ca55..d2926c79b 100644 --- a/src/helpers/ClientACL.cpp +++ b/src/helpers/ClientACL.cpp @@ -1,4 +1,5 @@ #include "ClientACL.h" +#include static File openWrite(FILESYSTEM* _fs, const char* filename) { #if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) @@ -111,13 +112,87 @@ ClientInfo* ClientACL::putClient(const mesh::Identity& id, uint8_t init_perms) { } else { c = oldest; // evict least active contact } + int idx = c - clients; memset(c, 0, sizeof(*c)); c->permissions = init_perms; c->id = id; c->out_path_len = -1; // initially out_path is unknown + if (_rng) { + _rng->random((uint8_t*)&c->aead_nonce, sizeof(c->aead_nonce)); + if (c->aead_nonce == 0) c->aead_nonce = 1; + } + nonce_at_last_persist[idx] = c->aead_nonce; return c; } +uint16_t ClientACL::nextAeadNonceFor(const ClientInfo& client) { + uint16_t nonce = client.nextAeadNonce(); + if (nonce != 0) { + int idx = &client - clients; + if (idx >= 0 && idx < num_clients && + (uint16_t)(client.aead_nonce - nonce_at_last_persist[idx]) >= NONCE_PERSIST_INTERVAL) { + nonce_dirty = true; + } + } + return nonce; +} + +void ClientACL::loadNonces() { + if (!_fs) return; +#if defined(RP2040_PLATFORM) + File file = _fs->open("/s_nonces", "r"); +#elif defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) + File file = _fs->open("/s_nonces", FILE_O_READ); +#else + File file = _fs->open("/s_nonces", "r", false); +#endif + if (file) { + uint8_t rec[6]; // 4-byte pub_key prefix + 2-byte nonce + while (file.read(rec, 6) == 6) { + uint16_t nonce; + memcpy(&nonce, &rec[4], 2); + for (int i = 0; i < num_clients; i++) { + if (memcmp(clients[i].id.pub_key, rec, 4) == 0) { + clients[i].aead_nonce = nonce; + break; + } + } + } + file.close(); + } +} + +void ClientACL::saveNonces() { + if (!_fs) return; + File file = openWrite(_fs, "/s_nonces"); + if (file) { + for (int i = 0; i < num_clients; i++) { + file.write(clients[i].id.pub_key, 4); + file.write((uint8_t*)&clients[i].aead_nonce, 2); + nonce_at_last_persist[i] = clients[i].aead_nonce; + } + file.close(); + nonce_dirty = false; + } +} + +void ClientACL::finalizeNonceLoad(bool needs_bump) { + for (int i = 0; i < num_clients; i++) { + if (needs_bump) { + uint16_t old = clients[i].aead_nonce; + clients[i].aead_nonce += NONCE_BOOT_BUMP; + if (clients[i].aead_nonce == 0) clients[i].aead_nonce = 1; + if (clients[i].aead_nonce < old) { + MESH_DEBUG_PRINTLN("AEAD nonce wrapped after boot bump for client: %02x%02x%02x%02x", + clients[i].id.pub_key[0], clients[i].id.pub_key[1], + clients[i].id.pub_key[2], clients[i].id.pub_key[3]); + } + } + nonce_at_last_persist[i] = clients[i].aead_nonce; + } + nonce_dirty = false; +} + bool ClientACL::applyPermissions(const mesh::LocalIdentity& self_id, const uint8_t* pubkey, int key_len, uint8_t perms) { ClientInfo* c; if ((perms & PERM_ACL_ROLE_MASK) == PERM_ACL_GUEST) { // guest role is not persisted in contacts @@ -128,6 +203,7 @@ bool ClientACL::applyPermissions(const mesh::LocalIdentity& self_id, const uint8 int i = c - clients; while (i < num_clients) { clients[i] = clients[i + 1]; + nonce_at_last_persist[i] = nonce_at_last_persist[i + 1]; i++; } } else { diff --git a/src/helpers/ClientACL.h b/src/helpers/ClientACL.h index c1f3afad8..9811d03b4 100644 --- a/src/helpers/ClientACL.h +++ b/src/helpers/ClientACL.h @@ -29,7 +29,7 @@ struct ClientInfo { uint8_t push_failures; } room; } extra; - + uint16_t nextAeadNonce() const { if (flags & CONTACT_FLAG_AEAD) { if (++aead_nonce == 0) ++aead_nonce; // skip 0 (means ECB) @@ -49,10 +49,18 @@ class ClientACL { ClientInfo clients[MAX_CLIENTS]; int num_clients; + // Nonce persistence state (parallel to clients[]) + uint16_t nonce_at_last_persist[MAX_CLIENTS]; + bool nonce_dirty; + mesh::RNG* _rng; + public: - ClientACL() { + ClientACL() { memset(clients, 0, sizeof(clients)); + memset(nonce_at_last_persist, 0, sizeof(nonce_at_last_persist)); num_clients = 0; + nonce_dirty = false; + _rng = NULL; } void load(FILESYSTEM* _fs, const mesh::LocalIdentity& self_id); void save(FILESYSTEM* _fs, bool (*filter)(ClientInfo*)=NULL); @@ -64,4 +72,16 @@ class ClientACL { int getNumClients() const { return num_clients; } ClientInfo* getClientByIdx(int idx) { return &clients[idx]; } + + // AEAD nonce persistence + void setRNG(mesh::RNG* rng) { _rng = rng; } + uint16_t nextAeadNonceFor(const ClientInfo& client); + void loadNonces(); + void saveNonces(); + void finalizeNonceLoad(bool needs_bump); + bool isNonceDirty() const { return nonce_dirty; } + void clearNonceDirty() { + for (int i = 0; i < num_clients; i++) nonce_at_last_persist[i] = clients[i].aead_nonce; + nonce_dirty = false; + } }; diff --git a/src/helpers/CommonCLI.cpp b/src/helpers/CommonCLI.cpp index beef8edaf..410ecab74 100644 --- a/src/helpers/CommonCLI.cpp +++ b/src/helpers/CommonCLI.cpp @@ -198,10 +198,12 @@ uint8_t CommonCLI::buildAdvertData(uint8_t node_type, uint8_t* app_data) { void CommonCLI::handleCommand(uint32_t sender_timestamp, const char* command, char* reply) { if (memcmp(command, "reboot", 6) == 0) { + _callbacks->onBeforeReboot(); _board->reboot(); // doesn't return } else if (memcmp(command, "clkreboot", 9) == 0) { // Reset clock getRTCClock()->setCurrentTime(1715770351); // 15 May 2024, 8:50pm + _callbacks->onBeforeReboot(); _board->reboot(); // doesn't return } else if (memcmp(command, "advert", 6) == 0) { // send flood advert diff --git a/src/helpers/CommonCLI.h b/src/helpers/CommonCLI.h index 146e1c6e2..3df5c7049 100644 --- a/src/helpers/CommonCLI.h +++ b/src/helpers/CommonCLI.h @@ -87,6 +87,10 @@ class CommonCLICallbacks { virtual void restartBridge() { // no op by default }; + + virtual void onBeforeReboot() { + // no op by default — override to flush nonces, etc. + }; }; class CommonCLI { From ac4d5b04460732cf820f9487c797069af0171c83 Mon Sep 17 00:00:00 2001 From: Wessel Nieboer Date: Sat, 14 Feb 2026 01:11:14 +0100 Subject: [PATCH 09/11] Implement session keys using --- examples/companion_radio/DataStore.cpp | 36 +++ examples/companion_radio/DataStore.h | 6 + examples/companion_radio/MyMesh.cpp | 22 +- examples/companion_radio/MyMesh.h | 10 + examples/simple_repeater/MyMesh.cpp | 65 ++-- examples/simple_repeater/MyMesh.h | 5 + examples/simple_room_server/MyMesh.cpp | 65 ++-- examples/simple_room_server/MyMesh.h | 5 + examples/simple_sensor/SensorMesh.cpp | 69 ++-- examples/simple_sensor/SensorMesh.h | 5 + src/Mesh.cpp | 44 ++- src/Mesh.h | 8 + src/MeshCore.h | 17 +- src/helpers/BaseChatMesh.cpp | 426 +++++++++++++++++++++++-- src/helpers/BaseChatMesh.h | 32 ++ src/helpers/ClientACL.cpp | 216 ++++++++++++- src/helpers/ClientACL.h | 30 ++ src/helpers/CommonCLI.cpp | 2 + src/helpers/ContactInfo.h | 4 + src/helpers/SessionKeyPool.h | 117 +++++++ 20 files changed, 1094 insertions(+), 90 deletions(-) create mode 100644 src/helpers/SessionKeyPool.h diff --git a/examples/companion_radio/DataStore.cpp b/examples/companion_radio/DataStore.cpp index 141ea54d9..2f97d204a 100644 --- a/examples/companion_radio/DataStore.cpp +++ b/examples/companion_radio/DataStore.cpp @@ -403,6 +403,42 @@ bool DataStore::saveNonces(DataStoreHost* host) { return false; } +void DataStore::loadSessionKeys(DataStoreHost* host) { + File file = openRead(_getContactsChannelsFS(), "/sess_keys"); + if (file) { + uint8_t rec[71]; // 4-byte pub_key prefix + 1 flags + 2 nonce + 32 session_key + 32 prev_session_key + while (file.read(rec, 71) == 71) { + uint16_t nonce; + memcpy(&nonce, &rec[5], 2); + host->onSessionKeyLoaded(rec, rec[4], nonce, &rec[7], &rec[39]); + } + file.close(); + } +} + +bool DataStore::saveSessionKeys(DataStoreHost* host) { + File file = openWrite(_getContactsChannelsFS(), "/sess_keys"); + if (file) { + uint8_t pub_key_prefix[4]; + uint8_t flags; + uint16_t nonce; + uint8_t session_key[32]; + uint8_t prev_session_key[32]; + for (int idx = 0; idx < MAX_SESSION_KEYS; idx++) { + if (host->getSessionKeyForSave(idx, pub_key_prefix, &flags, &nonce, session_key, prev_session_key)) { + file.write(pub_key_prefix, 4); + file.write(&flags, 1); + file.write((uint8_t*)&nonce, 2); + file.write(session_key, 32); + file.write(prev_session_key, 32); + } + } + file.close(); + return true; + } + return false; +} + #if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) #define MAX_ADVERT_PKT_LEN (2 + 32 + PUB_KEY_SIZE + 4 + SIGNATURE_SIZE + MAX_ADVERT_DATA_SIZE) diff --git a/examples/companion_radio/DataStore.h b/examples/companion_radio/DataStore.h index d2ec9167e..5f4170530 100644 --- a/examples/companion_radio/DataStore.h +++ b/examples/companion_radio/DataStore.h @@ -13,6 +13,10 @@ class DataStoreHost { virtual bool getChannelForSave(uint8_t channel_idx, ChannelDetails& ch) =0; virtual bool onNonceLoaded(const uint8_t* pub_key_prefix, uint16_t nonce) { return false; } virtual bool getNonceForSave(int idx, uint8_t* pub_key_prefix, uint16_t* nonce) { return false; } + virtual bool onSessionKeyLoaded(const uint8_t* pub_key_prefix, uint8_t flags, uint16_t nonce, + const uint8_t* session_key, const uint8_t* prev_session_key) { return false; } + virtual bool getSessionKeyForSave(int idx, uint8_t* pub_key_prefix, uint8_t* flags, uint16_t* nonce, + uint8_t* session_key, uint8_t* prev_session_key) { return false; } }; class DataStore { @@ -43,6 +47,8 @@ class DataStore { void saveChannels(DataStoreHost* host); void loadNonces(DataStoreHost* host); bool saveNonces(DataStoreHost* host); + void loadSessionKeys(DataStoreHost* host); + bool saveSessionKeys(DataStoreHost* host); void migrateToSecondaryFS(); uint8_t getBlobByKey(const uint8_t key[], int key_len, uint8_t dest_buf[]); bool putBlobByKey(const uint8_t key[], int key_len, const uint8_t src_buf[], uint8_t len); diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index b231d824a..725bb2c5e 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -873,6 +873,8 @@ void MyMesh::begin(bool has_display) { if (dirty_reset) saveNonces(); // persist bumped nonces immediately next_nonce_persist = futureMillis(60000); + _store->loadSessionKeys(this); + addChannel("Public", PUBLIC_GROUP_PSK); // pre-configure Andy's public channel _store->loadChannels(this); @@ -1285,6 +1287,7 @@ void MyMesh::handleCmdFrame(size_t len) { saveContacts(); } if (isNonceDirty()) saveNonces(); + saveSessionKeys(); board.reboot(); } else if (cmd_frame[0] == CMD_GET_BATT_AND_STORAGE) { uint8_t reply[11]; @@ -1925,8 +1928,22 @@ void MyMesh::checkCLIRescueCmd() { } + } else if (memcmp(cli_command, "rekey ", 6) == 0) { + const char* name_prefix = &cli_command[6]; + ContactInfo* c = searchContactsByPrefix(name_prefix); + if (c) { + if (initiateSessionKeyNegotiation(*c)) { + Serial.print(" Session key negotiation started with: "); + Serial.println(c->name); + } else { + Serial.println(" Error: failed to initiate (no AEAD or pool full)"); + } + } else { + Serial.println(" Error: contact not found"); + } } else if (strcmp(cli_command, "reboot") == 0) { if (isNonceDirty()) saveNonces(); + saveSessionKeys(); board.reboot(); // doesn't return } else { Serial.println(" Error: unknown command"); @@ -1978,11 +1995,14 @@ void MyMesh::loop() { dirty_contacts_expiry = 0; } - // periodic AEAD nonce persistence + // periodic AEAD nonce and session key persistence if (next_nonce_persist && millisHasNowPassed(next_nonce_persist)) { if (isNonceDirty()) { saveNonces(); } + if (isSessionKeysDirty()) { + saveSessionKeys(); + } next_nonce_persist = futureMillis(60000); } diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index e80e94028..1bd4c1c30 100644 --- a/examples/companion_radio/MyMesh.h +++ b/examples/companion_radio/MyMesh.h @@ -153,6 +153,14 @@ class MyMesh : public BaseChatMesh, public DataStoreHost { bool getChannelForSave(uint8_t channel_idx, ChannelDetails& ch) override { return getChannel(channel_idx, ch); } bool onNonceLoaded(const uint8_t* pub_key_prefix, uint16_t nonce) override { return applyLoadedNonce(pub_key_prefix, nonce); } bool getNonceForSave(int idx, uint8_t* pub_key_prefix, uint16_t* nonce) override { return getNonceEntry(idx, pub_key_prefix, nonce); } + bool onSessionKeyLoaded(const uint8_t* pub_key_prefix, uint8_t flags, uint16_t nonce, + const uint8_t* session_key, const uint8_t* prev_session_key) override { + return applyLoadedSessionKey(pub_key_prefix, flags, nonce, session_key, prev_session_key); + } + bool getSessionKeyForSave(int idx, uint8_t* pub_key_prefix, uint8_t* flags, uint16_t* nonce, + uint8_t* session_key, uint8_t* prev_session_key) override { + return getSessionKeyEntry(idx, pub_key_prefix, flags, nonce, session_key, prev_session_key); + } void clearPendingReqs() { pending_login = pending_status = pending_telemetry = pending_discovery = pending_req = 0; @@ -183,6 +191,8 @@ class MyMesh : public BaseChatMesh, public DataStoreHost { void saveChannels() { _store->saveChannels(this); } void saveContacts() { _store->saveContacts(this); } void saveNonces() { if (_store->saveNonces(this)) clearNonceDirty(); } + void saveSessionKeys() { if (_store->saveSessionKeys(this)) clearSessionKeysDirty(); } + void onSessionKeysUpdated() override { saveSessionKeys(); } DataStore* _store; NodePrefs _prefs; diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index 699312546..d002884bf 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -573,26 +573,36 @@ uint8_t MyMesh::getPeerFlags(int peer_idx) { } uint16_t MyMesh::getPeerNextAeadNonce(int peer_idx) { - int i = matching_peer_indexes[peer_idx]; - if (i >= 0 && i < acl.getNumClients()) - return acl.nextAeadNonceFor(*acl.getClientByIdx(i)); - return 0; + return acl.peerNextAeadNonce(peer_idx, matching_peer_indexes); } void MyMesh::onPeerAeadDetected(int peer_idx) { - int i = matching_peer_indexes[peer_idx]; - if (i >= 0 && i < acl.getNumClients()) { - auto c = acl.getClientByIdx(i); - if (!(c->flags & CONTACT_FLAG_AEAD)) { - c->flags |= CONTACT_FLAG_AEAD; - if (c->aead_nonce == 0) { // no persisted nonce — seed from RNG to avoid deterministic start - getRNG()->random((uint8_t*)&c->aead_nonce, sizeof(c->aead_nonce)); - if (c->aead_nonce == 0) c->aead_nonce = 1; - } + auto* c = acl.resolveClient(peer_idx, matching_peer_indexes); + if (c && !(c->flags & CONTACT_FLAG_AEAD)) { + c->flags |= CONTACT_FLAG_AEAD; + if (c->aead_nonce == 0) { // no persisted nonce — seed from RNG to avoid deterministic start + getRNG()->random((uint8_t*)&c->aead_nonce, sizeof(c->aead_nonce)); + if (c->aead_nonce == 0) c->aead_nonce = 1; } } } +const uint8_t* MyMesh::getPeerSessionKey(int peer_idx) { + return acl.peerSessionKey(peer_idx, matching_peer_indexes); +} +const uint8_t* MyMesh::getPeerPrevSessionKey(int peer_idx) { + return acl.peerPrevSessionKey(peer_idx, matching_peer_indexes); +} +void MyMesh::onSessionKeyDecryptSuccess(int peer_idx) { + acl.peerSessionKeyDecryptSuccess(peer_idx, matching_peer_indexes); +} +const uint8_t* MyMesh::getPeerEncryptionKey(int peer_idx, const uint8_t* static_secret) { + return acl.peerEncryptionKey(peer_idx, matching_peer_indexes, static_secret); +} +uint16_t MyMesh::getPeerEncryptionNonce(int peer_idx) { + return acl.peerEncryptionNonce(peer_idx, matching_peer_indexes); +} + static bool isShare(const mesh::Packet *packet) { if (packet->hasTransportCodes()) { return packet->transport_codes[0] == 0 && packet->transport_codes[1] == 0; // codes { 0, 0 } means 'send to nowhere' @@ -627,20 +637,37 @@ void MyMesh::onPeerDataRecv(mesh::Packet *packet, uint8_t type, int sender_idx, memcpy(×tamp, data, 4); if (timestamp > client->last_timestamp) { // prevent replay attacks - int reply_len = handleRequest(client, timestamp, &data[4], len - 4); + int reply_len; + bool use_static_secret = false; + + // Intercept session key INIT before handleRequest + if (data[4] == REQ_TYPE_SESSION_KEY_INIT && len >= 37) { + memcpy(reply_data, ×tamp, 4); + reply_data[4] = RESP_TYPE_SESSION_KEY_ACCEPT; + int n = acl.handleSessionKeyInit(client, &data[5], &reply_data[5], getRNG()); + reply_len = (n > 0) ? 5 + n : 0; + use_static_secret = true; // ACCEPT must use static secret (initiator doesn't have session key yet) + } else { + reply_len = handleRequest(client, timestamp, &data[4], len - 4); + } if (reply_len == 0) return; // invalid command client->last_timestamp = timestamp; client->last_activity = getRTCClock()->getCurrentTime(); + // Session key ACCEPT must be encrypted with static ECDH secret + static nonce, + // because the initiator hasn't derived the session key yet. + const uint8_t* enc_key = use_static_secret ? secret : acl.getEncryptionKey(*client); + uint16_t enc_nonce = use_static_secret ? acl.nextAeadNonceFor(*client) : acl.getEncryptionNonce(*client); + if (packet->isRouteFlood()) { // let this sender know path TO here, so they can use sendDirect(), and ALSO encode the response - mesh::Packet *path = createPathReturn(client->id, secret, packet->path, packet->path_len, - PAYLOAD_TYPE_RESPONSE, reply_data, reply_len, acl.nextAeadNonceFor(*client)); + mesh::Packet *path = createPathReturn(client->id, enc_key, packet->path, packet->path_len, + PAYLOAD_TYPE_RESPONSE, reply_data, reply_len, enc_nonce); if (path) sendFlood(path, SERVER_RESPONSE_DELAY); } else { mesh::Packet *reply = - createDatagram(PAYLOAD_TYPE_RESPONSE, client->id, secret, reply_data, reply_len, acl.nextAeadNonceFor(*client)); + createDatagram(PAYLOAD_TYPE_RESPONSE, client->id, enc_key, reply_data, reply_len, enc_nonce); if (reply) { if (client->out_path_len >= 0) { // we have an out_path, so send DIRECT sendDirect(reply, client->out_path, client->out_path_len, SERVER_RESPONSE_DELAY); @@ -701,7 +728,7 @@ void MyMesh::onPeerDataRecv(mesh::Packet *packet, uint8_t type, int sender_idx, memcpy(temp, ×tamp, 4); // mostly an extra blob to help make packet_hash unique temp[4] = (TXT_TYPE_CLI_DATA << 2); // NOTE: legacy was: TXT_TYPE_PLAIN - auto reply = createDatagram(PAYLOAD_TYPE_TXT_MSG, client->id, secret, temp, 5 + text_len, acl.nextAeadNonceFor(*client)); + auto reply = createDatagram(PAYLOAD_TYPE_TXT_MSG, client->id, acl.getEncryptionKey(*client), temp, 5 + text_len, acl.getEncryptionNonce(*client)); if (reply) { if (client->out_path_len < 0) { sendFlood(reply, CLI_REPLY_DELAY_MILLIS); @@ -840,6 +867,7 @@ void MyMesh::begin(FILESYSTEM *fs) { acl.load(_fs, self_id); acl.setRNG(getRNG()); acl.loadNonces(); + acl.loadSessionKeys(); bool dirty_reset = wasDirtyReset(board); acl.finalizeNonceLoad(dirty_reset); if (dirty_reset) acl.saveNonces(); // persist bumped nonces immediately @@ -1249,6 +1277,7 @@ void MyMesh::loop() { // persist dirty AEAD nonces if (next_nonce_persist && millisHasNowPassed(next_nonce_persist)) { if (acl.isNonceDirty()) { acl.saveNonces(); } + if (acl.isSessionKeysDirty()) { acl.saveSessionKeys(); } next_nonce_persist = futureMillis(60000); } diff --git a/examples/simple_repeater/MyMesh.h b/examples/simple_repeater/MyMesh.h index e57c38f3e..55a27598f 100644 --- a/examples/simple_repeater/MyMesh.h +++ b/examples/simple_repeater/MyMesh.h @@ -167,6 +167,11 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks { uint8_t getPeerFlags(int peer_idx) override; uint16_t getPeerNextAeadNonce(int peer_idx) override; void onPeerAeadDetected(int peer_idx) override; + const uint8_t* getPeerSessionKey(int peer_idx) override; + const uint8_t* getPeerPrevSessionKey(int peer_idx) override; + void onSessionKeyDecryptSuccess(int peer_idx) override; + const uint8_t* getPeerEncryptionKey(int peer_idx, const uint8_t* static_secret) override; + uint16_t getPeerEncryptionNonce(int peer_idx) override; void onAdvertRecv(mesh::Packet* packet, const mesh::Identity& id, uint32_t timestamp, const uint8_t* app_data, size_t app_data_len); void onPeerDataRecv(mesh::Packet* packet, uint8_t type, int sender_idx, const uint8_t* secret, uint8_t* data, size_t len) override; bool onPeerPathRecv(mesh::Packet* packet, int sender_idx, const uint8_t* secret, uint8_t* path, uint8_t path_len, uint8_t extra_type, uint8_t* extra, uint8_t extra_len) override; diff --git a/examples/simple_room_server/MyMesh.cpp b/examples/simple_room_server/MyMesh.cpp index 46bf747a9..788b33305 100644 --- a/examples/simple_room_server/MyMesh.cpp +++ b/examples/simple_room_server/MyMesh.cpp @@ -71,7 +71,7 @@ void MyMesh::pushPostToClient(ClientInfo *client, PostInfo &post) { mesh::Utils::sha256((uint8_t *)&client->extra.room.pending_ack, 4, reply_data, len, client->id.pub_key, PUB_KEY_SIZE); client->extra.room.push_post_timestamp = post.post_timestamp; - auto reply = createDatagram(PAYLOAD_TYPE_TXT_MSG, client->id, client->shared_secret, reply_data, len, acl.nextAeadNonceFor(*client)); + auto reply = createDatagram(PAYLOAD_TYPE_TXT_MSG, client->id, acl.getEncryptionKey(*client), reply_data, len, acl.getEncryptionNonce(*client)); if (reply) { if (client->out_path_len < 0) { sendFlood(reply); @@ -395,26 +395,36 @@ uint8_t MyMesh::getPeerFlags(int peer_idx) { } uint16_t MyMesh::getPeerNextAeadNonce(int peer_idx) { - int i = matching_peer_indexes[peer_idx]; - if (i >= 0 && i < acl.getNumClients()) - return acl.nextAeadNonceFor(*acl.getClientByIdx(i)); - return 0; + return acl.peerNextAeadNonce(peer_idx, matching_peer_indexes); } void MyMesh::onPeerAeadDetected(int peer_idx) { - int i = matching_peer_indexes[peer_idx]; - if (i >= 0 && i < acl.getNumClients()) { - auto c = acl.getClientByIdx(i); - if (!(c->flags & CONTACT_FLAG_AEAD)) { - c->flags |= CONTACT_FLAG_AEAD; - if (c->aead_nonce == 0) { // no persisted nonce — seed from RNG to avoid deterministic start - getRNG()->random((uint8_t*)&c->aead_nonce, sizeof(c->aead_nonce)); - if (c->aead_nonce == 0) c->aead_nonce = 1; - } + auto* c = acl.resolveClient(peer_idx, matching_peer_indexes); + if (c && !(c->flags & CONTACT_FLAG_AEAD)) { + c->flags |= CONTACT_FLAG_AEAD; + if (c->aead_nonce == 0) { // no persisted nonce — seed from RNG to avoid deterministic start + getRNG()->random((uint8_t*)&c->aead_nonce, sizeof(c->aead_nonce)); + if (c->aead_nonce == 0) c->aead_nonce = 1; } } } +const uint8_t* MyMesh::getPeerSessionKey(int peer_idx) { + return acl.peerSessionKey(peer_idx, matching_peer_indexes); +} +const uint8_t* MyMesh::getPeerPrevSessionKey(int peer_idx) { + return acl.peerPrevSessionKey(peer_idx, matching_peer_indexes); +} +void MyMesh::onSessionKeyDecryptSuccess(int peer_idx) { + acl.peerSessionKeyDecryptSuccess(peer_idx, matching_peer_indexes); +} +const uint8_t* MyMesh::getPeerEncryptionKey(int peer_idx, const uint8_t* static_secret) { + return acl.peerEncryptionKey(peer_idx, matching_peer_indexes, static_secret); +} +uint16_t MyMesh::getPeerEncryptionNonce(int peer_idx) { + return acl.peerEncryptionNonce(peer_idx, matching_peer_indexes); +} + void MyMesh::onPeerDataRecv(mesh::Packet *packet, uint8_t type, int sender_idx, const uint8_t *secret, uint8_t *data, size_t len) { int i = matching_peer_indexes[sender_idx]; @@ -508,7 +518,7 @@ void MyMesh::onPeerDataRecv(mesh::Packet *packet, uint8_t type, int sender_idx, // mesh::Utils::sha256((uint8_t *)&expected_ack_crc, 4, temp, 5 + text_len, self_id.pub_key, // PUB_KEY_SIZE); - auto reply = createDatagram(PAYLOAD_TYPE_TXT_MSG, client->id, secret, temp, 5 + text_len, acl.nextAeadNonceFor(*client)); + auto reply = createDatagram(PAYLOAD_TYPE_TXT_MSG, client->id, acl.getEncryptionKey(*client), temp, 5 + text_len, acl.getEncryptionNonce(*client)); if (reply) { if (client->out_path_len < 0) { sendFlood(reply, delay_millis + SERVER_RESPONSE_DELAY); @@ -560,15 +570,30 @@ void MyMesh::onPeerDataRecv(mesh::Packet *packet, uint8_t type, int sender_idx, } } } else { - int reply_len = handleRequest(client, sender_timestamp, &data[4], len - 4); + int reply_len; + bool use_static_secret = false; + + // Intercept session key INIT before handleRequest + if (data[4] == REQ_TYPE_SESSION_KEY_INIT && len >= 37) { + memcpy(reply_data, &sender_timestamp, 4); + reply_data[4] = RESP_TYPE_SESSION_KEY_ACCEPT; + int n = acl.handleSessionKeyInit(client, &data[5], &reply_data[5], getRNG()); + reply_len = (n > 0) ? 5 + n : 0; + use_static_secret = true; // ACCEPT must use static secret (initiator doesn't have session key yet) + } else { + reply_len = handleRequest(client, sender_timestamp, &data[4], len - 4); + } if (reply_len > 0) { // valid command + const uint8_t* enc_key = use_static_secret ? secret : acl.getEncryptionKey(*client); + uint16_t enc_nonce = use_static_secret ? acl.nextAeadNonceFor(*client) : acl.getEncryptionNonce(*client); + if (packet->isRouteFlood()) { // let this sender know path TO here, so they can use sendDirect(), and ALSO encode the response - mesh::Packet *path = createPathReturn(client->id, secret, packet->path, packet->path_len, - PAYLOAD_TYPE_RESPONSE, reply_data, reply_len, acl.nextAeadNonceFor(*client)); + mesh::Packet *path = createPathReturn(client->id, enc_key, packet->path, packet->path_len, + PAYLOAD_TYPE_RESPONSE, reply_data, reply_len, enc_nonce); if (path) sendFlood(path, SERVER_RESPONSE_DELAY); } else { - mesh::Packet *reply = createDatagram(PAYLOAD_TYPE_RESPONSE, client->id, secret, reply_data, reply_len, acl.nextAeadNonceFor(*client)); + mesh::Packet *reply = createDatagram(PAYLOAD_TYPE_RESPONSE, client->id, enc_key, reply_data, reply_len, enc_nonce); if (reply) { if (client->out_path_len >= 0) { // we have an out_path, so send DIRECT sendDirect(reply, client->out_path, client->out_path_len, SERVER_RESPONSE_DELAY); @@ -669,6 +694,7 @@ void MyMesh::begin(FILESYSTEM *fs) { acl.load(_fs, self_id); acl.setRNG(getRNG()); acl.loadNonces(); + acl.loadSessionKeys(); bool dirty_reset = wasDirtyReset(board); acl.finalizeNonceLoad(dirty_reset); if (dirty_reset) acl.saveNonces(); // persist bumped nonces immediately @@ -923,6 +949,7 @@ void MyMesh::loop() { // persist dirty AEAD nonces if (next_nonce_persist && millisHasNowPassed(next_nonce_persist)) { if (acl.isNonceDirty()) { acl.saveNonces(); } + if (acl.isSessionKeysDirty()) { acl.saveSessionKeys(); } next_nonce_persist = futureMillis(60000); } diff --git a/examples/simple_room_server/MyMesh.h b/examples/simple_room_server/MyMesh.h index c29529a03..f9c206074 100644 --- a/examples/simple_room_server/MyMesh.h +++ b/examples/simple_room_server/MyMesh.h @@ -152,6 +152,11 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks { uint8_t getPeerFlags(int peer_idx) override; uint16_t getPeerNextAeadNonce(int peer_idx) override; void onPeerAeadDetected(int peer_idx) override; + const uint8_t* getPeerSessionKey(int peer_idx) override; + const uint8_t* getPeerPrevSessionKey(int peer_idx) override; + void onSessionKeyDecryptSuccess(int peer_idx) override; + const uint8_t* getPeerEncryptionKey(int peer_idx, const uint8_t* static_secret) override; + uint16_t getPeerEncryptionNonce(int peer_idx) override; void onPeerDataRecv(mesh::Packet* packet, uint8_t type, int sender_idx, const uint8_t* secret, uint8_t* data, size_t len) override; bool onPeerPathRecv(mesh::Packet* packet, int sender_idx, const uint8_t* secret, uint8_t* path, uint8_t path_len, uint8_t extra_type, uint8_t* extra, uint8_t extra_len) override; void onAckRecv(mesh::Packet* packet, uint32_t ack_crc) override; diff --git a/examples/simple_sensor/SensorMesh.cpp b/examples/simple_sensor/SensorMesh.cpp index ba55e7654..d8da66173 100644 --- a/examples/simple_sensor/SensorMesh.cpp +++ b/examples/simple_sensor/SensorMesh.cpp @@ -256,7 +256,7 @@ void SensorMesh::sendAlert(const ClientInfo* c, Trigger* t) { mesh::Utils::sha256((uint8_t *)&t->expected_acks[t->attempt], 4, data, 5 + text_len, self_id.pub_key, PUB_KEY_SIZE); t->attempt++; - auto pkt = createDatagram(PAYLOAD_TYPE_TXT_MSG, c->id, c->shared_secret, data, 5 + text_len, acl.nextAeadNonceFor(*c)); + auto pkt = createDatagram(PAYLOAD_TYPE_TXT_MSG, c->id, acl.getEncryptionKey(*c), data, 5 + text_len, acl.getEncryptionNonce(*c)); if (pkt) { if (c->out_path_len >= 0) { // we have an out_path, so send DIRECT sendDirect(pkt, c->out_path, c->out_path_len); @@ -504,26 +504,36 @@ uint8_t SensorMesh::getPeerFlags(int peer_idx) { } uint16_t SensorMesh::getPeerNextAeadNonce(int peer_idx) { - int i = matching_peer_indexes[peer_idx]; - if (i >= 0 && i < acl.getNumClients()) - return acl.nextAeadNonceFor(*acl.getClientByIdx(i)); - return 0; + return acl.peerNextAeadNonce(peer_idx, matching_peer_indexes); } void SensorMesh::onPeerAeadDetected(int peer_idx) { - int i = matching_peer_indexes[peer_idx]; - if (i >= 0 && i < acl.getNumClients()) { - auto c = acl.getClientByIdx(i); - if (!(c->flags & CONTACT_FLAG_AEAD)) { - c->flags |= CONTACT_FLAG_AEAD; - if (c->aead_nonce == 0) { // no persisted nonce — seed from RNG to avoid deterministic start - getRNG()->random((uint8_t*)&c->aead_nonce, sizeof(c->aead_nonce)); - if (c->aead_nonce == 0) c->aead_nonce = 1; - } + auto* c = acl.resolveClient(peer_idx, matching_peer_indexes); + if (c && !(c->flags & CONTACT_FLAG_AEAD)) { + c->flags |= CONTACT_FLAG_AEAD; + if (c->aead_nonce == 0) { // no persisted nonce — seed from RNG to avoid deterministic start + getRNG()->random((uint8_t*)&c->aead_nonce, sizeof(c->aead_nonce)); + if (c->aead_nonce == 0) c->aead_nonce = 1; } } } +const uint8_t* SensorMesh::getPeerSessionKey(int peer_idx) { + return acl.peerSessionKey(peer_idx, matching_peer_indexes); +} +const uint8_t* SensorMesh::getPeerPrevSessionKey(int peer_idx) { + return acl.peerPrevSessionKey(peer_idx, matching_peer_indexes); +} +void SensorMesh::onSessionKeyDecryptSuccess(int peer_idx) { + acl.peerSessionKeyDecryptSuccess(peer_idx, matching_peer_indexes); +} +const uint8_t* SensorMesh::getPeerEncryptionKey(int peer_idx, const uint8_t* static_secret) { + return acl.peerEncryptionKey(peer_idx, matching_peer_indexes, static_secret); +} +uint16_t SensorMesh::getPeerEncryptionNonce(int peer_idx) { + return acl.peerEncryptionNonce(peer_idx, matching_peer_indexes); +} + void SensorMesh::sendAckTo(const ClientInfo& dest, uint32_t ack_hash) { if (dest.out_path_len < 0) { mesh::Packet* ack = createAck(ack_hash); @@ -555,19 +565,34 @@ void SensorMesh::onPeerDataRecv(mesh::Packet* packet, uint8_t type, int sender_i memcpy(×tamp, data, 4); if (timestamp > from->last_timestamp) { // prevent replay attacks - uint8_t reply_len = handleRequest(from->isAdmin() ? 0xFF : from->permissions, timestamp, data[4], &data[5], len - 5); + uint8_t reply_len; + bool use_static_secret = false; + if (data[4] == REQ_TYPE_SESSION_KEY_INIT && len >= 37) { // 4 (timestamp) + 1 (type) + 32 (ephemeral pub) + memcpy(reply_data, ×tamp, 4); + reply_data[4] = RESP_TYPE_SESSION_KEY_ACCEPT; + int n = acl.handleSessionKeyInit(from, &data[5], &reply_data[5], getRNG()); + reply_len = (n > 0) ? 5 + n : 0; + use_static_secret = true; // ACCEPT must use static secret (initiator doesn't have session key yet) + } else { + reply_len = handleRequest(from->isAdmin() ? 0xFF : from->permissions, timestamp, data[4], &data[5], len - 5); + } if (reply_len == 0) return; // invalid command from->last_timestamp = timestamp; from->last_activity = getRTCClock()->getCurrentTime(); + // Session key ACCEPT must be encrypted with static ECDH secret + static nonce, + // because the initiator hasn't derived the session key yet. + const uint8_t* enc_key = use_static_secret ? secret : acl.getEncryptionKey(*from); + uint16_t enc_nonce = use_static_secret ? acl.nextAeadNonceFor(*from) : acl.getEncryptionNonce(*from); + if (packet->isRouteFlood()) { // let this sender know path TO here, so they can use sendDirect(), and ALSO encode the response - mesh::Packet* path = createPathReturn(from->id, secret, packet->path, packet->path_len, - PAYLOAD_TYPE_RESPONSE, reply_data, reply_len, acl.nextAeadNonceFor(*from)); + mesh::Packet* path = createPathReturn(from->id, enc_key, packet->path, packet->path_len, + PAYLOAD_TYPE_RESPONSE, reply_data, reply_len, enc_nonce); if (path) sendFlood(path, SERVER_RESPONSE_DELAY); } else { - mesh::Packet* reply = createDatagram(PAYLOAD_TYPE_RESPONSE, from->id, secret, reply_data, reply_len, acl.nextAeadNonceFor(*from)); + mesh::Packet* reply = createDatagram(PAYLOAD_TYPE_RESPONSE, from->id, enc_key, reply_data, reply_len, enc_nonce); if (reply) { if (from->out_path_len >= 0) { // we have an out_path, so send DIRECT sendDirect(reply, from->out_path, from->out_path_len, SERVER_RESPONSE_DELAY); @@ -593,8 +618,8 @@ void SensorMesh::onPeerDataRecv(mesh::Packet* packet, uint8_t type, int sender_i if (packet->isRouteFlood()) { // let this sender know path TO here, so they can use sendDirect(), and ALSO encode the ACK - mesh::Packet* path = createPathReturn(from->id, secret, packet->path, packet->path_len, - PAYLOAD_TYPE_ACK, (uint8_t *) &ack_hash, 4, acl.nextAeadNonceFor(*from)); + mesh::Packet* path = createPathReturn(from->id, acl.getEncryptionKey(*from), packet->path, packet->path_len, + PAYLOAD_TYPE_ACK, (uint8_t *) &ack_hash, 4, acl.getEncryptionNonce(*from)); if (path) sendFlood(path, TXT_ACK_DELAY); } else { sendAckTo(*from, ack_hash); @@ -622,7 +647,7 @@ void SensorMesh::onPeerDataRecv(mesh::Packet* packet, uint8_t type, int sender_i memcpy(temp, ×tamp, 4); // mostly an extra blob to help make packet_hash unique temp[4] = (TXT_TYPE_CLI_DATA << 2); - auto reply = createDatagram(PAYLOAD_TYPE_TXT_MSG, from->id, secret, temp, 5 + text_len, acl.nextAeadNonceFor(*from)); + auto reply = createDatagram(PAYLOAD_TYPE_TXT_MSG, from->id, acl.getEncryptionKey(*from), temp, 5 + text_len, acl.getEncryptionNonce(*from)); if (reply) { if (from->out_path_len < 0) { sendFlood(reply, CLI_REPLY_DELAY_MILLIS); @@ -768,6 +793,7 @@ void SensorMesh::begin(FILESYSTEM* fs) { acl.load(_fs, self_id); acl.setRNG(getRNG()); acl.loadNonces(); + acl.loadSessionKeys(); bool dirty_reset = wasDirtyReset(board); acl.finalizeNonceLoad(dirty_reset); if (dirty_reset) acl.saveNonces(); // persist bumped nonces immediately @@ -983,6 +1009,7 @@ void SensorMesh::loop() { // persist dirty AEAD nonces if (next_nonce_persist && millisHasNowPassed(next_nonce_persist)) { if (acl.isNonceDirty()) { acl.saveNonces(); } + if (acl.isSessionKeysDirty()) { acl.saveSessionKeys(); } next_nonce_persist = futureMillis(60000); } } diff --git a/examples/simple_sensor/SensorMesh.h b/examples/simple_sensor/SensorMesh.h index 09d79a6fb..79757d582 100644 --- a/examples/simple_sensor/SensorMesh.h +++ b/examples/simple_sensor/SensorMesh.h @@ -129,6 +129,11 @@ class SensorMesh : public mesh::Mesh, public CommonCLICallbacks { uint8_t getPeerFlags(int peer_idx) override; uint16_t getPeerNextAeadNonce(int peer_idx) override; void onPeerAeadDetected(int peer_idx) override; + const uint8_t* getPeerSessionKey(int peer_idx) override; + const uint8_t* getPeerPrevSessionKey(int peer_idx) override; + void onSessionKeyDecryptSuccess(int peer_idx) override; + const uint8_t* getPeerEncryptionKey(int peer_idx, const uint8_t* static_secret) override; + uint16_t getPeerEncryptionNonce(int peer_idx) override; void onPeerDataRecv(mesh::Packet* packet, uint8_t type, int sender_idx, const uint8_t* secret, uint8_t* data, size_t len) override; bool onPeerPathRecv(mesh::Packet* packet, int sender_idx, const uint8_t* secret, uint8_t* path, uint8_t path_len, uint8_t extra_type, uint8_t* extra, uint8_t extra_len) override; void onControlDataRecv(mesh::Packet* packet) override; diff --git a/src/Mesh.cpp b/src/Mesh.cpp index d8b3b93b1..0d0988a75 100644 --- a/src/Mesh.cpp +++ b/src/Mesh.cpp @@ -154,17 +154,46 @@ DispatcherAction Mesh::onRecvPacket(Packet* pkt) { uint8_t data[MAX_PACKET_PAYLOAD]; int macAndDataLen = pkt->payload_len - i; - // Try-both decode: AEAD-first for peers known to support it (avoids 1/65536 - // ECB false-positive on AEAD packets), ECB-first for unknown/legacy peers. // Mask out route type bits — they are set after encryption and vary per hop. uint8_t assoc[3] = { (uint8_t)(pkt->header & ~PH_ROUTE_MASK), dest_hash, src_hash }; - int len; + int len = 0; bool decoded_aead = false; - if (getPeerFlags(j) & CONTACT_FLAG_AEAD) { + bool decoded_session = false; + + // Session key decode path: try session key(s) first if available + const uint8_t* sess_key = getPeerSessionKey(j); + if (sess_key) { + len = Utils::aeadDecrypt(sess_key, data, macAndData, macAndDataLen, assoc, 3, dest_hash, src_hash); + if (len > 0) { + decoded_session = true; + decoded_aead = true; + } else { + // Try prev_session_key (dual-decode window) + const uint8_t* prev_key = getPeerPrevSessionKey(j); + if (prev_key) { + len = Utils::aeadDecrypt(prev_key, data, macAndData, macAndDataLen, assoc, 3, dest_hash, src_hash); + if (len > 0) { + decoded_session = true; + decoded_aead = true; + } + } + } + if (!decoded_session) { + // Session key failed — try static ECDH, then ECB + len = Utils::aeadDecrypt(secret, data, macAndData, macAndDataLen, assoc, 3, dest_hash, src_hash); + if (len > 0) { + decoded_aead = true; + } else { + len = Utils::MACThenDecrypt(secret, data, macAndData, macAndDataLen); + } + } + } else if (getPeerFlags(j) & CONTACT_FLAG_AEAD) { + // No session key — standard AEAD-first decode for AEAD-capable peers len = Utils::aeadDecrypt(secret, data, macAndData, macAndDataLen, assoc, 3, dest_hash, src_hash); if (len > 0) decoded_aead = true; else len = Utils::MACThenDecrypt(secret, data, macAndData, macAndDataLen); } else { + // Legacy ECB-first decode len = Utils::MACThenDecrypt(secret, data, macAndData, macAndDataLen); if (len <= 0) { len = Utils::aeadDecrypt(secret, data, macAndData, macAndDataLen, assoc, 3, dest_hash, src_hash); @@ -172,7 +201,8 @@ DispatcherAction Mesh::onRecvPacket(Packet* pkt) { } } if (len > 0) { // success! - if (decoded_aead) onPeerAeadDetected(j); + if (decoded_session) onSessionKeyDecryptSuccess(j); + else if (decoded_aead) onPeerAeadDetected(j); if (pkt->getPayloadType() == PAYLOAD_TYPE_PATH) { int k = 0; uint8_t path_len = data[k++]; @@ -183,12 +213,12 @@ DispatcherAction Mesh::onRecvPacket(Packet* pkt) { if (onPeerPathRecv(pkt, j, secret, path, path_len, extra_type, extra, extra_len)) { if (pkt->isRouteFlood()) { // send a reciprocal return path to sender, but send DIRECTLY! - mesh::Packet* rpath = createPathReturn(&src_hash, secret, pkt->path, pkt->path_len, 0, NULL, 0, getPeerNextAeadNonce(j)); + mesh::Packet* rpath = createPathReturn(&src_hash, getPeerEncryptionKey(j, secret), pkt->path, pkt->path_len, 0, NULL, 0, getPeerEncryptionNonce(j)); if (rpath) sendDirect(rpath, path, path_len, 500); } } } else { - onPeerDataRecv(pkt, pkt->getPayloadType(), j, secret, data, len); + onPeerDataRecv(pkt, pkt->getPayloadType(), j, getPeerEncryptionKey(j, secret), data, len); } found = true; break; diff --git a/src/Mesh.h b/src/Mesh.h index 11eab1359..bac6b9d7a 100644 --- a/src/Mesh.h +++ b/src/Mesh.h @@ -86,6 +86,14 @@ class Mesh : public Dispatcher { virtual uint16_t getPeerNextAeadNonce(int peer_idx) { return 0; } virtual void onPeerAeadDetected(int peer_idx) { } + // Session key support (Phase 2) + virtual const uint8_t* getPeerSessionKey(int peer_idx) { return NULL; } + virtual const uint8_t* getPeerPrevSessionKey(int peer_idx) { return NULL; } + virtual void onSessionKeyDecryptSuccess(int peer_idx) { } + // Encryption key/nonce for outgoing messages to peer (session key with static ECDH fallback) + virtual const uint8_t* getPeerEncryptionKey(int peer_idx, const uint8_t* static_secret) { return static_secret; } + virtual uint16_t getPeerEncryptionNonce(int peer_idx) { return getPeerNextAeadNonce(peer_idx); } + /** * \brief A (now decrypted) data packet has been received (by a known peer). * NOTE: these can be received multiple times (per sender/msg-id), via different routes diff --git a/src/MeshCore.h b/src/MeshCore.h index 651b68d4a..8725e9057 100644 --- a/src/MeshCore.h +++ b/src/MeshCore.h @@ -9,6 +9,7 @@ #define SEED_SIZE 32 #define SIGNATURE_SIZE 64 #define MAX_ADVERT_DATA_SIZE 32 +#define SESSION_KEY_SIZE 32 #define CIPHER_KEY_SIZE 16 #define CIPHER_BLOCK_SIZE 16 @@ -24,7 +25,21 @@ // AEAD nonce persistence #define NONCE_PERSIST_INTERVAL 50 // persist every N messages per peer -#define NONCE_BOOT_BUMP 100 // add this on load after dirty boot (must be >= 2 * PERSIST_INTERVAL) +#define NONCE_BOOT_BUMP 50 // add this on load after dirty boot (must be >= PERSIST_INTERVAL) + +// Session key negotiation (Phase 2) +#define REQ_TYPE_SESSION_KEY_INIT 0x08 +#define RESP_TYPE_SESSION_KEY_ACCEPT 0x08 // response type byte in PAYLOAD_TYPE_RESPONSE + +#define NONCE_REKEY_THRESHOLD 60000 // start renegotiation when nonce exceeds this +#define NONCE_INITIAL_MIN 1000 // min random nonce seed for new contacts +#define NONCE_INITIAL_MAX 50000 // max random nonce seed for new contacts +#define SESSION_KEY_TIMEOUT_MS 180000 // 3 minutes per attempt +#define SESSION_KEY_MAX_RETRIES 3 // attempts per negotiation round +#define MAX_SESSION_KEYS 8 // max concurrent session key entries +#define SESSION_KEY_STALE_THRESHOLD 50 // sends without recv before fallback to static ECDH +#define SESSION_KEY_ECB_THRESHOLD 100 // sends without recv before fallback to ECB +#define SESSION_KEY_ABANDON_THRESHOLD 255 // sends without recv before clearing AEAD + session key #define MAX_PACKET_PAYLOAD 184 #define MAX_PATH_SIZE 64 diff --git a/src/helpers/BaseChatMesh.cpp b/src/helpers/BaseChatMesh.cpp index 4cc4714b3..a34cc8a7b 100644 --- a/src/helpers/BaseChatMesh.cpp +++ b/src/helpers/BaseChatMesh.cpp @@ -1,5 +1,7 @@ #include #include +#include +#include #ifndef SERVER_RESPONSE_DELAY #define SERVER_RESPONSE_DELAY 300 @@ -44,6 +46,20 @@ void BaseChatMesh::finalizeNonceLoad(bool needs_bump) { nonce_at_last_persist[i] = contacts[i].aead_nonce; } nonce_dirty = false; + + // Apply boot bump to session key nonces too + if (needs_bump) { + for (int i = 0; i < session_keys.getCount(); i++) { + auto entry = session_keys.getByIdx(i); + if (entry && (entry->state == SESSION_STATE_ACTIVE || entry->state == SESSION_STATE_DUAL_DECODE)) { + uint16_t old_nonce = entry->nonce; + entry->nonce += NONCE_BOOT_BUMP; + if (entry->nonce <= old_nonce) { + entry->nonce = 65535; // wrapped — force exhaustion so renegotiation happens + } + } + } + } } bool BaseChatMesh::getNonceEntry(int idx, uint8_t* pub_key_prefix, uint16_t* nonce) { @@ -147,8 +163,7 @@ void BaseChatMesh::populateContactFromAdvert(ContactInfo& ci, const mesh::Identi } ci.last_advert_timestamp = timestamp; ci.lastmod = getRTCClock()->getCurrentTime(); - getRNG()->random((uint8_t*)&ci.aead_nonce, sizeof(ci.aead_nonce)); // seed AEAD nonce from HW RNG - if (ci.aead_nonce == 0) ci.aead_nonce = 1; + ci.aead_nonce = (uint16_t)getRNG()->nextInt(NONCE_INITIAL_MIN, NONCE_INITIAL_MAX + 1); if (parser.getFeat1() & FEAT1_AEAD_SUPPORT) { ci.flags |= CONTACT_FLAG_AEAD; } @@ -287,8 +302,8 @@ void BaseChatMesh::onPeerDataRecv(mesh::Packet* packet, uint8_t type, int sender if (packet->isRouteFlood()) { // let this sender know path TO here, so they can use sendDirect(), and ALSO encode the ACK - mesh::Packet* path = createPathReturn(from.id, secret, packet->path, packet->path_len, - PAYLOAD_TYPE_ACK, (uint8_t *) &ack_hash, 4, nextAeadNonceFor(from)); + mesh::Packet* path = createPathReturn(from.id, getEncryptionKeyFor(from), packet->path, packet->path_len, + PAYLOAD_TYPE_ACK, (uint8_t *) &ack_hash, 4, getEncryptionNonceFor(from)); if (path) sendFloodScoped(from, path, TXT_ACK_DELAY); } else { sendAckTo(from, ack_hash); @@ -299,7 +314,7 @@ void BaseChatMesh::onPeerDataRecv(mesh::Packet* packet, uint8_t type, int sender if (packet->isRouteFlood()) { // let this sender know path TO here, so they can use sendDirect() (NOTE: no ACK as extra) - mesh::Packet* path = createPathReturn(from.id, secret, packet->path, packet->path_len, 0, NULL, 0, nextAeadNonceFor(from)); + mesh::Packet* path = createPathReturn(from.id, getEncryptionKeyFor(from), packet->path, packet->path_len, 0, NULL, 0, getEncryptionNonceFor(from)); if (path) sendFloodScoped(from, path); } } else if (flags == TXT_TYPE_SIGNED_PLAIN) { @@ -314,8 +329,8 @@ void BaseChatMesh::onPeerDataRecv(mesh::Packet* packet, uint8_t type, int sender if (packet->isRouteFlood()) { // let this sender know path TO here, so they can use sendDirect(), and ALSO encode the ACK - mesh::Packet* path = createPathReturn(from.id, secret, packet->path, packet->path_len, - PAYLOAD_TYPE_ACK, (uint8_t *) &ack_hash, 4, nextAeadNonceFor(from)); + mesh::Packet* path = createPathReturn(from.id, getEncryptionKeyFor(from), packet->path, packet->path_len, + PAYLOAD_TYPE_ACK, (uint8_t *) &ack_hash, 4, getEncryptionNonceFor(from)); if (path) sendFloodScoped(from, path, TXT_ACK_DELAY); } else { sendAckTo(from, ack_hash); @@ -326,15 +341,37 @@ void BaseChatMesh::onPeerDataRecv(mesh::Packet* packet, uint8_t type, int sender } else if (type == PAYLOAD_TYPE_REQ && len > 4) { uint32_t sender_timestamp; memcpy(&sender_timestamp, data, 4); - uint8_t reply_len = onContactRequest(from, sender_timestamp, &data[4], len - 4, temp_buf); + + uint8_t reply_len = 0; + bool use_static_secret = false; + + // Intercept session key INIT before subclass onContactRequest + if (len >= 5 + PUB_KEY_SIZE && data[4] == REQ_TYPE_SESSION_KEY_INIT) { + memcpy(temp_buf, &sender_timestamp, 4); + temp_buf[4] = RESP_TYPE_SESSION_KEY_ACCEPT; + uint8_t n = handleIncomingSessionKeyInit(from, &data[5], &temp_buf[5]); + if (n > 0) { + reply_len = 5 + n; + use_static_secret = true; // ACCEPT must use static secret (initiator doesn't have session key yet) + } + } + if (reply_len == 0) { + reply_len = onContactRequest(from, sender_timestamp, &data[4], len - 4, temp_buf); + } + if (reply_len > 0) { + // Session key ACCEPT must be encrypted with static ECDH secret, because + // the initiator hasn't derived the session key yet (they need our ephemeral_pub_B first). + const uint8_t* enc_key = use_static_secret ? from.getSharedSecret(self_id) : getEncryptionKeyFor(from); + uint16_t enc_nonce = use_static_secret ? nextAeadNonceFor(from) : getEncryptionNonceFor(from); + if (packet->isRouteFlood()) { // let this sender know path TO here, so they can use sendDirect(), and ALSO encode the response - mesh::Packet* path = createPathReturn(from.id, secret, packet->path, packet->path_len, - PAYLOAD_TYPE_RESPONSE, temp_buf, reply_len, nextAeadNonceFor(from)); + mesh::Packet* path = createPathReturn(from.id, enc_key, packet->path, packet->path_len, + PAYLOAD_TYPE_RESPONSE, temp_buf, reply_len, enc_nonce); if (path) sendFloodScoped(from, path, SERVER_RESPONSE_DELAY); } else { - mesh::Packet* reply = createDatagram(PAYLOAD_TYPE_RESPONSE, from.id, secret, temp_buf, reply_len, nextAeadNonceFor(from)); + mesh::Packet* reply = createDatagram(PAYLOAD_TYPE_RESPONSE, from.id, enc_key, temp_buf, reply_len, enc_nonce); if (reply) { if (from.out_path_len >= 0) { // we have an out_path, so send DIRECT sendDirect(reply, from.out_path, from.out_path_len, SERVER_RESPONSE_DELAY); @@ -345,7 +382,16 @@ void BaseChatMesh::onPeerDataRecv(mesh::Packet* packet, uint8_t type, int sender } } } else if (type == PAYLOAD_TYPE_RESPONSE && len > 0) { - onContactResponse(from, data, len); + // Intercept session key accept responses before passing to onContactResponse. + // Note: RESP_TYPE_SESSION_KEY_ACCEPT (0x08) could collide with a normal response whose + // 5th byte happens to be 0x08, but handleSessionKeyResponse has a secondary guard + // (requires INIT_SENT state for this peer) so false positives are extremely unlikely, + // and self-heal via session key invalidation if they ever occur. + if (len >= 5 && data[4] == RESP_TYPE_SESSION_KEY_ACCEPT && handleSessionKeyResponse(from, data, len)) { + // Session key response handled — don't pass to onContactResponse + } else { + onContactResponse(from, data, len); + } if (packet->isRouteFlood() && from.out_path_len >= 0) { // we have direct path, but other node is still sending flood response, so maybe they didn't receive reciprocal path properly(?) handleReturnPathRetry(from, packet->path, packet->path_len); @@ -379,7 +425,11 @@ bool BaseChatMesh::onContactPathRecv(ContactInfo& from, uint8_t* in_path, uint8_ txt_send_timeout = 0; // matched one we're waiting for, cancel timeout timer } } else if (extra_type == PAYLOAD_TYPE_RESPONSE && extra_len > 0) { - onContactResponse(from, extra, extra_len); + if (extra_len >= 5 && extra[4] == RESP_TYPE_SESSION_KEY_ACCEPT && handleSessionKeyResponse(from, extra, extra_len)) { + // Session key response handled + } else { + onContactResponse(from, extra, extra_len); + } } return true; // send reciprocal path if necessary } @@ -400,7 +450,7 @@ void BaseChatMesh::onAckRecv(mesh::Packet* packet, uint32_t ack_crc) { void BaseChatMesh::handleReturnPathRetry(const ContactInfo& contact, const uint8_t* path, uint8_t path_len) { // NOTE: simplest impl is just to re-send a reciprocal return path to sender (DIRECTLY) // override this method in various firmwares, if there's a better strategy - mesh::Packet* rpath = createPathReturn(contact.id, contact.getSharedSecret(self_id), path, path_len, 0, NULL, 0, nextAeadNonceFor(contact)); + mesh::Packet* rpath = createPathReturn(contact.id, getEncryptionKeyFor(contact), path, path_len, 0, NULL, 0, getEncryptionNonceFor(contact)); if (rpath) sendDirect(rpath, contact.out_path, contact.out_path_len, 3000); // 3 second delay } @@ -449,7 +499,7 @@ mesh::Packet* BaseChatMesh::composeMsgPacket(const ContactInfo& recipient, uint3 temp[len++] = attempt; // hide attempt number at tail end of payload } - return createDatagram(PAYLOAD_TYPE_TXT_MSG, recipient.id, recipient.getSharedSecret(self_id), temp, len, nextAeadNonceFor(recipient)); + return createDatagram(PAYLOAD_TYPE_TXT_MSG, recipient.id, getEncryptionKeyFor(recipient), temp, len, getEncryptionNonceFor(recipient)); } int BaseChatMesh::sendMessage(const ContactInfo& recipient, uint32_t timestamp, uint8_t attempt, const char* text, uint32_t& expected_ack, uint32_t& est_timeout) { @@ -480,7 +530,7 @@ int BaseChatMesh::sendCommandData(const ContactInfo& recipient, uint32_t timest temp[4] = (attempt & 3) | (TXT_TYPE_CLI_DATA << 2); memcpy(&temp[5], text, text_len + 1); - auto pkt = createDatagram(PAYLOAD_TYPE_TXT_MSG, recipient.id, recipient.getSharedSecret(self_id), temp, 5 + text_len, nextAeadNonceFor(recipient)); + auto pkt = createDatagram(PAYLOAD_TYPE_TXT_MSG, recipient.id, getEncryptionKeyFor(recipient), temp, 5 + text_len, getEncryptionNonceFor(recipient)); if (pkt == NULL) return MSG_SEND_FAILED; uint32_t t = _radio->getEstAirtimeFor(pkt->getRawLength()); @@ -621,7 +671,7 @@ int BaseChatMesh::sendRequest(const ContactInfo& recipient, const uint8_t* req_ memcpy(temp, &tag, 4); // mostly an extra blob to help make packet_hash unique memcpy(&temp[4], req_data, data_len); - pkt = createDatagram(PAYLOAD_TYPE_REQ, recipient.id, recipient.getSharedSecret(self_id), temp, 4 + data_len, nextAeadNonceFor(recipient)); + pkt = createDatagram(PAYLOAD_TYPE_REQ, recipient.id, getEncryptionKeyFor(recipient), temp, 4 + data_len, getEncryptionNonceFor(recipient)); } if (pkt) { uint32_t t = _radio->getEstAirtimeFor(pkt->getRawLength()); @@ -648,7 +698,7 @@ int BaseChatMesh::sendRequest(const ContactInfo& recipient, uint8_t req_type, u memset(&temp[5], 0, 4); // reserved (possibly for 'since' param) getRNG()->random(&temp[9], 4); // random blob to help make packet-hash unique - pkt = createDatagram(PAYLOAD_TYPE_REQ, recipient.id, recipient.getSharedSecret(self_id), temp, sizeof(temp), nextAeadNonceFor(recipient)); + pkt = createDatagram(PAYLOAD_TYPE_REQ, recipient.id, getEncryptionKeyFor(recipient), temp, sizeof(temp), getEncryptionNonceFor(recipient)); } if (pkt) { uint32_t t = _radio->getEstAirtimeFor(pkt->getRawLength()); @@ -771,7 +821,7 @@ void BaseChatMesh::checkConnections() { // calc expected ACK reply mesh::Utils::sha256((uint8_t *)&connections[i].expected_ack, 4, data, 9, self_id.pub_key, PUB_KEY_SIZE); - auto pkt = createDatagram(PAYLOAD_TYPE_REQ, contact->id, contact->getSharedSecret(self_id), data, 9, nextAeadNonceFor(*contact)); + auto pkt = createDatagram(PAYLOAD_TYPE_REQ, contact->id, getEncryptionKeyFor(*contact), data, 9, getEncryptionNonceFor(*contact)); if (pkt) { sendDirect(pkt, contact->out_path, contact->out_path_len); } @@ -836,8 +886,7 @@ bool BaseChatMesh::addContact(const ContactInfo& contact) { int idx = dest - contacts; *dest = contact; dest->shared_secret_valid = false; // mark shared_secret as needing calculation - getRNG()->random((uint8_t*)&dest->aead_nonce, sizeof(dest->aead_nonce)); // always seed fresh from HW RNG - if (dest->aead_nonce == 0) dest->aead_nonce = 1; + dest->aead_nonce = (uint16_t)getRNG()->nextInt(NONCE_INITIAL_MIN, NONCE_INITIAL_MAX + 1); nonce_at_last_persist[idx] = dest->aead_nonce; return true; // success } @@ -851,6 +900,8 @@ bool BaseChatMesh::removeContact(ContactInfo& contact) { } if (idx >= num_contacts) return false; // not found + session_keys.remove(contact.id.pub_key); // also remove session key if any + // remove from contacts array and parallel nonce tracking num_contacts--; while (idx < num_contacts) { @@ -953,4 +1004,337 @@ void BaseChatMesh::loop() { releasePacket(_pendingLoopback); // undo the obtainNewPacket() _pendingLoopback = NULL; } + + checkSessionKeyTimeouts(); + + // Process deferred session key negotiation (set by getEncryptionNonceFor) + if (_pending_rekey_idx >= 0 && _pending_rekey_idx < num_contacts) { + int idx = _pending_rekey_idx; + _pending_rekey_idx = -1; + initiateSessionKeyNegotiation(contacts[idx]); + } +} + +// --- Session key support (Phase 2 — initiator) --- + +static bool canUseSessionKey(const SessionKeyEntry* entry) { + if (!entry) return false; + // ACTIVE/DUAL_DECODE: normal session key use + // INIT_SENT with nonce > 1: renegotiation in progress, keep using old session key + // (nonce == 0 means fresh allocation with no prior session key) + bool valid_state = (entry->state == SESSION_STATE_ACTIVE || entry->state == SESSION_STATE_DUAL_DECODE) + || (entry->state == SESSION_STATE_INIT_SENT && entry->nonce > 1); + return valid_state + && entry->sends_since_last_recv < SESSION_KEY_STALE_THRESHOLD + && entry->nonce < 65535; // nonce exhausted → fall back to static ECDH +} + +const uint8_t* BaseChatMesh::getEncryptionKeyFor(const ContactInfo& contact) { + auto entry = session_keys.findByPrefix(contact.id.pub_key); + if (canUseSessionKey(entry)) { + return entry->session_key; + } + return contact.getSharedSecret(self_id); +} + +uint16_t BaseChatMesh::getEncryptionNonceFor(const ContactInfo& contact) { + uint16_t nonce = 0; + auto entry = session_keys.findByPrefix(contact.id.pub_key); + if (canUseSessionKey(entry)) { + ++entry->nonce; + if (entry->sends_since_last_recv < 255) entry->sends_since_last_recv++; + session_keys_dirty = true; + nonce = entry->nonce; + } else if (entry && entry->sends_since_last_recv < 255) { + // Progressive fallback: keep incrementing counter even when not using session key + entry->sends_since_last_recv++; + if (entry->sends_since_last_recv >= SESSION_KEY_ABANDON_THRESHOLD) { + // Give up: clear AEAD capability and remove session key + int idx = &contact - contacts; + if (idx >= 0 && idx < num_contacts) + contacts[idx].flags &= ~CONTACT_FLAG_AEAD; + session_keys.remove(contact.id.pub_key); + onSessionKeysUpdated(); + // nonce = 0 (ECB) + } else if (entry->sends_since_last_recv >= SESSION_KEY_ECB_THRESHOLD) { + // nonce = 0 (ECB) + } else { + nonce = nextAeadNonceFor(contact); + } + } else { + nonce = nextAeadNonceFor(contact); + } + + // Trigger session key negotiation on the next loop() tick. + // Checking here (the single funnel for all outgoing encryption) ensures no + // send path can silently skip a trigger — unlike the old per-call-site approach. + if (_pending_rekey_idx < 0 && shouldInitiateSessionKey(contact)) { + _pending_rekey_idx = &contact - contacts; + } + + return nonce; +} + +bool BaseChatMesh::shouldInitiateSessionKey(const ContactInfo& contact) { + // Only for AEAD-capable peers + if (!(contact.flags & CONTACT_FLAG_AEAD)) return false; + + // Need a known path to send the request + if (contact.out_path_len < 0) return false; + + auto entry = session_keys.findByPrefix(contact.id.pub_key); + + // Don't trigger if negotiation already in progress + if (entry && entry->state == SESSION_STATE_INIT_SENT) return false; + + // Determine intervals based on hop count tier: + // direct (0): static=100, session=100 + // 1–9 hops: static=500, session=300 + // 10+ hops: static=1000, session=300 + uint16_t static_interval, session_interval; + if (contact.out_path_len == 0) { + static_interval = 100; + session_interval = 100; + } else if (contact.out_path_len < 10) { + static_interval = 500; + session_interval = 300; + } else { + static_interval = 1000; + session_interval = 300; + } + + if (entry && (entry->state == SESSION_STATE_ACTIVE || entry->state == SESSION_STATE_DUAL_DECODE)) { + if (entry->nonce < 65535) { + // Active session key with remaining nonces — renegotiate after nonce > 60000 + if (entry->nonce <= NONCE_REKEY_THRESHOLD) return false; + return ((entry->nonce - NONCE_REKEY_THRESHOLD) % session_interval) == 0; + } + // Session key nonce exhausted — fall through to static ECDH trigger + } + + // No session key (or state=NONE) — trigger based on static ECDH nonce vs interval + if (contact.aead_nonce == 0) return false; // no messages sent yet + return (contact.aead_nonce % static_interval) == 0; +} + +bool BaseChatMesh::initiateSessionKeyNegotiation(const ContactInfo& contact) { + auto entry = session_keys.allocate(contact.id.pub_key); + if (!entry) return false; + + // Don't start a new negotiation if one is already pending + if (entry->state == SESSION_STATE_INIT_SENT) return false; + + // Generate ephemeral keypair A + uint8_t seed[SEED_SIZE]; + getRNG()->random(seed, SEED_SIZE); + ed25519_create_keypair(entry->ephemeral_pub, entry->ephemeral_prv, seed); + memset(seed, 0, SEED_SIZE); + + // Send REQ_TYPE_SESSION_KEY_INIT with ephemeral_pub_A + uint8_t req_data[1 + PUB_KEY_SIZE]; + req_data[0] = REQ_TYPE_SESSION_KEY_INIT; + memcpy(&req_data[1], entry->ephemeral_pub, PUB_KEY_SIZE); + + uint32_t tag, est_timeout; + int rc = sendRequest(contact, req_data, sizeof(req_data), tag, est_timeout); + if (rc == MSG_SEND_FAILED) { + memset(entry->ephemeral_prv, 0, PRV_KEY_SIZE); + memset(entry->ephemeral_pub, 0, PUB_KEY_SIZE); + return false; + } + + entry->state = SESSION_STATE_INIT_SENT; + entry->retries_left = SESSION_KEY_MAX_RETRIES - 1; + entry->timeout_at = futureMillis(SESSION_KEY_TIMEOUT_MS); + return true; +} + +bool BaseChatMesh::handleSessionKeyResponse(ContactInfo& contact, const uint8_t* data, uint8_t len) { + // Response format: [timestamp:4][RESP_TYPE_SESSION_KEY_ACCEPT:1][ephemeral_pub_B:32] + if (len < 5 + PUB_KEY_SIZE) return false; + if (data[4] != RESP_TYPE_SESSION_KEY_ACCEPT) return false; + + auto entry = session_keys.findByPrefix(contact.id.pub_key); + if (!entry || entry->state != SESSION_STATE_INIT_SENT) return false; + + const uint8_t* ephemeral_pub_B = &data[5]; + + // Compute ephemeral_secret via X25519 + uint8_t ephemeral_secret[PUB_KEY_SIZE]; + ed25519_key_exchange(ephemeral_secret, ephemeral_pub_B, entry->ephemeral_prv); + memset(entry->ephemeral_prv, 0, PRV_KEY_SIZE); + memset(entry->ephemeral_pub, 0, PUB_KEY_SIZE); + + // Derive session_key = HMAC-SHA256(static_shared_secret, ephemeral_secret) + const uint8_t* static_secret = contact.getSharedSecret(self_id); + uint8_t new_session_key[SESSION_KEY_SIZE]; + { + SHA256 sha; + sha.resetHMAC(static_secret, PUB_KEY_SIZE); + sha.update(ephemeral_secret, PUB_KEY_SIZE); + sha.finalizeHMAC(static_secret, PUB_KEY_SIZE, new_session_key, SESSION_KEY_SIZE); + } + memset(ephemeral_secret, 0, PUB_KEY_SIZE); + + // Activate session key + memcpy(entry->session_key, new_session_key, SESSION_KEY_SIZE); + memset(new_session_key, 0, SESSION_KEY_SIZE); + entry->nonce = 1; + entry->state = SESSION_STATE_ACTIVE; + entry->sends_since_last_recv = 0; + entry->retries_left = 0; + entry->timeout_at = 0; + + MESH_DEBUG_PRINTLN("Session key established with: %s", contact.name); + onSessionKeysUpdated(); + return true; +} + +uint8_t BaseChatMesh::handleIncomingSessionKeyInit(ContactInfo& from, const uint8_t* ephemeral_pub_A, uint8_t* reply_buf) { + // 1. Generate ephemeral keypair B + uint8_t seed[SEED_SIZE]; + getRNG()->random(seed, SEED_SIZE); + uint8_t ephemeral_pub_B[PUB_KEY_SIZE]; + uint8_t ephemeral_prv_B[PRV_KEY_SIZE]; + ed25519_create_keypair(ephemeral_pub_B, ephemeral_prv_B, seed); + memset(seed, 0, SEED_SIZE); + + // 2. Compute ephemeral_secret via X25519 + uint8_t ephemeral_secret[PUB_KEY_SIZE]; + ed25519_key_exchange(ephemeral_secret, ephemeral_pub_A, ephemeral_prv_B); + memset(ephemeral_prv_B, 0, PRV_KEY_SIZE); + + // 3. Derive session_key = HMAC-SHA256(static_shared_secret, ephemeral_secret) + const uint8_t* static_secret = from.getSharedSecret(self_id); + uint8_t new_session_key[SESSION_KEY_SIZE]; + { + SHA256 sha; + sha.resetHMAC(static_secret, PUB_KEY_SIZE); + sha.update(ephemeral_secret, PUB_KEY_SIZE); + sha.finalizeHMAC(static_secret, PUB_KEY_SIZE, new_session_key, SESSION_KEY_SIZE); + } + memset(ephemeral_secret, 0, PUB_KEY_SIZE); + + // 4. Store in pool (dual-decode: new key active, old key still valid) + auto entry = session_keys.allocate(from.id.pub_key); + if (!entry) return 0; + + if (entry->state == SESSION_STATE_ACTIVE || entry->state == SESSION_STATE_DUAL_DECODE) { + memcpy(entry->prev_session_key, entry->session_key, SESSION_KEY_SIZE); + } + memcpy(entry->session_key, new_session_key, SESSION_KEY_SIZE); + entry->nonce = 1; + entry->state = SESSION_STATE_DUAL_DECODE; + entry->sends_since_last_recv = 0; + memset(new_session_key, 0, SESSION_KEY_SIZE); + + // 5. Persist immediately + onSessionKeysUpdated(); + + // 6. Write ephemeral_pub_B to reply + memcpy(reply_buf, ephemeral_pub_B, PUB_KEY_SIZE); + MESH_DEBUG_PRINTLN("Session key INIT accepted from: %s", from.name); + return PUB_KEY_SIZE; +} + +void BaseChatMesh::checkSessionKeyTimeouts() { + for (int i = 0; i < session_keys.getCount(); i++) { + auto entry = session_keys.getByIdx(i); + if (!entry || entry->state != SESSION_STATE_INIT_SENT) continue; + if (entry->timeout_at == 0 || !millisHasNowPassed(entry->timeout_at)) continue; + + if (entry->retries_left > 0) { + // Retry: find the contact and resend INIT + for (int j = 0; j < num_contacts; j++) { + if (memcmp(contacts[j].id.pub_key, entry->peer_pub_prefix, 4) == 0) { + entry->retries_left--; + entry->timeout_at = futureMillis(SESSION_KEY_TIMEOUT_MS); + + // Regenerate ephemeral keypair for retry + uint8_t seed[SEED_SIZE]; + getRNG()->random(seed, SEED_SIZE); + ed25519_create_keypair(entry->ephemeral_pub, entry->ephemeral_prv, seed); + memset(seed, 0, SEED_SIZE); + + uint8_t req_data[1 + PUB_KEY_SIZE]; + req_data[0] = REQ_TYPE_SESSION_KEY_INIT; + memcpy(&req_data[1], entry->ephemeral_pub, PUB_KEY_SIZE); + + uint32_t tag, est_timeout; + sendRequest(contacts[j], req_data, sizeof(req_data), tag, est_timeout); + break; + } + } + } else { + // All retries exhausted — clean up + memset(entry->ephemeral_prv, 0, PRV_KEY_SIZE); + memset(entry->ephemeral_pub, 0, PUB_KEY_SIZE); + entry->state = SESSION_STATE_NONE; + entry->timeout_at = 0; + } + } +} + +// Virtual overrides for session key decrypt path +const uint8_t* BaseChatMesh::getPeerSessionKey(int peer_idx) { + int i = matching_peer_indexes[peer_idx]; + if (i >= 0 && i < num_contacts) { + auto entry = session_keys.findByPrefix(contacts[i].id.pub_key); + // Also try decode during INIT_SENT renegotiation (nonce > 1 means prior key exists) + if (entry && (entry->state == SESSION_STATE_ACTIVE || entry->state == SESSION_STATE_DUAL_DECODE + || (entry->state == SESSION_STATE_INIT_SENT && entry->nonce > 1))) + return entry->session_key; + } + return nullptr; +} + +const uint8_t* BaseChatMesh::getPeerPrevSessionKey(int peer_idx) { + int i = matching_peer_indexes[peer_idx]; + if (i >= 0 && i < num_contacts) { + auto entry = session_keys.findByPrefix(contacts[i].id.pub_key); + if (entry && entry->state == SESSION_STATE_DUAL_DECODE) + return entry->prev_session_key; + } + return nullptr; +} + +void BaseChatMesh::onSessionKeyDecryptSuccess(int peer_idx) { + int i = matching_peer_indexes[peer_idx]; + if (i >= 0 && i < num_contacts) { + auto entry = session_keys.findByPrefix(contacts[i].id.pub_key); + if (entry) { + bool changed = (entry->state == SESSION_STATE_DUAL_DECODE); + if (changed) { + memset(entry->prev_session_key, 0, SESSION_KEY_SIZE); + entry->state = SESSION_STATE_ACTIVE; + } + entry->sends_since_last_recv = 0; + if (changed) onSessionKeysUpdated(); + } + } +} + +const uint8_t* BaseChatMesh::getPeerEncryptionKey(int peer_idx, const uint8_t* static_secret) { + int i = matching_peer_indexes[peer_idx]; + if (i >= 0 && i < num_contacts) + return getEncryptionKeyFor(contacts[i]); + return static_secret; +} + +uint16_t BaseChatMesh::getPeerEncryptionNonce(int peer_idx) { + int i = matching_peer_indexes[peer_idx]; + if (i >= 0 && i < num_contacts) + return getEncryptionNonceFor(contacts[i]); + return getPeerNextAeadNonce(peer_idx); +} + +// Session key persistence helpers (delegated to subclass for file I/O) +bool BaseChatMesh::applyLoadedSessionKey(const uint8_t* pub_key_prefix, uint8_t flags, uint16_t nonce, + const uint8_t* session_key, const uint8_t* prev_session_key) { + return session_keys.applyLoaded(pub_key_prefix, flags, nonce, session_key, prev_session_key); +} + +bool BaseChatMesh::getSessionKeyEntry(int idx, uint8_t* pub_key_prefix, uint8_t* flags, uint16_t* nonce, + uint8_t* session_key, uint8_t* prev_session_key) { + return session_keys.getEntryForSave(idx, pub_key_prefix, flags, nonce, session_key, prev_session_key); } diff --git a/src/helpers/BaseChatMesh.h b/src/helpers/BaseChatMesh.h index 77c984129..24e14ffdf 100644 --- a/src/helpers/BaseChatMesh.h +++ b/src/helpers/BaseChatMesh.h @@ -8,6 +8,7 @@ #define MAX_TEXT_LEN (10*CIPHER_BLOCK_SIZE) // must be LESS than (MAX_PACKET_PAYLOAD - 4 - CIPHER_MAC_SIZE - 1) #include "ContactInfo.h" +#include "SessionKeyPool.h" #define MAX_SEARCH_RESULTS 8 @@ -75,6 +76,11 @@ class BaseChatMesh : public mesh::Mesh { uint16_t nonce_at_last_persist[MAX_CONTACTS]; bool nonce_dirty; + // Session key pool (Phase 2) + SessionKeyPool session_keys; + bool session_keys_dirty; + int _pending_rekey_idx; // contact index needing session key negotiation, -1 = none + mesh::Packet* composeMsgPacket(const ContactInfo& recipient, uint32_t timestamp, uint8_t attempt, const char *text, uint32_t& expected_ack); void sendAckTo(const ContactInfo& dest, uint32_t ack_hash); @@ -92,6 +98,8 @@ class BaseChatMesh : public mesh::Mesh { memset(connections, 0, sizeof(connections)); memset(nonce_at_last_persist, 0, sizeof(nonce_at_last_persist)); nonce_dirty = false; + session_keys_dirty = false; + _pending_rekey_idx = -1; } void bootstrapRTCfromContacts(); @@ -138,12 +146,36 @@ class BaseChatMesh : public mesh::Mesh { nonce_dirty = false; } + // Session key support (Phase 2 — initiator) + virtual void onSessionKeysUpdated() { session_keys_dirty = true; } // called when session key pool changes; override to persist + const uint8_t* getEncryptionKeyFor(const ContactInfo& contact); + uint16_t getEncryptionNonceFor(const ContactInfo& contact); + bool shouldInitiateSessionKey(const ContactInfo& contact); + bool initiateSessionKeyNegotiation(const ContactInfo& contact); + bool handleSessionKeyResponse(ContactInfo& contact, const uint8_t* data, uint8_t len); + uint8_t handleIncomingSessionKeyInit(ContactInfo& from, const uint8_t* ephemeral_pub_A, uint8_t* reply_buf); + void checkSessionKeyTimeouts(); + + // Session key persistence helpers (for subclass to call) + bool applyLoadedSessionKey(const uint8_t* pub_key_prefix, uint8_t flags, uint16_t nonce, + const uint8_t* session_key, const uint8_t* prev_session_key); + bool getSessionKeyEntry(int idx, uint8_t* pub_key_prefix, uint8_t* flags, uint16_t* nonce, + uint8_t* session_key, uint8_t* prev_session_key); + int getSessionKeyCount() const { return session_keys.getCount(); } + bool isSessionKeysDirty() const { return session_keys_dirty; } + void clearSessionKeysDirty() { session_keys_dirty = false; } + // Mesh overrides void onAdvertRecv(mesh::Packet* packet, const mesh::Identity& id, uint32_t timestamp, const uint8_t* app_data, size_t app_data_len) override; int searchPeersByHash(const uint8_t* hash) override; void getPeerSharedSecret(uint8_t* dest_secret, int peer_idx) override; uint8_t getPeerFlags(int peer_idx) override; uint16_t getPeerNextAeadNonce(int peer_idx) override; + const uint8_t* getPeerSessionKey(int peer_idx) override; + const uint8_t* getPeerPrevSessionKey(int peer_idx) override; + void onSessionKeyDecryptSuccess(int peer_idx) override; + const uint8_t* getPeerEncryptionKey(int peer_idx, const uint8_t* static_secret) override; + uint16_t getPeerEncryptionNonce(int peer_idx) override; void onPeerDataRecv(mesh::Packet* packet, uint8_t type, int sender_idx, const uint8_t* secret, uint8_t* data, size_t len) override; bool onPeerPathRecv(mesh::Packet* packet, int sender_idx, const uint8_t* secret, uint8_t* path, uint8_t path_len, uint8_t extra_type, uint8_t* extra, uint8_t extra_len) override; void onAckRecv(mesh::Packet* packet, uint32_t ack_crc) override; diff --git a/src/helpers/ClientACL.cpp b/src/helpers/ClientACL.cpp index d2926c79b..7188f3468 100644 --- a/src/helpers/ClientACL.cpp +++ b/src/helpers/ClientACL.cpp @@ -1,5 +1,7 @@ #include "ClientACL.h" #include +#include +#include static File openWrite(FILESYSTEM* _fs, const char* filename) { #if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) @@ -118,8 +120,7 @@ ClientInfo* ClientACL::putClient(const mesh::Identity& id, uint8_t init_perms) { c->id = id; c->out_path_len = -1; // initially out_path is unknown if (_rng) { - _rng->random((uint8_t*)&c->aead_nonce, sizeof(c->aead_nonce)); - if (c->aead_nonce == 0) c->aead_nonce = 1; + c->aead_nonce = (uint16_t)_rng->nextInt(NONCE_INITIAL_MIN, NONCE_INITIAL_MAX + 1); } nonce_at_last_persist[idx] = c->aead_nonce; return c; @@ -191,6 +192,20 @@ void ClientACL::finalizeNonceLoad(bool needs_bump) { nonce_at_last_persist[i] = clients[i].aead_nonce; } nonce_dirty = false; + + // Apply boot bump to session key nonces too + if (needs_bump) { + for (int i = 0; i < session_keys.getCount(); i++) { + auto entry = session_keys.getByIdx(i); + if (entry && (entry->state == SESSION_STATE_ACTIVE || entry->state == SESSION_STATE_DUAL_DECODE)) { + uint16_t old_nonce = entry->nonce; + entry->nonce += NONCE_BOOT_BUMP; + if (entry->nonce <= old_nonce) { + entry->nonce = 65535; // wrapped — force exhaustion so renegotiation happens + } + } + } + } } bool ClientACL::applyPermissions(const mesh::LocalIdentity& self_id, const uint8_t* pubkey, int key_len, uint8_t perms) { @@ -199,6 +214,8 @@ bool ClientACL::applyPermissions(const mesh::LocalIdentity& self_id, const uint8 c = getClient(pubkey, key_len); if (c == NULL) return false; // partial pubkey not found + session_keys.remove(c->id.pub_key); // also remove session key if any + num_clients--; // delete from contacts[] int i = c - clients; while (i < num_clients) { @@ -217,3 +234,198 @@ bool ClientACL::applyPermissions(const mesh::LocalIdentity& self_id, const uint8 } return true; } + +// --- Session key support (Phase 2) --- + +int ClientACL::handleSessionKeyInit(const ClientInfo* client, const uint8_t* ephemeral_pub_A, uint8_t* reply_buf, mesh::RNG* rng) { + // 1. Generate ephemeral keypair B + uint8_t seed[SEED_SIZE]; + rng->random(seed, SEED_SIZE); + uint8_t ephemeral_pub_B[PUB_KEY_SIZE]; + uint8_t ephemeral_prv_B[PRV_KEY_SIZE]; + ed25519_create_keypair(ephemeral_pub_B, ephemeral_prv_B, seed); + memset(seed, 0, SEED_SIZE); + + // 2. Compute ephemeral_secret via X25519 + uint8_t ephemeral_secret[PUB_KEY_SIZE]; + ed25519_key_exchange(ephemeral_secret, ephemeral_pub_A, ephemeral_prv_B); + memset(ephemeral_prv_B, 0, PRV_KEY_SIZE); + + // 3. Derive session_key = HMAC-SHA256(static_shared_secret, ephemeral_secret) + uint8_t new_session_key[SESSION_KEY_SIZE]; + { + SHA256 sha; + sha.resetHMAC(client->shared_secret, PUB_KEY_SIZE); + sha.update(ephemeral_secret, PUB_KEY_SIZE); + sha.finalizeHMAC(client->shared_secret, PUB_KEY_SIZE, new_session_key, SESSION_KEY_SIZE); + } + memset(ephemeral_secret, 0, PUB_KEY_SIZE); + + // 4. Store in pool (dual-decode: new key active, old key still valid) + auto entry = session_keys.allocate(client->id.pub_key); + if (!entry) return 0; + + if (entry->state == SESSION_STATE_ACTIVE || entry->state == SESSION_STATE_DUAL_DECODE) { + memcpy(entry->prev_session_key, entry->session_key, SESSION_KEY_SIZE); + } + memcpy(entry->session_key, new_session_key, SESSION_KEY_SIZE); + entry->nonce = 1; + entry->state = SESSION_STATE_DUAL_DECODE; + entry->sends_since_last_recv = 0; + memset(new_session_key, 0, SESSION_KEY_SIZE); + + // 5. Persist immediately + saveSessionKeys(); + + // 6. Write ephemeral_pub_B to reply + memcpy(reply_buf, ephemeral_pub_B, PUB_KEY_SIZE); + return PUB_KEY_SIZE; +} + +const uint8_t* ClientACL::getSessionKey(const uint8_t* pub_key) { + auto entry = session_keys.findByPrefix(pub_key); + if (entry && (entry->state == SESSION_STATE_ACTIVE || entry->state == SESSION_STATE_DUAL_DECODE)) { + return entry->session_key; + } + return nullptr; +} + +const uint8_t* ClientACL::getPrevSessionKey(const uint8_t* pub_key) { + auto entry = session_keys.findByPrefix(pub_key); + if (entry && entry->state == SESSION_STATE_DUAL_DECODE) { + return entry->prev_session_key; + } + return nullptr; +} + +const uint8_t* ClientACL::getEncryptionKey(const ClientInfo& client) { + auto entry = session_keys.findByPrefix(client.id.pub_key); + if (entry && (entry->state == SESSION_STATE_ACTIVE || entry->state == SESSION_STATE_DUAL_DECODE) + && entry->sends_since_last_recv < SESSION_KEY_STALE_THRESHOLD + && entry->nonce < 65535) { + return entry->session_key; + } + return client.shared_secret; +} + +uint16_t ClientACL::getEncryptionNonce(const ClientInfo& client) { + auto entry = session_keys.findByPrefix(client.id.pub_key); + if (entry && (entry->state == SESSION_STATE_ACTIVE || entry->state == SESSION_STATE_DUAL_DECODE) + && entry->sends_since_last_recv < SESSION_KEY_STALE_THRESHOLD + && entry->nonce < 65535) { + ++entry->nonce; + if (entry->sends_since_last_recv < 255) entry->sends_since_last_recv++; + _session_keys_dirty = true; + return entry->nonce; + } + // Progressive fallback: keep incrementing counter even when not using session key + if (entry && entry->sends_since_last_recv < 255) { + entry->sends_since_last_recv++; + if (entry->sends_since_last_recv >= SESSION_KEY_ABANDON_THRESHOLD) { + int idx = &client - clients; + if (idx >= 0 && idx < num_clients) + clients[idx].flags &= ~CONTACT_FLAG_AEAD; + session_keys.remove(client.id.pub_key); + saveSessionKeys(); + return 0; // ECB + } + if (entry->sends_since_last_recv >= SESSION_KEY_ECB_THRESHOLD) { + return 0; // ECB + } + } + return nextAeadNonceFor(client); +} + +void ClientACL::onSessionConfirmed(const uint8_t* pub_key) { + auto entry = session_keys.findByPrefix(pub_key); + if (entry) { + if (entry->state == SESSION_STATE_DUAL_DECODE) { + memset(entry->prev_session_key, 0, SESSION_KEY_SIZE); + entry->state = SESSION_STATE_ACTIVE; + saveSessionKeys(); + } + entry->sends_since_last_recv = 0; + } +} + +// --- Peer-index forwarding helpers --- + +ClientInfo* ClientACL::resolveClient(int peer_idx, const int* matching_indexes) { + int i = matching_indexes[peer_idx]; + if (i >= 0 && i < num_clients) return &clients[i]; + return nullptr; +} + +uint16_t ClientACL::peerNextAeadNonce(int peer_idx, const int* matching_indexes) { + auto* c = resolveClient(peer_idx, matching_indexes); + return c ? nextAeadNonceFor(*c) : 0; +} + +const uint8_t* ClientACL::peerSessionKey(int peer_idx, const int* matching_indexes) { + auto* c = resolveClient(peer_idx, matching_indexes); + return c ? getSessionKey(c->id.pub_key) : nullptr; +} + +const uint8_t* ClientACL::peerPrevSessionKey(int peer_idx, const int* matching_indexes) { + auto* c = resolveClient(peer_idx, matching_indexes); + return c ? getPrevSessionKey(c->id.pub_key) : nullptr; +} + +void ClientACL::peerSessionKeyDecryptSuccess(int peer_idx, const int* matching_indexes) { + auto* c = resolveClient(peer_idx, matching_indexes); + if (c) onSessionConfirmed(c->id.pub_key); +} + +const uint8_t* ClientACL::peerEncryptionKey(int peer_idx, const int* matching_indexes, const uint8_t* fallback) { + auto* c = resolveClient(peer_idx, matching_indexes); + return c ? getEncryptionKey(*c) : fallback; +} + +uint16_t ClientACL::peerEncryptionNonce(int peer_idx, const int* matching_indexes) { + auto* c = resolveClient(peer_idx, matching_indexes); + return c ? getEncryptionNonce(*c) : 0; +} + +void ClientACL::loadSessionKeys() { + if (!_fs) return; +#if defined(RP2040_PLATFORM) + File file = _fs->open("/s_sess_keys", "r"); +#elif defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) + File file = _fs->open("/s_sess_keys", FILE_O_READ); +#else + File file = _fs->open("/s_sess_keys", "r", false); +#endif + if (file) { + uint8_t rec[71]; // [pub_prefix:4][flags:1][nonce:2][session_key:32][prev_session_key:32] + while (file.read(rec, 71) == 71) { + uint8_t flags = rec[4]; + uint16_t nonce; + memcpy(&nonce, &rec[5], 2); + session_keys.applyLoaded(rec, flags, nonce, &rec[7], &rec[39]); + } + file.close(); + } +} + +void ClientACL::saveSessionKeys() { + _session_keys_dirty = false; + if (!_fs) return; + File file = openWrite(_fs, "/s_sess_keys"); + if (file) { + for (int i = 0; i < session_keys.getCount(); i++) { + uint8_t pub_key_prefix[4]; + uint8_t flags; + uint16_t nonce; + uint8_t session_key[SESSION_KEY_SIZE]; + uint8_t prev_session_key[SESSION_KEY_SIZE]; + if (session_keys.getEntryForSave(i, pub_key_prefix, &flags, &nonce, session_key, prev_session_key)) { + file.write(pub_key_prefix, 4); + file.write(&flags, 1); + file.write((uint8_t*)&nonce, 2); + file.write(session_key, SESSION_KEY_SIZE); + file.write(prev_session_key, SESSION_KEY_SIZE); + } + } + file.close(); + } +} diff --git a/src/helpers/ClientACL.h b/src/helpers/ClientACL.h index 9811d03b4..fcf33283d 100644 --- a/src/helpers/ClientACL.h +++ b/src/helpers/ClientACL.h @@ -3,6 +3,7 @@ #include // needed for PlatformIO #include #include +#include #define PERM_ACL_ROLE_MASK 3 // lower 2 bits #define PERM_ACL_GUEST 0 @@ -52,14 +53,19 @@ class ClientACL { // Nonce persistence state (parallel to clients[]) uint16_t nonce_at_last_persist[MAX_CLIENTS]; bool nonce_dirty; + bool _session_keys_dirty; mesh::RNG* _rng; + // Session key pool (Phase 2) + SessionKeyPool session_keys; + public: ClientACL() { memset(clients, 0, sizeof(clients)); memset(nonce_at_last_persist, 0, sizeof(nonce_at_last_persist)); num_clients = 0; nonce_dirty = false; + _session_keys_dirty = false; _rng = NULL; } void load(FILESYSTEM* _fs, const mesh::LocalIdentity& self_id); @@ -72,6 +78,7 @@ class ClientACL { int getNumClients() const { return num_clients; } ClientInfo* getClientByIdx(int idx) { return &clients[idx]; } + int getSessionKeyCount() const { return session_keys.getCount(); } // AEAD nonce persistence void setRNG(mesh::RNG* rng) { _rng = rng; } @@ -84,4 +91,27 @@ class ClientACL { for (int i = 0; i < num_clients; i++) nonce_at_last_persist[i] = clients[i].aead_nonce; nonce_dirty = false; } + + // Session key support (Phase 2) + int handleSessionKeyInit(const ClientInfo* client, const uint8_t* ephemeral_pub_A, uint8_t* reply_buf, mesh::RNG* rng); + const uint8_t* getSessionKey(const uint8_t* pub_key); + const uint8_t* getPrevSessionKey(const uint8_t* pub_key); + const uint8_t* getEncryptionKey(const ClientInfo& client); + uint16_t getEncryptionNonce(const ClientInfo& client); + void onSessionConfirmed(const uint8_t* pub_key); + bool isSessionKeysDirty() const { return _session_keys_dirty; } + void loadSessionKeys(); + void saveSessionKeys(); + + // Peer-index forwarding helpers for server-side Mesh overrides. + // These resolve peer_idx → ClientInfo via matching_indexes[], then delegate + // to the corresponding method above. Eliminates repeated boilerplate in + // repeater/room/sensor examples. + ClientInfo* resolveClient(int peer_idx, const int* matching_indexes); + uint16_t peerNextAeadNonce(int peer_idx, const int* matching_indexes); + const uint8_t* peerSessionKey(int peer_idx, const int* matching_indexes); + const uint8_t* peerPrevSessionKey(int peer_idx, const int* matching_indexes); + void peerSessionKeyDecryptSuccess(int peer_idx, const int* matching_indexes); + const uint8_t* peerEncryptionKey(int peer_idx, const int* matching_indexes, const uint8_t* fallback); + uint16_t peerEncryptionNonce(int peer_idx, const int* matching_indexes); }; diff --git a/src/helpers/CommonCLI.cpp b/src/helpers/CommonCLI.cpp index 410ecab74..affb9343b 100644 --- a/src/helpers/CommonCLI.cpp +++ b/src/helpers/CommonCLI.cpp @@ -783,6 +783,8 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, const char* command, ch _callbacks->formatRadioStatsReply(reply); } else if (sender_timestamp == 0 && memcmp(command, "stats-core", 10) == 0 && (command[10] == 0 || command[10] == ' ')) { _callbacks->formatStatsReply(reply); + } else if (memcmp(command, "rekey", 5) == 0) { + strcpy(reply, "rekey is client-initiated"); } else { strcpy(reply, "Unknown command"); } diff --git a/src/helpers/ContactInfo.h b/src/helpers/ContactInfo.h index 3d20ff7a1..bd7977987 100644 --- a/src/helpers/ContactInfo.h +++ b/src/helpers/ContactInfo.h @@ -25,6 +25,10 @@ struct ContactInfo { ++aead_nonce; // skip 0 (sentinel for ECB) MESH_DEBUG_PRINTLN("AEAD nonce wrapped for peer: %s", name); } + if (aead_nonce < NONCE_INITIAL_MIN) { + aead_nonce = 1; // stay stuck in exhaustion zone, always return ECB + return 0; + } return aead_nonce; } return 0; diff --git a/src/helpers/SessionKeyPool.h b/src/helpers/SessionKeyPool.h new file mode 100644 index 000000000..b2c168a61 --- /dev/null +++ b/src/helpers/SessionKeyPool.h @@ -0,0 +1,117 @@ +#pragma once + +#include +#include + +#define SESSION_STATE_NONE 0 +#define SESSION_STATE_INIT_SENT 1 // initiator: INIT sent, waiting for ACCEPT +#define SESSION_STATE_DUAL_DECODE 2 // responder: new key active, old key still valid +#define SESSION_STATE_ACTIVE 3 // session key confirmed and in use + +#define SESSION_FLAG_PREV_VALID 0x01 // prev_session_key is valid for dual-decode + +struct SessionKeyEntry { + uint8_t peer_pub_prefix[4]; // first 4 bytes of peer's public key + uint8_t session_key[SESSION_KEY_SIZE]; + uint8_t prev_session_key[SESSION_KEY_SIZE]; + uint16_t nonce; // session key nonce counter (starts at 1) + uint8_t state; // SESSION_STATE_* + uint8_t sends_since_last_recv; // RAM-only counter, threshold SESSION_KEY_STALE_THRESHOLD + uint8_t retries_left; // remaining INIT retries this round + unsigned long timeout_at; // millis timestamp for INIT timeout + uint8_t ephemeral_prv[PRV_KEY_SIZE]; // initiator-only: ephemeral private key (zeroed after use) + uint8_t ephemeral_pub[PUB_KEY_SIZE]; // initiator-only: ephemeral public key +}; + +class SessionKeyPool { + SessionKeyEntry entries[MAX_SESSION_KEYS]; + int count; + +public: + SessionKeyPool() : count(0) { + memset(entries, 0, sizeof(entries)); + } + + SessionKeyEntry* findByPrefix(const uint8_t* pub_key) { + for (int i = 0; i < count; i++) { + if (memcmp(entries[i].peer_pub_prefix, pub_key, 4) == 0) { + return &entries[i]; + } + } + return nullptr; + } + + SessionKeyEntry* allocate(const uint8_t* pub_key) { + // Check if already exists + auto existing = findByPrefix(pub_key); + if (existing) return existing; + + // Find free slot or evict oldest + if (count < MAX_SESSION_KEYS) { + auto e = &entries[count++]; + memset(e, 0, sizeof(*e)); + memcpy(e->peer_pub_prefix, pub_key, 4); + return e; + } + // Pool full — evict the entry with state NONE, or the first one + for (int i = 0; i < MAX_SESSION_KEYS; i++) { + if (entries[i].state == SESSION_STATE_NONE) { + memset(&entries[i], 0, sizeof(entries[i])); + memcpy(entries[i].peer_pub_prefix, pub_key, 4); + return &entries[i]; + } + } + // All slots active — evict first entry + memset(&entries[0], 0, sizeof(entries[0])); + memcpy(entries[0].peer_pub_prefix, pub_key, 4); + return &entries[0]; + } + + void remove(const uint8_t* pub_key) { + for (int i = 0; i < count; i++) { + if (memcmp(entries[i].peer_pub_prefix, pub_key, 4) == 0) { + // Shift remaining entries down + count--; + for (int j = i; j < count; j++) { + entries[j] = entries[j + 1]; + } + memset(&entries[count], 0, sizeof(entries[count])); + return; + } + } + } + + int getCount() const { return count; } + SessionKeyEntry* getByIdx(int idx) { return (idx >= 0 && idx < count) ? &entries[idx] : nullptr; } + + // Persistence helpers — 71-byte records: [pub_prefix:4][flags:1][nonce:2][session_key:32][prev_session_key:32] + // Returns false when idx is past end + bool getEntryForSave(int idx, uint8_t* pub_key_prefix, uint8_t* flags, uint16_t* nonce, + uint8_t* session_key, uint8_t* prev_session_key) { + if (idx >= count) return false; + auto& e = entries[idx]; + if (e.state == SESSION_STATE_NONE || e.state == SESSION_STATE_INIT_SENT) return false; // don't persist pending negotiations + memcpy(pub_key_prefix, e.peer_pub_prefix, 4); + *flags = (e.state == SESSION_STATE_DUAL_DECODE) ? SESSION_FLAG_PREV_VALID : 0; + *nonce = e.nonce; + memcpy(session_key, e.session_key, SESSION_KEY_SIZE); + memcpy(prev_session_key, e.prev_session_key, SESSION_KEY_SIZE); + return true; + } + + bool applyLoaded(const uint8_t* pub_key_prefix, uint8_t flags, uint16_t nonce, + const uint8_t* session_key, const uint8_t* prev_session_key) { + auto e = allocate(pub_key_prefix); + if (!e) return false; + e->nonce = nonce; + e->state = (flags & SESSION_FLAG_PREV_VALID) ? SESSION_STATE_DUAL_DECODE : SESSION_STATE_ACTIVE; + e->sends_since_last_recv = 0; + e->retries_left = 0; + e->timeout_at = 0; + memcpy(e->session_key, session_key, SESSION_KEY_SIZE); + memcpy(e->prev_session_key, prev_session_key, SESSION_KEY_SIZE); + memset(e->ephemeral_prv, 0, sizeof(e->ephemeral_prv)); + memset(e->ephemeral_pub, 0, sizeof(e->ephemeral_pub)); + return true; + } +}; From 07db4bcdd80cff666832b83d05d1777ed275e447 Mon Sep 17 00:00:00 2001 From: Wessel Nieboer Date: Sat, 14 Feb 2026 13:39:52 +0100 Subject: [PATCH 10/11] Allow persisting more session to flash --- examples/companion_radio/DataStore.cpp | 114 ++++++++++++--- examples/companion_radio/DataStore.h | 4 + examples/companion_radio/MyMesh.cpp | 8 ++ examples/companion_radio/MyMesh.h | 11 +- src/MeshCore.h | 6 +- src/helpers/BaseChatMesh.cpp | 57 ++++++-- src/helpers/BaseChatMesh.h | 12 ++ src/helpers/ClientACL.cpp | 192 ++++++++++++++++++++----- src/helpers/ClientACL.h | 10 ++ src/helpers/SessionKeyPool.h | 72 +++++++--- 10 files changed, 394 insertions(+), 92 deletions(-) diff --git a/examples/companion_radio/DataStore.cpp b/examples/companion_radio/DataStore.cpp index 2f97d204a..9cf11fe10 100644 --- a/examples/companion_radio/DataStore.cpp +++ b/examples/companion_radio/DataStore.cpp @@ -405,37 +405,105 @@ bool DataStore::saveNonces(DataStoreHost* host) { void DataStore::loadSessionKeys(DataStoreHost* host) { File file = openRead(_getContactsChannelsFS(), "/sess_keys"); - if (file) { - uint8_t rec[71]; // 4-byte pub_key prefix + 1 flags + 2 nonce + 32 session_key + 32 prev_session_key - while (file.read(rec, 71) == 71) { - uint16_t nonce; - memcpy(&nonce, &rec[5], 2); - host->onSessionKeyLoaded(rec, rec[4], nonce, &rec[7], &rec[39]); + if (!file) return; + while (true) { + uint8_t rec[SESSION_KEY_RECORD_MIN_SIZE]; + if (file.read(rec, SESSION_KEY_RECORD_MIN_SIZE) != SESSION_KEY_RECORD_MIN_SIZE) break; + uint8_t flags = rec[4]; + uint16_t nonce; + memcpy(&nonce, &rec[5], 2); + uint8_t prev_key[SESSION_KEY_SIZE]; + if (flags & SESSION_FLAG_PREV_VALID) { + if (file.read(prev_key, SESSION_KEY_SIZE) != SESSION_KEY_SIZE) break; + } else { + memset(prev_key, 0, SESSION_KEY_SIZE); } - file.close(); + host->onSessionKeyLoaded(rec, flags, nonce, &rec[7], prev_key); } + file.close(); } bool DataStore::saveSessionKeys(DataStoreHost* host) { - File file = openWrite(_getContactsChannelsFS(), "/sess_keys"); - if (file) { - uint8_t pub_key_prefix[4]; - uint8_t flags; - uint16_t nonce; - uint8_t session_key[32]; - uint8_t prev_session_key[32]; - for (int idx = 0; idx < MAX_SESSION_KEYS; idx++) { - if (host->getSessionKeyForSave(idx, pub_key_prefix, &flags, &nonce, session_key, prev_session_key)) { - file.write(pub_key_prefix, 4); - file.write(&flags, 1); - file.write((uint8_t*)&nonce, 2); - file.write(session_key, 32); - file.write(prev_session_key, 32); + FILESYSTEM* fs = _getContactsChannelsFS(); + + // 1. Read old flash file into buffer (variable-length records) + uint8_t old_buf[MAX_SESSION_KEYS_FLASH * SESSION_KEY_RECORD_SIZE]; + int old_len = 0; + File rf = openRead(fs, "/sess_keys"); + if (rf) { + while (true) { + if (old_len + SESSION_KEY_RECORD_MIN_SIZE > (int)sizeof(old_buf)) break; + if (rf.read(&old_buf[old_len], SESSION_KEY_RECORD_MIN_SIZE) != SESSION_KEY_RECORD_MIN_SIZE) break; + uint8_t flags = old_buf[old_len + 4]; + int rec_len = SESSION_KEY_RECORD_MIN_SIZE; + if (flags & SESSION_FLAG_PREV_VALID) { + if (old_len + SESSION_KEY_RECORD_SIZE > (int)sizeof(old_buf)) break; + if (rf.read(&old_buf[old_len + SESSION_KEY_RECORD_MIN_SIZE], SESSION_KEY_SIZE) != SESSION_KEY_SIZE) break; + rec_len = SESSION_KEY_RECORD_SIZE; } + old_len += rec_len; + } + rf.close(); + } + + // 2. Write merged file + File wf = openWrite(fs, "/sess_keys"); + if (!wf) return false; + + // Write kept old records (variable-length) + int pos = 0; + while (pos + SESSION_KEY_RECORD_MIN_SIZE <= old_len) { + uint8_t* rec = &old_buf[pos]; + uint8_t flags = rec[4]; + int rec_len = (flags & SESSION_FLAG_PREV_VALID) ? SESSION_KEY_RECORD_SIZE : SESSION_KEY_RECORD_MIN_SIZE; + if (pos + rec_len > old_len) break; + if (!host->isSessionKeyInRAM(rec) && !host->isSessionKeyRemoved(rec)) { + wf.write(rec, rec_len); + } + pos += rec_len; + } + // Write current RAM entries (variable-length) + for (int idx = 0; idx < MAX_SESSION_KEYS_RAM; idx++) { + uint8_t pk[4]; uint8_t fl; uint16_t n; uint8_t sk[32]; uint8_t psk[32]; + if (!host->getSessionKeyForSave(idx, pk, &fl, &n, sk, psk)) continue; + wf.write(pk, 4); wf.write(&fl, 1); wf.write((uint8_t*)&n, 2); + wf.write(sk, 32); + if (fl & SESSION_FLAG_PREV_VALID) { + wf.write(psk, 32); + } + } + wf.close(); + return true; +} + +bool DataStore::loadSessionKeyByPrefix(const uint8_t* prefix, + uint8_t* flags, uint16_t* nonce, uint8_t* session_key, uint8_t* prev_session_key) { + File f = openRead(_getContactsChannelsFS(), "/sess_keys"); + if (!f) return false; + while (true) { + uint8_t rec[SESSION_KEY_RECORD_MIN_SIZE]; + if (f.read(rec, SESSION_KEY_RECORD_MIN_SIZE) != SESSION_KEY_RECORD_MIN_SIZE) break; + uint8_t rec_flags = rec[4]; + bool has_prev = (rec_flags & SESSION_FLAG_PREV_VALID); + if (memcmp(rec, prefix, 4) == 0) { + *flags = rec_flags; + memcpy(nonce, &rec[5], 2); + memcpy(session_key, &rec[7], SESSION_KEY_SIZE); + if (has_prev) { + if (f.read(prev_session_key, SESSION_KEY_SIZE) != SESSION_KEY_SIZE) break; + } else { + memset(prev_session_key, 0, SESSION_KEY_SIZE); + } + f.close(); + return true; + } + // Skip prev_key if present + if (has_prev) { + uint8_t skip[SESSION_KEY_SIZE]; + if (f.read(skip, SESSION_KEY_SIZE) != SESSION_KEY_SIZE) break; } - file.close(); - return true; } + f.close(); return false; } diff --git a/examples/companion_radio/DataStore.h b/examples/companion_radio/DataStore.h index 5f4170530..011ce5a3d 100644 --- a/examples/companion_radio/DataStore.h +++ b/examples/companion_radio/DataStore.h @@ -17,6 +17,8 @@ class DataStoreHost { const uint8_t* session_key, const uint8_t* prev_session_key) { return false; } virtual bool getSessionKeyForSave(int idx, uint8_t* pub_key_prefix, uint8_t* flags, uint16_t* nonce, uint8_t* session_key, uint8_t* prev_session_key) { return false; } + virtual bool isSessionKeyInRAM(const uint8_t* pub_key_prefix) { return false; } + virtual bool isSessionKeyRemoved(const uint8_t* pub_key_prefix) { return false; } }; class DataStore { @@ -49,6 +51,8 @@ class DataStore { bool saveNonces(DataStoreHost* host); void loadSessionKeys(DataStoreHost* host); bool saveSessionKeys(DataStoreHost* host); + bool loadSessionKeyByPrefix(const uint8_t* prefix, + uint8_t* flags, uint16_t* nonce, uint8_t* session_key, uint8_t* prev_session_key); void migrateToSecondaryFS(); uint8_t getBlobByKey(const uint8_t key[], int key_len, uint8_t dest_buf[]); bool putBlobByKey(const uint8_t key[], int key_len, const uint8_t src_buf[], uint8_t len); diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index 725bb2c5e..618150159 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -1980,6 +1980,14 @@ void MyMesh::checkSerialInterface() { } } +bool MyMesh::isSessionKeyInRAM(const uint8_t* pub_key_prefix) { + return isSessionKeyInRAMPool(pub_key_prefix); +} + +bool MyMesh::isSessionKeyRemoved(const uint8_t* pub_key_prefix) { + return isSessionKeyRemovedFromPool(pub_key_prefix); +} + void MyMesh::loop() { BaseChatMesh::loop(); diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index 1bd4c1c30..8be9306a6 100644 --- a/examples/companion_radio/MyMesh.h +++ b/examples/companion_radio/MyMesh.h @@ -161,6 +161,8 @@ class MyMesh : public BaseChatMesh, public DataStoreHost { uint8_t* session_key, uint8_t* prev_session_key) override { return getSessionKeyEntry(idx, pub_key_prefix, flags, nonce, session_key, prev_session_key); } + bool isSessionKeyInRAM(const uint8_t* pub_key_prefix) override; + bool isSessionKeyRemoved(const uint8_t* pub_key_prefix) override; void clearPendingReqs() { pending_login = pending_status = pending_telemetry = pending_discovery = pending_req = 0; @@ -191,9 +193,16 @@ class MyMesh : public BaseChatMesh, public DataStoreHost { void saveChannels() { _store->saveChannels(this); } void saveContacts() { _store->saveContacts(this); } void saveNonces() { if (_store->saveNonces(this)) clearNonceDirty(); } - void saveSessionKeys() { if (_store->saveSessionKeys(this)) clearSessionKeysDirty(); } + void saveSessionKeys() { if (_store->saveSessionKeys(this)) { clearSessionKeysDirty(); clearSessionKeysRemoved(); } } void onSessionKeysUpdated() override { saveSessionKeys(); } + // Flash-backed session key overrides + bool loadSessionKeyRecordFromFlash(const uint8_t* pub_key_prefix, + uint8_t* flags, uint16_t* nonce, uint8_t* session_key, uint8_t* prev_session_key) override { + return _store->loadSessionKeyByPrefix(pub_key_prefix, flags, nonce, session_key, prev_session_key); + } + void mergeAndSaveSessionKeys() override { saveSessionKeys(); } + DataStore* _store; NodePrefs _prefs; uint32_t pending_login; diff --git a/src/MeshCore.h b/src/MeshCore.h index 8725e9057..ad73a02ab 100644 --- a/src/MeshCore.h +++ b/src/MeshCore.h @@ -36,7 +36,11 @@ #define NONCE_INITIAL_MAX 50000 // max random nonce seed for new contacts #define SESSION_KEY_TIMEOUT_MS 180000 // 3 minutes per attempt #define SESSION_KEY_MAX_RETRIES 3 // attempts per negotiation round -#define MAX_SESSION_KEYS 8 // max concurrent session key entries +#define MAX_SESSION_KEYS_RAM 8 // max concurrent session key entries in RAM (LRU cache) +#define MAX_SESSION_KEYS_FLASH 48 // max entries in flash file +#define SESSION_KEY_RECORD_SIZE 71 // max bytes per record (with prev_key) +#define SESSION_KEY_RECORD_MIN_SIZE 39 // min bytes per record: [pub_prefix:4][flags:1][nonce:2][key:32] +#define SESSION_FLAG_PREV_VALID 0x01 // prev_session_key is valid for dual-decode #define SESSION_KEY_STALE_THRESHOLD 50 // sends without recv before fallback to static ECDH #define SESSION_KEY_ECB_THRESHOLD 100 // sends without recv before fallback to ECB #define SESSION_KEY_ABANDON_THRESHOLD 255 // sends without recv before clearing AEAD + session key diff --git a/src/helpers/BaseChatMesh.cpp b/src/helpers/BaseChatMesh.cpp index a34cc8a7b..8fe216677 100644 --- a/src/helpers/BaseChatMesh.cpp +++ b/src/helpers/BaseChatMesh.cpp @@ -900,7 +900,7 @@ bool BaseChatMesh::removeContact(ContactInfo& contact) { } if (idx >= num_contacts) return false; // not found - session_keys.remove(contact.id.pub_key); // also remove session key if any + removeSessionKey(contact.id.pub_key); // also remove session key if any // remove from contacts array and parallel nonce tracking num_contacts--; @@ -1015,6 +1015,41 @@ void BaseChatMesh::loop() { } } +// --- Session key flash-backed wrappers --- + +SessionKeyEntry* BaseChatMesh::findSessionKey(const uint8_t* pub_key) { + auto entry = session_keys.findByPrefix(pub_key); + if (entry) return entry; + + // Cache miss — try flash + uint8_t flags; uint16_t nonce; + uint8_t sk[SESSION_KEY_SIZE], psk[SESSION_KEY_SIZE]; + if (!loadSessionKeyRecordFromFlash(pub_key, &flags, &nonce, sk, psk)) return nullptr; + + // Save dirty evictee before overwriting + if (session_keys.isFull() && session_keys_dirty) { + mergeAndSaveSessionKeys(); + } + session_keys.applyLoaded(pub_key, flags, nonce, sk, psk); + return session_keys.findByPrefix(pub_key); +} + +SessionKeyEntry* BaseChatMesh::allocateSessionKey(const uint8_t* pub_key) { + // Check RAM and flash first + auto entry = findSessionKey(pub_key); + if (entry) return entry; + + // Not found anywhere — save dirty evictee before allocating + if (session_keys.isFull() && session_keys_dirty) { + mergeAndSaveSessionKeys(); + } + return session_keys.allocate(pub_key); +} + +void BaseChatMesh::removeSessionKey(const uint8_t* pub_key) { + session_keys.remove(pub_key); +} + // --- Session key support (Phase 2 — initiator) --- static bool canUseSessionKey(const SessionKeyEntry* entry) { @@ -1030,7 +1065,7 @@ static bool canUseSessionKey(const SessionKeyEntry* entry) { } const uint8_t* BaseChatMesh::getEncryptionKeyFor(const ContactInfo& contact) { - auto entry = session_keys.findByPrefix(contact.id.pub_key); + auto entry = findSessionKey(contact.id.pub_key); if (canUseSessionKey(entry)) { return entry->session_key; } @@ -1039,7 +1074,7 @@ const uint8_t* BaseChatMesh::getEncryptionKeyFor(const ContactInfo& contact) { uint16_t BaseChatMesh::getEncryptionNonceFor(const ContactInfo& contact) { uint16_t nonce = 0; - auto entry = session_keys.findByPrefix(contact.id.pub_key); + auto entry = findSessionKey(contact.id.pub_key); if (canUseSessionKey(entry)) { ++entry->nonce; if (entry->sends_since_last_recv < 255) entry->sends_since_last_recv++; @@ -1053,7 +1088,7 @@ uint16_t BaseChatMesh::getEncryptionNonceFor(const ContactInfo& contact) { int idx = &contact - contacts; if (idx >= 0 && idx < num_contacts) contacts[idx].flags &= ~CONTACT_FLAG_AEAD; - session_keys.remove(contact.id.pub_key); + removeSessionKey(contact.id.pub_key); onSessionKeysUpdated(); // nonce = 0 (ECB) } else if (entry->sends_since_last_recv >= SESSION_KEY_ECB_THRESHOLD) { @@ -1082,7 +1117,7 @@ bool BaseChatMesh::shouldInitiateSessionKey(const ContactInfo& contact) { // Need a known path to send the request if (contact.out_path_len < 0) return false; - auto entry = session_keys.findByPrefix(contact.id.pub_key); + auto entry = findSessionKey(contact.id.pub_key); // Don't trigger if negotiation already in progress if (entry && entry->state == SESSION_STATE_INIT_SENT) return false; @@ -1118,7 +1153,7 @@ bool BaseChatMesh::shouldInitiateSessionKey(const ContactInfo& contact) { } bool BaseChatMesh::initiateSessionKeyNegotiation(const ContactInfo& contact) { - auto entry = session_keys.allocate(contact.id.pub_key); + auto entry = allocateSessionKey(contact.id.pub_key); if (!entry) return false; // Don't start a new negotiation if one is already pending @@ -1154,7 +1189,7 @@ bool BaseChatMesh::handleSessionKeyResponse(ContactInfo& contact, const uint8_t* if (len < 5 + PUB_KEY_SIZE) return false; if (data[4] != RESP_TYPE_SESSION_KEY_ACCEPT) return false; - auto entry = session_keys.findByPrefix(contact.id.pub_key); + auto entry = findSessionKey(contact.id.pub_key); if (!entry || entry->state != SESSION_STATE_INIT_SENT) return false; const uint8_t* ephemeral_pub_B = &data[5]; @@ -1216,7 +1251,7 @@ uint8_t BaseChatMesh::handleIncomingSessionKeyInit(ContactInfo& from, const uint memset(ephemeral_secret, 0, PUB_KEY_SIZE); // 4. Store in pool (dual-decode: new key active, old key still valid) - auto entry = session_keys.allocate(from.id.pub_key); + auto entry = allocateSessionKey(from.id.pub_key); if (!entry) return 0; if (entry->state == SESSION_STATE_ACTIVE || entry->state == SESSION_STATE_DUAL_DECODE) { @@ -1279,7 +1314,7 @@ void BaseChatMesh::checkSessionKeyTimeouts() { const uint8_t* BaseChatMesh::getPeerSessionKey(int peer_idx) { int i = matching_peer_indexes[peer_idx]; if (i >= 0 && i < num_contacts) { - auto entry = session_keys.findByPrefix(contacts[i].id.pub_key); + auto entry = findSessionKey(contacts[i].id.pub_key); // Also try decode during INIT_SENT renegotiation (nonce > 1 means prior key exists) if (entry && (entry->state == SESSION_STATE_ACTIVE || entry->state == SESSION_STATE_DUAL_DECODE || (entry->state == SESSION_STATE_INIT_SENT && entry->nonce > 1))) @@ -1291,7 +1326,7 @@ const uint8_t* BaseChatMesh::getPeerSessionKey(int peer_idx) { const uint8_t* BaseChatMesh::getPeerPrevSessionKey(int peer_idx) { int i = matching_peer_indexes[peer_idx]; if (i >= 0 && i < num_contacts) { - auto entry = session_keys.findByPrefix(contacts[i].id.pub_key); + auto entry = findSessionKey(contacts[i].id.pub_key); if (entry && entry->state == SESSION_STATE_DUAL_DECODE) return entry->prev_session_key; } @@ -1301,7 +1336,7 @@ const uint8_t* BaseChatMesh::getPeerPrevSessionKey(int peer_idx) { void BaseChatMesh::onSessionKeyDecryptSuccess(int peer_idx) { int i = matching_peer_indexes[peer_idx]; if (i >= 0 && i < num_contacts) { - auto entry = session_keys.findByPrefix(contacts[i].id.pub_key); + auto entry = findSessionKey(contacts[i].id.pub_key); if (entry) { bool changed = (entry->state == SESSION_STATE_DUAL_DECODE); if (changed) { diff --git a/src/helpers/BaseChatMesh.h b/src/helpers/BaseChatMesh.h index 24e14ffdf..5578b197b 100644 --- a/src/helpers/BaseChatMesh.h +++ b/src/helpers/BaseChatMesh.h @@ -148,6 +148,15 @@ class BaseChatMesh : public mesh::Mesh { // Session key support (Phase 2 — initiator) virtual void onSessionKeysUpdated() { session_keys_dirty = true; } // called when session key pool changes; override to persist + virtual bool loadSessionKeyRecordFromFlash(const uint8_t* pub_key_prefix, + uint8_t* flags, uint16_t* nonce, uint8_t* session_key, uint8_t* prev_session_key) { return false; } + virtual void mergeAndSaveSessionKeys() {} // merge RAM + flash, write back + + // Wrappers that add flash fallback on cache miss + SessionKeyEntry* findSessionKey(const uint8_t* pub_key); + SessionKeyEntry* allocateSessionKey(const uint8_t* pub_key); + void removeSessionKey(const uint8_t* pub_key); + const uint8_t* getEncryptionKeyFor(const ContactInfo& contact); uint16_t getEncryptionNonceFor(const ContactInfo& contact); bool shouldInitiateSessionKey(const ContactInfo& contact); @@ -164,6 +173,9 @@ class BaseChatMesh : public mesh::Mesh { int getSessionKeyCount() const { return session_keys.getCount(); } bool isSessionKeysDirty() const { return session_keys_dirty; } void clearSessionKeysDirty() { session_keys_dirty = false; } + bool isSessionKeyInRAMPool(const uint8_t* pub_key_prefix) { return session_keys.hasPrefix(pub_key_prefix); } + bool isSessionKeyRemovedFromPool(const uint8_t* pub_key_prefix) { return session_keys.isRemoved(pub_key_prefix); } + void clearSessionKeysRemoved() { session_keys.clearRemoved(); } // Mesh overrides void onAdvertRecv(mesh::Packet* packet, const mesh::Identity& id, uint32_t timestamp, const uint8_t* app_data, size_t app_data_len) override; diff --git a/src/helpers/ClientACL.cpp b/src/helpers/ClientACL.cpp index 7188f3468..03687aaa8 100644 --- a/src/helpers/ClientACL.cpp +++ b/src/helpers/ClientACL.cpp @@ -214,7 +214,7 @@ bool ClientACL::applyPermissions(const mesh::LocalIdentity& self_id, const uint8 c = getClient(pubkey, key_len); if (c == NULL) return false; // partial pubkey not found - session_keys.remove(c->id.pub_key); // also remove session key if any + removeSessionKey(c->id.pub_key); // also remove session key if any num_clients--; // delete from contacts[] int i = c - clients; @@ -262,7 +262,7 @@ int ClientACL::handleSessionKeyInit(const ClientInfo* client, const uint8_t* eph memset(ephemeral_secret, 0, PUB_KEY_SIZE); // 4. Store in pool (dual-decode: new key active, old key still valid) - auto entry = session_keys.allocate(client->id.pub_key); + auto entry = allocateSessionKey(client->id.pub_key); if (!entry) return 0; if (entry->state == SESSION_STATE_ACTIVE || entry->state == SESSION_STATE_DUAL_DECODE) { @@ -283,7 +283,7 @@ int ClientACL::handleSessionKeyInit(const ClientInfo* client, const uint8_t* eph } const uint8_t* ClientACL::getSessionKey(const uint8_t* pub_key) { - auto entry = session_keys.findByPrefix(pub_key); + auto entry = findSessionKey(pub_key); if (entry && (entry->state == SESSION_STATE_ACTIVE || entry->state == SESSION_STATE_DUAL_DECODE)) { return entry->session_key; } @@ -291,7 +291,7 @@ const uint8_t* ClientACL::getSessionKey(const uint8_t* pub_key) { } const uint8_t* ClientACL::getPrevSessionKey(const uint8_t* pub_key) { - auto entry = session_keys.findByPrefix(pub_key); + auto entry = findSessionKey(pub_key); if (entry && entry->state == SESSION_STATE_DUAL_DECODE) { return entry->prev_session_key; } @@ -299,7 +299,7 @@ const uint8_t* ClientACL::getPrevSessionKey(const uint8_t* pub_key) { } const uint8_t* ClientACL::getEncryptionKey(const ClientInfo& client) { - auto entry = session_keys.findByPrefix(client.id.pub_key); + auto entry = findSessionKey(client.id.pub_key); if (entry && (entry->state == SESSION_STATE_ACTIVE || entry->state == SESSION_STATE_DUAL_DECODE) && entry->sends_since_last_recv < SESSION_KEY_STALE_THRESHOLD && entry->nonce < 65535) { @@ -309,7 +309,7 @@ const uint8_t* ClientACL::getEncryptionKey(const ClientInfo& client) { } uint16_t ClientACL::getEncryptionNonce(const ClientInfo& client) { - auto entry = session_keys.findByPrefix(client.id.pub_key); + auto entry = findSessionKey(client.id.pub_key); if (entry && (entry->state == SESSION_STATE_ACTIVE || entry->state == SESSION_STATE_DUAL_DECODE) && entry->sends_since_last_recv < SESSION_KEY_STALE_THRESHOLD && entry->nonce < 65535) { @@ -325,7 +325,7 @@ uint16_t ClientACL::getEncryptionNonce(const ClientInfo& client) { int idx = &client - clients; if (idx >= 0 && idx < num_clients) clients[idx].flags &= ~CONTACT_FLAG_AEAD; - session_keys.remove(client.id.pub_key); + removeSessionKey(client.id.pub_key); saveSessionKeys(); return 0; // ECB } @@ -337,7 +337,7 @@ uint16_t ClientACL::getEncryptionNonce(const ClientInfo& client) { } void ClientACL::onSessionConfirmed(const uint8_t* pub_key) { - auto entry = session_keys.findByPrefix(pub_key); + auto entry = findSessionKey(pub_key); if (entry) { if (entry->state == SESSION_STATE_DUAL_DECODE) { memset(entry->prev_session_key, 0, SESSION_KEY_SIZE); @@ -386,46 +386,160 @@ uint16_t ClientACL::peerEncryptionNonce(int peer_idx, const int* matching_indexe return c ? getEncryptionNonce(*c) : 0; } -void ClientACL::loadSessionKeys() { - if (!_fs) return; -#if defined(RP2040_PLATFORM) - File file = _fs->open("/s_sess_keys", "r"); -#elif defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) - File file = _fs->open("/s_sess_keys", FILE_O_READ); +// --- Flash-backed session key wrappers --- + +static File openReadACL(FILESYSTEM* fs, const char* filename) { +#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) + return fs->open(filename, FILE_O_READ); +#elif defined(RP2040_PLATFORM) + return fs->open(filename, "r"); #else - File file = _fs->open("/s_sess_keys", "r", false); + return fs->open(filename, "r", false); #endif - if (file) { - uint8_t rec[71]; // [pub_prefix:4][flags:1][nonce:2][session_key:32][prev_session_key:32] - while (file.read(rec, 71) == 71) { - uint8_t flags = rec[4]; - uint16_t nonce; - memcpy(&nonce, &rec[5], 2); - session_keys.applyLoaded(rec, flags, nonce, &rec[7], &rec[39]); +} + +bool ClientACL::loadSessionKeyRecordFromFlash(const uint8_t* prefix, + uint8_t* flags, uint16_t* nonce, uint8_t* session_key, uint8_t* prev_session_key) { + if (!_fs) return false; + File f = openReadACL(_fs, "/s_sess_keys"); + if (!f) return false; + while (true) { + uint8_t rec[SESSION_KEY_RECORD_MIN_SIZE]; + if (f.read(rec, SESSION_KEY_RECORD_MIN_SIZE) != SESSION_KEY_RECORD_MIN_SIZE) break; + uint8_t rec_flags = rec[4]; + bool has_prev = (rec_flags & SESSION_FLAG_PREV_VALID); + if (memcmp(rec, prefix, 4) == 0) { + *flags = rec_flags; + memcpy(nonce, &rec[5], 2); + memcpy(session_key, &rec[7], SESSION_KEY_SIZE); + if (has_prev) { + if (f.read(prev_session_key, SESSION_KEY_SIZE) != SESSION_KEY_SIZE) break; + } else { + memset(prev_session_key, 0, SESSION_KEY_SIZE); + } + f.close(); + return true; } - file.close(); + // Skip prev_key if present + if (has_prev) { + uint8_t skip[SESSION_KEY_SIZE]; + if (f.read(skip, SESSION_KEY_SIZE) != SESSION_KEY_SIZE) break; + } + } + f.close(); + return false; +} + +SessionKeyEntry* ClientACL::findSessionKey(const uint8_t* pub_key) { + auto entry = session_keys.findByPrefix(pub_key); + if (entry) return entry; + + // Cache miss — try flash + uint8_t flags; uint16_t nonce; + uint8_t sk[SESSION_KEY_SIZE], psk[SESSION_KEY_SIZE]; + if (!loadSessionKeyRecordFromFlash(pub_key, &flags, &nonce, sk, psk)) return nullptr; + + // Save dirty evictee before overwriting + if (session_keys.isFull() && _session_keys_dirty) { + saveSessionKeys(); + } + session_keys.applyLoaded(pub_key, flags, nonce, sk, psk); + return session_keys.findByPrefix(pub_key); +} + +SessionKeyEntry* ClientACL::allocateSessionKey(const uint8_t* pub_key) { + auto entry = findSessionKey(pub_key); + if (entry) return entry; + + // Not found anywhere — save dirty evictee before allocating + if (session_keys.isFull() && _session_keys_dirty) { + saveSessionKeys(); + } + return session_keys.allocate(pub_key); +} + +void ClientACL::removeSessionKey(const uint8_t* pub_key) { + session_keys.remove(pub_key); +} + +void ClientACL::loadSessionKeys() { + if (!_fs) return; + File file = openReadACL(_fs, "/s_sess_keys"); + if (!file) return; + while (true) { + uint8_t rec[SESSION_KEY_RECORD_MIN_SIZE]; + if (file.read(rec, SESSION_KEY_RECORD_MIN_SIZE) != SESSION_KEY_RECORD_MIN_SIZE) break; + uint8_t flags = rec[4]; + uint16_t nonce; + memcpy(&nonce, &rec[5], 2); + uint8_t prev_key[SESSION_KEY_SIZE]; + if (flags & SESSION_FLAG_PREV_VALID) { + if (file.read(prev_key, SESSION_KEY_SIZE) != SESSION_KEY_SIZE) break; + } else { + memset(prev_key, 0, SESSION_KEY_SIZE); + } + session_keys.applyLoaded(rec, flags, nonce, &rec[7], prev_key); } + file.close(); } void ClientACL::saveSessionKeys() { - _session_keys_dirty = false; if (!_fs) return; - File file = openWrite(_fs, "/s_sess_keys"); - if (file) { - for (int i = 0; i < session_keys.getCount(); i++) { - uint8_t pub_key_prefix[4]; - uint8_t flags; - uint16_t nonce; - uint8_t session_key[SESSION_KEY_SIZE]; - uint8_t prev_session_key[SESSION_KEY_SIZE]; - if (session_keys.getEntryForSave(i, pub_key_prefix, &flags, &nonce, session_key, prev_session_key)) { - file.write(pub_key_prefix, 4); - file.write(&flags, 1); - file.write((uint8_t*)&nonce, 2); - file.write(session_key, SESSION_KEY_SIZE); - file.write(prev_session_key, SESSION_KEY_SIZE); + + // 1. Read old flash file into buffer (variable-length records) + uint8_t old_buf[MAX_SESSION_KEYS_FLASH * SESSION_KEY_RECORD_SIZE]; + int old_len = 0; + File rf = openReadACL(_fs, "/s_sess_keys"); + if (rf) { + while (true) { + if (old_len + SESSION_KEY_RECORD_MIN_SIZE > (int)sizeof(old_buf)) break; + if (rf.read(&old_buf[old_len], SESSION_KEY_RECORD_MIN_SIZE) != SESSION_KEY_RECORD_MIN_SIZE) break; + uint8_t flags = old_buf[old_len + 4]; + int rec_len = SESSION_KEY_RECORD_MIN_SIZE; + if (flags & SESSION_FLAG_PREV_VALID) { + if (old_len + SESSION_KEY_RECORD_SIZE > (int)sizeof(old_buf)) break; + if (rf.read(&old_buf[old_len + SESSION_KEY_RECORD_MIN_SIZE], SESSION_KEY_SIZE) != SESSION_KEY_SIZE) break; + rec_len = SESSION_KEY_RECORD_SIZE; } + old_len += rec_len; } - file.close(); + rf.close(); } + + // 2. Write merged file + File wf = openWrite(_fs, "/s_sess_keys"); + if (!wf) return; + + // Write kept old records (variable-length) + int pos = 0; + while (pos + SESSION_KEY_RECORD_MIN_SIZE <= old_len) { + uint8_t* rec = &old_buf[pos]; + uint8_t flags = rec[4]; + int rec_len = (flags & SESSION_FLAG_PREV_VALID) ? SESSION_KEY_RECORD_SIZE : SESSION_KEY_RECORD_MIN_SIZE; + if (pos + rec_len > old_len) break; + if (!session_keys.hasPrefix(rec) && !session_keys.isRemoved(rec)) { + wf.write(rec, rec_len); + } + pos += rec_len; + } + // Write current RAM entries (variable-length) + for (int i = 0; i < session_keys.getCount(); i++) { + uint8_t pub_key_prefix[4]; + uint8_t flags; + uint16_t nonce; + uint8_t session_key[SESSION_KEY_SIZE]; + uint8_t prev_session_key[SESSION_KEY_SIZE]; + if (session_keys.getEntryForSave(i, pub_key_prefix, &flags, &nonce, session_key, prev_session_key)) { + wf.write(pub_key_prefix, 4); + wf.write(&flags, 1); + wf.write((uint8_t*)&nonce, 2); + wf.write(session_key, SESSION_KEY_SIZE); + if (flags & SESSION_FLAG_PREV_VALID) { + wf.write(prev_session_key, SESSION_KEY_SIZE); + } + } + } + wf.close(); + _session_keys_dirty = false; + session_keys.clearRemoved(); } diff --git a/src/helpers/ClientACL.h b/src/helpers/ClientACL.h index fcf33283d..2ba828c01 100644 --- a/src/helpers/ClientACL.h +++ b/src/helpers/ClientACL.h @@ -103,6 +103,16 @@ class ClientACL { void loadSessionKeys(); void saveSessionKeys(); +private: + // Flash-backed session key wrappers + SessionKeyEntry* findSessionKey(const uint8_t* pub_key); + SessionKeyEntry* allocateSessionKey(const uint8_t* pub_key); + void removeSessionKey(const uint8_t* pub_key); + bool loadSessionKeyRecordFromFlash(const uint8_t* pub_key_prefix, + uint8_t* flags, uint16_t* nonce, uint8_t* session_key, uint8_t* prev_session_key); + +public: + // Peer-index forwarding helpers for server-side Mesh overrides. // These resolve peer_idx → ClientInfo via matching_indexes[], then delegate // to the corresponding method above. Eliminates repeated boilerplate in diff --git a/src/helpers/SessionKeyPool.h b/src/helpers/SessionKeyPool.h index b2c168a61..d1f100fc0 100644 --- a/src/helpers/SessionKeyPool.h +++ b/src/helpers/SessionKeyPool.h @@ -8,7 +8,6 @@ #define SESSION_STATE_DUAL_DECODE 2 // responder: new key active, old key still valid #define SESSION_STATE_ACTIVE 3 // session key confirmed and in use -#define SESSION_FLAG_PREV_VALID 0x01 // prev_session_key is valid for dual-decode struct SessionKeyEntry { uint8_t peer_pub_prefix[4]; // first 4 bytes of peer's public key @@ -21,55 +20,85 @@ struct SessionKeyEntry { unsigned long timeout_at; // millis timestamp for INIT timeout uint8_t ephemeral_prv[PRV_KEY_SIZE]; // initiator-only: ephemeral private key (zeroed after use) uint8_t ephemeral_pub[PUB_KEY_SIZE]; // initiator-only: ephemeral public key + uint32_t last_used; // LRU counter (higher = more recent) }; class SessionKeyPool { - SessionKeyEntry entries[MAX_SESSION_KEYS]; + SessionKeyEntry entries[MAX_SESSION_KEYS_RAM]; int count; + uint32_t lru_counter; + + // Track prefixes removed since last save, so merge-save doesn't resurrect them + uint8_t removed_prefixes[MAX_SESSION_KEYS_RAM][4]; + int removed_count; + + void touch(SessionKeyEntry* entry) { + entry->last_used = ++lru_counter; + } public: - SessionKeyPool() : count(0) { + SessionKeyPool() : count(0), lru_counter(0), removed_count(0) { memset(entries, 0, sizeof(entries)); + memset(removed_prefixes, 0, sizeof(removed_prefixes)); } + bool isFull() const { return count >= MAX_SESSION_KEYS_RAM; } + SessionKeyEntry* findByPrefix(const uint8_t* pub_key) { for (int i = 0; i < count; i++) { if (memcmp(entries[i].peer_pub_prefix, pub_key, 4) == 0) { + touch(&entries[i]); return &entries[i]; } } return nullptr; } + // Lookup without updating LRU — use during save/merge to avoid perturbing eviction order + bool hasPrefix(const uint8_t* pub_key) const { + for (int i = 0; i < count; i++) { + if (memcmp(entries[i].peer_pub_prefix, pub_key, 4) == 0) return true; + } + return false; + } + SessionKeyEntry* allocate(const uint8_t* pub_key) { // Check if already exists auto existing = findByPrefix(pub_key); if (existing) return existing; - // Find free slot or evict oldest - if (count < MAX_SESSION_KEYS) { + // Find free slot + if (count < MAX_SESSION_KEYS_RAM) { auto e = &entries[count++]; memset(e, 0, sizeof(*e)); memcpy(e->peer_pub_prefix, pub_key, 4); + touch(e); return e; } - // Pool full — evict the entry with state NONE, or the first one - for (int i = 0; i < MAX_SESSION_KEYS; i++) { - if (entries[i].state == SESSION_STATE_NONE) { - memset(&entries[i], 0, sizeof(entries[i])); - memcpy(entries[i].peer_pub_prefix, pub_key, 4); - return &entries[i]; + // Pool full — LRU eviction, skip INIT_SENT entries (ephemeral keys are RAM-only) + int evict_idx = -1; + uint32_t min_used = 0xFFFFFFFF; + for (int i = 0; i < MAX_SESSION_KEYS_RAM; i++) { + if (entries[i].state == SESSION_STATE_INIT_SENT) continue; + if (entries[i].last_used < min_used) { + min_used = entries[i].last_used; + evict_idx = i; } } - // All slots active — evict first entry - memset(&entries[0], 0, sizeof(entries[0])); - memcpy(entries[0].peer_pub_prefix, pub_key, 4); - return &entries[0]; + if (evict_idx < 0) evict_idx = 0; // all INIT_SENT — shouldn't happen, fall back to [0] + memset(&entries[evict_idx], 0, sizeof(entries[evict_idx])); + memcpy(entries[evict_idx].peer_pub_prefix, pub_key, 4); + touch(&entries[evict_idx]); + return &entries[evict_idx]; } void remove(const uint8_t* pub_key) { for (int i = 0; i < count; i++) { if (memcmp(entries[i].peer_pub_prefix, pub_key, 4) == 0) { + // Track removed prefix for merge-save + if (removed_count < MAX_SESSION_KEYS_RAM) { + memcpy(removed_prefixes[removed_count++], entries[i].peer_pub_prefix, 4); + } // Shift remaining entries down count--; for (int j = i; j < count; j++) { @@ -81,11 +110,20 @@ class SessionKeyPool { } } + bool isRemoved(const uint8_t* pub_key_prefix) const { + for (int i = 0; i < removed_count; i++) { + if (memcmp(removed_prefixes[i], pub_key_prefix, 4) == 0) return true; + } + return false; + } + + void clearRemoved() { removed_count = 0; } + int getCount() const { return count; } SessionKeyEntry* getByIdx(int idx) { return (idx >= 0 && idx < count) ? &entries[idx] : nullptr; } - // Persistence helpers — 71-byte records: [pub_prefix:4][flags:1][nonce:2][session_key:32][prev_session_key:32] - // Returns false when idx is past end + // Persistence helpers — variable-length records: [pub_prefix:4][flags:1][nonce:2][session_key:32][prev_session_key:32 if flags & PREV_VALID] + // Returns false when idx is past end or entry is not persistable bool getEntryForSave(int idx, uint8_t* pub_key_prefix, uint8_t* flags, uint16_t* nonce, uint8_t* session_key, uint8_t* prev_session_key) { if (idx >= count) return false; From aed23143861ae5dbaf5d2490ba194624a2f98c6b Mon Sep 17 00:00:00 2001 From: Wessel Nieboer Date: Sat, 14 Feb 2026 14:00:15 +0100 Subject: [PATCH 11/11] Fix dirty session keys. --- src/helpers/BaseChatMesh.cpp | 4 ++++ src/helpers/ClientACL.cpp | 2 ++ 2 files changed, 6 insertions(+) diff --git a/src/helpers/BaseChatMesh.cpp b/src/helpers/BaseChatMesh.cpp index 8fe216677..46c3c0cd5 100644 --- a/src/helpers/BaseChatMesh.cpp +++ b/src/helpers/BaseChatMesh.cpp @@ -909,6 +909,7 @@ bool BaseChatMesh::removeContact(ContactInfo& contact) { nonce_at_last_persist[idx] = nonce_at_last_persist[idx + 1]; idx++; } + memset(&contacts[num_contacts], 0, sizeof(ContactInfo)); return true; // Success } @@ -1048,6 +1049,7 @@ SessionKeyEntry* BaseChatMesh::allocateSessionKey(const uint8_t* pub_key) { void BaseChatMesh::removeSessionKey(const uint8_t* pub_key) { session_keys.remove(pub_key); + session_keys_dirty = true; } // --- Session key support (Phase 2 — initiator) --- @@ -1304,6 +1306,8 @@ void BaseChatMesh::checkSessionKeyTimeouts() { // All retries exhausted — clean up memset(entry->ephemeral_prv, 0, PRV_KEY_SIZE); memset(entry->ephemeral_pub, 0, PUB_KEY_SIZE); + memset(entry->session_key, 0, SESSION_KEY_SIZE); + memset(entry->prev_session_key, 0, SESSION_KEY_SIZE); entry->state = SESSION_STATE_NONE; entry->timeout_at = 0; } diff --git a/src/helpers/ClientACL.cpp b/src/helpers/ClientACL.cpp index 03687aaa8..159de8385 100644 --- a/src/helpers/ClientACL.cpp +++ b/src/helpers/ClientACL.cpp @@ -223,6 +223,7 @@ bool ClientACL::applyPermissions(const mesh::LocalIdentity& self_id, const uint8 nonce_at_last_persist[i] = nonce_at_last_persist[i + 1]; i++; } + memset(&clients[num_clients], 0, sizeof(ClientInfo)); } else { if (key_len < PUB_KEY_SIZE) return false; // need complete pubkey when adding/modifying @@ -460,6 +461,7 @@ SessionKeyEntry* ClientACL::allocateSessionKey(const uint8_t* pub_key) { void ClientACL::removeSessionKey(const uint8_t* pub_key) { session_keys.remove(pub_key); + _session_keys_dirty = true; } void ClientACL::loadSessionKeys() {