Skip to content

Commit 26bdb41

Browse files
committed
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
1 parent 5ccae4b commit 26bdb41

7 files changed

Lines changed: 193 additions & 6 deletions

File tree

src/Mesh.cpp

Lines changed: 33 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -151,9 +151,19 @@ DispatcherAction Mesh::onRecvPacket(Packet* pkt) {
151151
uint8_t secret[PUB_KEY_SIZE];
152152
getPeerSharedSecret(secret, j);
153153

154-
// decrypt, checking MAC is valid
155154
uint8_t data[MAX_PACKET_PAYLOAD];
156-
int len = Utils::MACThenDecrypt(secret, data, macAndData, pkt->payload_len - i);
155+
int macAndDataLen = pkt->payload_len - i;
156+
157+
// Try ECB first (Phase 1: all senders use ECB), then AEAD-4 fallback.
158+
// IMPORTANT: Phase 2 MUST swap to AEAD-first. ECB-first has a 1/65536
159+
// false-positive rate on AEAD packets (nonce bytes matching truncated HMAC),
160+
// producing garbage plaintext. AEAD-first has only 1/2^32 false-positive on
161+
// ECB packets, which is negligible.
162+
int len = Utils::MACThenDecrypt(secret, data, macAndData, macAndDataLen);
163+
if (len <= 0) {
164+
uint8_t assoc[3] = { pkt->header, dest_hash, src_hash };
165+
len = Utils::aeadDecrypt(secret, data, macAndData, macAndDataLen, assoc, 3, dest_hash, src_hash);
166+
}
157167
if (len > 0) { // success!
158168
if (pkt->getPayloadType() == PAYLOAD_TYPE_PATH) {
159169
int k = 0;
@@ -201,9 +211,16 @@ DispatcherAction Mesh::onRecvPacket(Packet* pkt) {
201211
uint8_t secret[PUB_KEY_SIZE];
202212
self_id.calcSharedSecret(secret, sender);
203213

204-
// decrypt, checking MAC is valid
205214
uint8_t data[MAX_PACKET_PAYLOAD];
206-
int len = Utils::MACThenDecrypt(secret, data, macAndData, pkt->payload_len - i);
215+
int macAndDataLen = pkt->payload_len - i;
216+
217+
// Try ECB first (Phase 1), then AEAD-4 fallback.
218+
// Phase 2 MUST swap to AEAD-first (see peer message comment above).
219+
int len = Utils::MACThenDecrypt(secret, data, macAndData, macAndDataLen);
220+
if (len <= 0) {
221+
uint8_t assoc[2] = { pkt->header, dest_hash };
222+
len = Utils::aeadDecrypt(secret, data, macAndData, macAndDataLen, assoc, 2, dest_hash, 0);
223+
}
207224
if (len > 0) { // success!
208225
onAnonDataRecv(pkt, secret, sender, data, len);
209226
pkt->markDoNotRetransmit();
@@ -227,9 +244,19 @@ DispatcherAction Mesh::onRecvPacket(Packet* pkt) {
227244
int num = searchChannelsByHash(&channel_hash, channels, 4);
228245
// for each matching channel, try to decrypt data
229246
for (int j = 0; j < num; j++) {
230-
// decrypt, checking MAC is valid
231247
uint8_t data[MAX_PACKET_PAYLOAD];
232-
int len = Utils::MACThenDecrypt(channels[j].secret, data, macAndData, pkt->payload_len - i);
248+
int macAndDataLen = pkt->payload_len - i;
249+
250+
// Try ECB first (Phase 1), then AEAD-4 fallback.
251+
// Phase 2 MUST swap to AEAD-first (see peer message comment above).
252+
// Note: group channels share a key, so nonce collisions across senders can leak
253+
// P1 XOR P2 for colliding message pairs (no key recovery). Bounded risk, mainly
254+
// worthwhile for public/hashtag channels where the PSK is already widely known.
255+
int len = Utils::MACThenDecrypt(channels[j].secret, data, macAndData, macAndDataLen);
256+
if (len <= 0) {
257+
uint8_t assoc[2] = { pkt->header, channel_hash };
258+
len = Utils::aeadDecrypt(channels[j].secret, data, macAndData, macAndDataLen, assoc, 2, channel_hash, 0);
259+
}
233260
if (len > 0) { // success!
234261
onGroupDataRecv(pkt, pkt->getPayloadType(), channels[j], data, len);
235262
break;

src/MeshCore.h

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,12 @@
1616
#define CIPHER_MAC_SIZE 2
1717
#define PATH_HASH_SIZE 1
1818

19+
// AEAD-4 (ChaChaPoly) encryption
20+
#define AEAD_TAG_SIZE 4
21+
#define AEAD_NONCE_SIZE 2
22+
#define CONTACT_FLAG_AEAD 0x02 // bit 1 of ContactInfo.flags (bit 0 = favourite)
23+
#define FEAT1_AEAD_SUPPORT 0x0001 // bit 0 of feat1 uint16_t
24+
1925
#define MAX_PACKET_PAYLOAD 184
2026
#define MAX_PATH_SIZE 64
2127
#define MAX_TRANS_UNIT 255

src/Utils.cpp

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
#include "Utils.h"
22
#include <AES.h>
33
#include <SHA256.h>
4+
#include <ChaChaPoly.h>
45

56
#ifdef ARDUINO
67
#include <Arduino.h>
@@ -87,6 +88,120 @@ int Utils::MACThenDecrypt(const uint8_t* shared_secret, uint8_t* dest, const uin
8788
return 0; // invalid HMAC
8889
}
8990

91+
/*
92+
* AEAD-4: ChaCha20-Poly1305 authenticated encryption with 4-byte tag.
93+
*
94+
* Wire format (replaces ECB's [HMAC:2][ciphertext:N*16]):
95+
* [nonce:2] [ciphertext:M] [tag:4] (M = exact plaintext length)
96+
*
97+
* Key derivation (per-message, eliminates nonce-reuse catastrophe):
98+
* msg_key[32] = HMAC-SHA256(shared_secret[32], nonce_hi || nonce_lo || dest_hash || src_hash)
99+
* Including hashes makes keys direction-dependent: Alice->Bob and Bob->Alice derive
100+
* different keys even with the same nonce (for 255/256 peer pairs; the 1/256 where
101+
* dest_hash == src_hash remains a residual risk inherent to 1-byte hashes).
102+
*
103+
* IV construction (12 bytes, from on-wire fields):
104+
* iv[12] = { nonce_hi, nonce_lo, dest_hash, src_hash, 0, 0, 0, 0, 0, 0, 0, 0 }
105+
*
106+
* Associated data (authenticated but not encrypted):
107+
* Peer msgs: header || dest_hash || src_hash
108+
* Anon reqs: header || dest_hash
109+
* Group msgs: header || channel_hash
110+
*
111+
* Nonce: 16-bit counter per peer, seeded from HW RNG on boot. With per-message
112+
* key derivation, even a nonce collision (across reboots) only leaks P1 XOR P2
113+
* for that message pair — no key recovery, no impact on other messages.
114+
*
115+
* Group channels: all members share the same key, so cross-sender nonce
116+
* collisions are possible (~300 msgs for 50% chance with random nonces).
117+
* Damage is bounded (message pair leak, no key recovery).
118+
*/
119+
int Utils::aeadEncrypt(const uint8_t* shared_secret,
120+
uint8_t* dest,
121+
const uint8_t* src, int src_len,
122+
const uint8_t* assoc_data, int assoc_len,
123+
uint16_t nonce_counter,
124+
uint8_t dest_hash, uint8_t src_hash) {
125+
if (src_len <= 0) return 0;
126+
127+
// Write nonce to output
128+
dest[0] = (uint8_t)(nonce_counter >> 8);
129+
dest[1] = (uint8_t)(nonce_counter & 0xFF);
130+
131+
// Derive per-message key: HMAC-SHA256(shared_secret, nonce || dest_hash || src_hash)
132+
// Including hashes makes the key direction-dependent, preventing keystream reuse
133+
// when Alice->Bob and Bob->Alice use the same nonce (255/256 peer pairs).
134+
uint8_t msg_key[32];
135+
{
136+
uint8_t kdf_input[AEAD_NONCE_SIZE + 2] = { dest[0], dest[1], dest_hash, src_hash };
137+
SHA256 sha;
138+
sha.resetHMAC(shared_secret, PUB_KEY_SIZE);
139+
sha.update(kdf_input, sizeof(kdf_input));
140+
sha.finalizeHMAC(shared_secret, PUB_KEY_SIZE, msg_key, 32);
141+
}
142+
143+
// Build 12-byte IV from on-wire fields
144+
uint8_t iv[12];
145+
iv[0] = dest[0]; // nonce_hi
146+
iv[1] = dest[1]; // nonce_lo
147+
iv[2] = dest_hash;
148+
iv[3] = src_hash;
149+
memset(&iv[4], 0, 8);
150+
151+
ChaChaPoly cipher;
152+
cipher.setKey(msg_key, 32);
153+
cipher.setIV(iv, 12);
154+
cipher.addAuthData(assoc_data, assoc_len);
155+
cipher.encrypt(dest + AEAD_NONCE_SIZE, src, src_len);
156+
cipher.computeTag(dest + AEAD_NONCE_SIZE + src_len, AEAD_TAG_SIZE);
157+
cipher.clear();
158+
memset(msg_key, 0, 32);
159+
160+
return AEAD_NONCE_SIZE + src_len + AEAD_TAG_SIZE;
161+
}
162+
163+
int Utils::aeadDecrypt(const uint8_t* shared_secret,
164+
uint8_t* dest,
165+
const uint8_t* src, int src_len,
166+
const uint8_t* assoc_data, int assoc_len,
167+
uint8_t dest_hash, uint8_t src_hash) {
168+
// Minimum: nonce(2) + at least 1 byte ciphertext + tag(4)
169+
if (src_len < AEAD_NONCE_SIZE + 1 + AEAD_TAG_SIZE) return 0;
170+
171+
int ct_len = src_len - AEAD_NONCE_SIZE - AEAD_TAG_SIZE;
172+
173+
// Derive per-message key: HMAC-SHA256(shared_secret, nonce || dest_hash || src_hash)
174+
uint8_t msg_key[32];
175+
{
176+
uint8_t kdf_input[AEAD_NONCE_SIZE + 2] = { src[0], src[1], dest_hash, src_hash };
177+
SHA256 sha;
178+
sha.resetHMAC(shared_secret, PUB_KEY_SIZE);
179+
sha.update(kdf_input, sizeof(kdf_input));
180+
sha.finalizeHMAC(shared_secret, PUB_KEY_SIZE, msg_key, 32);
181+
}
182+
183+
// Build 12-byte IV from on-wire fields
184+
uint8_t iv[12];
185+
iv[0] = src[0]; // nonce_hi
186+
iv[1] = src[1]; // nonce_lo
187+
iv[2] = dest_hash;
188+
iv[3] = src_hash;
189+
memset(&iv[4], 0, 8);
190+
191+
ChaChaPoly cipher;
192+
cipher.setKey(msg_key, 32);
193+
cipher.setIV(iv, 12);
194+
cipher.addAuthData(assoc_data, assoc_len);
195+
cipher.decrypt(dest, src + AEAD_NONCE_SIZE, ct_len);
196+
197+
bool valid = cipher.checkTag(src + AEAD_NONCE_SIZE + ct_len, AEAD_TAG_SIZE);
198+
cipher.clear();
199+
memset(msg_key, 0, 32);
200+
if (!valid) memset(dest, 0, ct_len);
201+
202+
return valid ? ct_len : 0;
203+
}
204+
90205
static const char hex_chars[] = "0123456789ABCDEF";
91206

92207
void Utils::toHex(char* dest, const uint8_t* src, size_t len) {

src/Utils.h

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,29 @@ class Utils {
5454
*/
5555
static int MACThenDecrypt(const uint8_t* shared_secret, uint8_t* dest, const uint8_t* src, int src_len);
5656

57+
/**
58+
* \brief Encrypt with ChaChaPoly AEAD. Derives per-message key via HMAC-SHA256(shared_secret, nonce || dest_hash || src_hash).
59+
* Output: [nonce:2][ciphertext:src_len][tag:4]
60+
* \returns total output length (AEAD_NONCE_SIZE + src_len + AEAD_TAG_SIZE), or 0 on failure
61+
*/
62+
static int aeadEncrypt(const uint8_t* shared_secret,
63+
uint8_t* dest,
64+
const uint8_t* src, int src_len,
65+
const uint8_t* assoc_data, int assoc_len,
66+
uint16_t nonce_counter,
67+
uint8_t dest_hash, uint8_t src_hash);
68+
69+
/**
70+
* \brief Decrypt with ChaChaPoly AEAD. Derives per-message key via HMAC-SHA256(shared_secret, nonce || dest_hash || src_hash).
71+
* Input: [nonce:2][ciphertext:M][tag:4]
72+
* \returns plaintext length, or 0 if tag verification fails
73+
*/
74+
static int aeadDecrypt(const uint8_t* shared_secret,
75+
uint8_t* dest,
76+
const uint8_t* src, int src_len,
77+
const uint8_t* assoc_data, int assoc_len,
78+
uint8_t dest_hash, uint8_t src_hash);
79+
5780
/**
5881
* \brief converts 'src' bytes with given length to Hex representation, and null terminates.
5982
*/

src/helpers/BaseChatMesh.cpp

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ mesh::Packet* BaseChatMesh::createSelfAdvert(const char* name) {
2121
uint8_t app_data_len;
2222
{
2323
AdvertDataBuilder builder(ADV_TYPE_CHAT, name);
24+
builder.setFeat1(FEAT1_AEAD_SUPPORT);
2425
app_data_len = builder.encodeTo(app_data);
2526
}
2627

@@ -32,6 +33,7 @@ mesh::Packet* BaseChatMesh::createSelfAdvert(const char* name, double lat, doubl
3233
uint8_t app_data_len;
3334
{
3435
AdvertDataBuilder builder(ADV_TYPE_CHAT, name, lat, lon);
36+
builder.setFeat1(FEAT1_AEAD_SUPPORT);
3537
app_data_len = builder.encodeTo(app_data);
3638
}
3739

@@ -101,6 +103,10 @@ void BaseChatMesh::populateContactFromAdvert(ContactInfo& ci, const mesh::Identi
101103
}
102104
ci.last_advert_timestamp = timestamp;
103105
ci.lastmod = getRTCClock()->getCurrentTime();
106+
getRNG()->random((uint8_t*)&ci.aead_nonce, sizeof(ci.aead_nonce)); // seed AEAD nonce from HW RNG
107+
if (parser.getFeat1() & FEAT1_AEAD_SUPPORT) {
108+
ci.flags |= CONTACT_FLAG_AEAD;
109+
}
104110
}
105111

106112
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,
165171
}
166172
from->last_advert_timestamp = timestamp;
167173
from->lastmod = getRTCClock()->getCurrentTime();
174+
if (parser.getFeat1() & FEAT1_AEAD_SUPPORT) {
175+
from->flags |= CONTACT_FLAG_AEAD;
176+
} else {
177+
from->flags &= ~CONTACT_FLAG_AEAD;
178+
}
168179

169180
onDiscoveredContact(*from, is_new, packet->path_len, packet->path); // let UI know
170181
}
@@ -762,6 +773,7 @@ bool BaseChatMesh::addContact(const ContactInfo& contact) {
762773
if (dest) {
763774
*dest = contact;
764775
dest->shared_secret_valid = false; // mark shared_secret as needing calculation
776+
getRNG()->random((uint8_t*)&dest->aead_nonce, sizeof(dest->aead_nonce)); // always seed fresh from HW RNG
765777
return true; // success
766778
}
767779
return false;

src/helpers/CommonCLI.cpp

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,12 +183,15 @@ void CommonCLI::savePrefs() {
183183
uint8_t CommonCLI::buildAdvertData(uint8_t node_type, uint8_t* app_data) {
184184
if (_prefs->advert_loc_policy == ADVERT_LOC_NONE) {
185185
AdvertDataBuilder builder(node_type, _prefs->node_name);
186+
builder.setFeat1(FEAT1_AEAD_SUPPORT);
186187
return builder.encodeTo(app_data);
187188
} else if (_prefs->advert_loc_policy == ADVERT_LOC_SHARE) {
188189
AdvertDataBuilder builder(node_type, _prefs->node_name, _sensors->node_lat, _sensors->node_lon);
190+
builder.setFeat1(FEAT1_AEAD_SUPPORT);
189191
return builder.encodeTo(app_data);
190192
} else {
191193
AdvertDataBuilder builder(node_type, _prefs->node_name, _prefs->node_lat, _prefs->node_lon);
194+
builder.setFeat1(FEAT1_AEAD_SUPPORT);
192195
return builder.encodeTo(app_data);
193196
}
194197
}

src/helpers/ContactInfo.h

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ struct ContactInfo {
1515
uint32_t lastmod; // by OUR clock
1616
int32_t gps_lat, gps_lon; // 6 dec places
1717
uint32_t sync_since;
18+
uint16_t aead_nonce; // per-peer AEAD nonce counter for DMs (not used for group messages), seeded from HW RNG
1819

1920
const uint8_t* getSharedSecret(const mesh::LocalIdentity& self_id) const {
2021
if (!shared_secret_valid) {

0 commit comments

Comments
 (0)