From 33c01e50b9f4d09b28586cf82d1406a087847987 Mon Sep 17 00:00:00 2001 From: Wessel Nieboer Date: Thu, 12 Feb 2026 01:01:56 +0100 Subject: [PATCH] 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 c11f37cac..7a54dd21c 100644 --- a/src/Mesh.cpp +++ b/src/Mesh.cpp @@ -153,9 +153,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; @@ -210,9 +220,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(); @@ -237,9 +254,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 89e60b1f7..30541075c 100644 --- a/src/MeshCore.h +++ b/src/MeshCore.h @@ -17,6 +17,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_GROUP_DATA_LENGTH (MAX_PACKET_PAYLOAD - CIPHER_BLOCK_SIZE - 3) #define MAX_PATH_SIZE 64 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 @@ public: */ 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 972a97e9e..cd60e5c01 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); } @@ -115,6 +117,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) { @@ -194,6 +200,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 } @@ -853,6 +864,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 c95e3e34b..72a825c50 100644 --- a/src/helpers/CommonCLI.cpp +++ b/src/helpers/CommonCLI.cpp @@ -208,12 +208,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 ede977cac..2ba81080e 100644 --- a/src/helpers/ContactInfo.h +++ b/src/helpers/ContactInfo.h @@ -17,6 +17,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) {