Browse Source

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
pull/1677/head
Wessel Nieboer 6 months ago
parent
commit
33c01e50b9
No known key found for this signature in database GPG Key ID: 929C8E45E33B5FD2
  1. 39
      src/Mesh.cpp
  2. 6
      src/MeshCore.h
  3. 115
      src/Utils.cpp
  4. 23
      src/Utils.h
  5. 12
      src/helpers/BaseChatMesh.cpp
  6. 3
      src/helpers/CommonCLI.cpp
  7. 1
      src/helpers/ContactInfo.h

39
src/Mesh.cpp

@ -153,9 +153,19 @@ DispatcherAction Mesh::onRecvPacket(Packet* pkt) {
uint8_t secret[PUB_KEY_SIZE]; uint8_t secret[PUB_KEY_SIZE];
getPeerSharedSecret(secret, j); getPeerSharedSecret(secret, j);
// decrypt, checking MAC is valid
uint8_t data[MAX_PACKET_PAYLOAD]; 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 (len > 0) { // success!
if (pkt->getPayloadType() == PAYLOAD_TYPE_PATH) { if (pkt->getPayloadType() == PAYLOAD_TYPE_PATH) {
int k = 0; int k = 0;
@ -210,9 +220,16 @@ DispatcherAction Mesh::onRecvPacket(Packet* pkt) {
uint8_t secret[PUB_KEY_SIZE]; uint8_t secret[PUB_KEY_SIZE];
self_id.calcSharedSecret(secret, sender); self_id.calcSharedSecret(secret, sender);
// decrypt, checking MAC is valid
uint8_t data[MAX_PACKET_PAYLOAD]; 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! if (len > 0) { // success!
onAnonDataRecv(pkt, secret, sender, data, len); onAnonDataRecv(pkt, secret, sender, data, len);
pkt->markDoNotRetransmit(); pkt->markDoNotRetransmit();
@ -237,9 +254,19 @@ DispatcherAction Mesh::onRecvPacket(Packet* pkt) {
int num = searchChannelsByHash(&channel_hash, channels, 4); int num = searchChannelsByHash(&channel_hash, channels, 4);
// for each matching channel, try to decrypt data // for each matching channel, try to decrypt data
for (int j = 0; j < num; j++) { for (int j = 0; j < num; j++) {
// decrypt, checking MAC is valid
uint8_t data[MAX_PACKET_PAYLOAD]; 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! if (len > 0) { // success!
onGroupDataRecv(pkt, pkt->getPayloadType(), channels[j], data, len); onGroupDataRecv(pkt, pkt->getPayloadType(), channels[j], data, len);
break; break;

6
src/MeshCore.h

@ -17,6 +17,12 @@
#define CIPHER_MAC_SIZE 2 #define CIPHER_MAC_SIZE 2
#define PATH_HASH_SIZE 1 #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_PACKET_PAYLOAD 184
#define MAX_GROUP_DATA_LENGTH (MAX_PACKET_PAYLOAD - CIPHER_BLOCK_SIZE - 3) #define MAX_GROUP_DATA_LENGTH (MAX_PACKET_PAYLOAD - CIPHER_BLOCK_SIZE - 3)
#define MAX_PATH_SIZE 64 #define MAX_PATH_SIZE 64

115
src/Utils.cpp

@ -1,6 +1,7 @@
#include "Utils.h" #include "Utils.h"
#include <AES.h> #include <AES.h>
#include <SHA256.h> #include <SHA256.h>
#include <ChaChaPoly.h>
#ifdef ARDUINO #ifdef ARDUINO
#include <Arduino.h> #include <Arduino.h>
@ -87,6 +88,120 @@ int Utils::MACThenDecrypt(const uint8_t* shared_secret, uint8_t* dest, const uin
return 0; // invalid HMAC 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"; static const char hex_chars[] = "0123456789ABCDEF";
void Utils::toHex(char* dest, const uint8_t* src, size_t len) { void Utils::toHex(char* dest, const uint8_t* src, size_t len) {

23
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); 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. * \brief converts 'src' bytes with given length to Hex representation, and null terminates.
*/ */

12
src/helpers/BaseChatMesh.cpp

@ -21,6 +21,7 @@ mesh::Packet* BaseChatMesh::createSelfAdvert(const char* name) {
uint8_t app_data_len; uint8_t app_data_len;
{ {
AdvertDataBuilder builder(ADV_TYPE_CHAT, name); AdvertDataBuilder builder(ADV_TYPE_CHAT, name);
builder.setFeat1(FEAT1_AEAD_SUPPORT);
app_data_len = builder.encodeTo(app_data); 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; uint8_t app_data_len;
{ {
AdvertDataBuilder builder(ADV_TYPE_CHAT, name, lat, lon); AdvertDataBuilder builder(ADV_TYPE_CHAT, name, lat, lon);
builder.setFeat1(FEAT1_AEAD_SUPPORT);
app_data_len = builder.encodeTo(app_data); 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.last_advert_timestamp = timestamp;
ci.lastmod = getRTCClock()->getCurrentTime(); 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) { 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->last_advert_timestamp = timestamp;
from->lastmod = getRTCClock()->getCurrentTime(); 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 onDiscoveredContact(*from, is_new, packet->path_len, packet->path); // let UI know
} }
@ -853,6 +864,7 @@ bool BaseChatMesh::addContact(const ContactInfo& contact) {
if (dest) { if (dest) {
*dest = contact; *dest = contact;
dest->shared_secret_valid = false; // mark shared_secret as needing calculation 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 true; // success
} }
return false; return false;

3
src/helpers/CommonCLI.cpp

@ -208,12 +208,15 @@ void CommonCLI::savePrefs() {
uint8_t CommonCLI::buildAdvertData(uint8_t node_type, uint8_t* app_data) { uint8_t CommonCLI::buildAdvertData(uint8_t node_type, uint8_t* app_data) {
if (_prefs->advert_loc_policy == ADVERT_LOC_NONE) { if (_prefs->advert_loc_policy == ADVERT_LOC_NONE) {
AdvertDataBuilder builder(node_type, _prefs->node_name); AdvertDataBuilder builder(node_type, _prefs->node_name);
builder.setFeat1(FEAT1_AEAD_SUPPORT);
return builder.encodeTo(app_data); return builder.encodeTo(app_data);
} else if (_prefs->advert_loc_policy == ADVERT_LOC_SHARE) { } else if (_prefs->advert_loc_policy == ADVERT_LOC_SHARE) {
AdvertDataBuilder builder(node_type, _prefs->node_name, _sensors->node_lat, _sensors->node_lon); AdvertDataBuilder builder(node_type, _prefs->node_name, _sensors->node_lat, _sensors->node_lon);
builder.setFeat1(FEAT1_AEAD_SUPPORT);
return builder.encodeTo(app_data); return builder.encodeTo(app_data);
} else { } else {
AdvertDataBuilder builder(node_type, _prefs->node_name, _prefs->node_lat, _prefs->node_lon); AdvertDataBuilder builder(node_type, _prefs->node_name, _prefs->node_lat, _prefs->node_lon);
builder.setFeat1(FEAT1_AEAD_SUPPORT);
return builder.encodeTo(app_data); return builder.encodeTo(app_data);
} }
} }

1
src/helpers/ContactInfo.h

@ -17,6 +17,7 @@ struct ContactInfo {
uint32_t lastmod; // by OUR clock uint32_t lastmod; // by OUR clock
int32_t gps_lat, gps_lon; // 6 dec places int32_t gps_lat, gps_lon; // 6 dec places
uint32_t sync_since; 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 { const uint8_t* getSharedSecret(const mesh::LocalIdentity& self_id) const {
if (!shared_secret_valid) { if (!shared_secret_valid) {

Loading…
Cancel
Save