Wessel 4 days ago
committed by GitHub
parent
commit
61df861a50
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 134
      examples/companion_radio/DataStore.cpp
  2. 14
      examples/companion_radio/DataStore.h
  3. 47
      examples/companion_radio/MyMesh.cpp
  4. 23
      examples/companion_radio/MyMesh.h
  5. 80
      examples/simple_repeater/MyMesh.cpp
  6. 12
      examples/simple_repeater/MyMesh.h
  7. 80
      examples/simple_room_server/MyMesh.cpp
  8. 12
      examples/simple_room_server/MyMesh.h
  9. 84
      examples/simple_sensor/SensorMesh.cpp
  10. 12
      examples/simple_sensor/SensorMesh.h
  11. 121
      src/Mesh.cpp
  12. 17
      src/Mesh.h
  13. 29
      src/MeshCore.h
  14. 121
      src/Utils.cpp
  15. 23
      src/Utils.h
  16. 15
      src/helpers/ArduinoHelpers.h
  17. 552
      src/helpers/BaseChatMesh.cpp
  18. 63
      src/helpers/BaseChatMesh.h
  19. 407
      src/helpers/ClientACL.cpp
  20. 73
      src/helpers/ClientACL.h
  21. 7
      src/helpers/CommonCLI.cpp
  22. 4
      src/helpers/CommonCLI.h
  23. 18
      src/helpers/ContactInfo.h
  24. 155
      src/helpers/SessionKeyPool.h

134
examples/companion_radio/DataStore.cpp

@ -365,6 +365,140 @@ 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;
}
void DataStore::loadSessionKeys(DataStoreHost* host) {
File file = openRead(_getContactsChannelsFS(), "/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);
}
host->onSessionKeyLoaded(rec, flags, nonce, &rec[7], prev_key);
}
file.close();
}
bool DataStore::saveSessionKeys(DataStoreHost* host) {
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;
}
}
f.close();
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)

14
examples/companion_radio/DataStore.h

@ -11,6 +11,14 @@ public:
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; }
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; }
virtual bool isSessionKeyInRAM(const uint8_t* pub_key_prefix) { return false; }
virtual bool isSessionKeyRemoved(const uint8_t* pub_key_prefix) { return false; }
};
class DataStore {
@ -39,6 +47,12 @@ public:
void saveContacts(DataStoreHost* host, bool (*filter)(const ContactInfo& c) = NULL);
void loadChannels(DataStoreHost* host);
void saveChannels(DataStoreHost* host);
void loadNonces(DataStoreHost* host);
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);

47
examples/companion_radio/MyMesh.cpp

@ -871,6 +871,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));
send_unscoped = false;
@ -968,6 +969,16 @@ 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);
_store->loadSessionKeys(this);
addChannel("Public", PUBLIC_GROUP_PSK); // pre-configure Andy's public channel
_store->loadChannels(this);
@ -1464,6 +1475,8 @@ void MyMesh::handleCmdFrame(size_t len) {
if (dirty_contacts_expiry) { // is there are pending dirty contacts write needed?
saveContacts();
}
if (isNonceDirty()) saveNonces();
saveSessionKeys();
board.reboot();
} else if (cmd_frame[0] == CMD_GET_BATT_AND_STORAGE) {
uint8_t reply[11];
@ -2185,7 +2198,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");
@ -2222,6 +2250,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();
@ -2237,6 +2273,17 @@ void MyMesh::loop() {
dirty_contacts_expiry = 0;
}
// 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);
}
#ifdef DISPLAY_CLASS
if (_ui) _ui->setHasConnection(_serial->isConnected());
#endif

23
examples/companion_radio/MyMesh.h

@ -159,6 +159,18 @@ protected:
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); }
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);
}
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;
@ -207,6 +219,16 @@ private:
// helpers, short-cuts
void saveChannels() { _store->saveChannels(this); }
void saveContacts();
void saveNonces() { if (_store->saveNonces(this)) clearNonceDirty(); }
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;
@ -229,6 +251,7 @@ private:
uint8_t *sign_data;
uint32_t sign_data_len;
unsigned long dirty_contacts_expiry;
unsigned long next_nonce_persist;
TransportKey send_scope;

80
examples/simple_repeater/MyMesh.cpp

@ -625,6 +625,44 @@ 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) {
return acl.peerNextAeadNonce(peer_idx, matching_peer_indexes);
}
void MyMesh::onPeerAeadDetected(int peer_idx) {
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'
@ -659,20 +697,37 @@ void MyMesh::onPeerDataRecv(mesh::Packet *packet, uint8_t type, int sender_idx,
memcpy(&timestamp, 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, &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, 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);
mesh::Packet *path = createPathReturn(client->id, enc_key, packet->path, packet->path_len,
PAYLOAD_TYPE_RESPONSE, reply_data, reply_len, enc_nonce);
if (path) sendFloodReply(path, SERVER_RESPONSE_DELAY, packet->getPathHashSize());
} else {
mesh::Packet *reply =
createDatagram(PAYLOAD_TYPE_RESPONSE, client->id, secret, reply_data, reply_len);
createDatagram(PAYLOAD_TYPE_RESPONSE, client->id, enc_key, reply_data, reply_len, enc_nonce);
if (reply) {
if (client->out_path_len != OUT_PATH_UNKNOWN) { // we have an out_path, so send DIRECT
sendDirect(reply, client->out_path, client->out_path_len, SERVER_RESPONSE_DELAY);
@ -733,7 +788,7 @@ void MyMesh::onPeerDataRecv(mesh::Packet *packet, uint8_t type, int sender_idx,
memcpy(temp, &timestamp, 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, acl.getEncryptionKey(*client), temp, 5 + text_len, acl.getEncryptionNonce(*client));
if (reply) {
if (client->out_path_len == OUT_PATH_UNKNOWN) {
sendFloodReply(reply, CLI_REPLY_DELAY_MILLIS, packet->getPathHashSize());
@ -862,6 +917,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;
@ -930,6 +986,13 @@ void MyMesh::begin(FILESYSTEM *fs) {
// load persisted prefs
_cli.loadPrefs(_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
next_nonce_persist = futureMillis(60000);
// TODO: key_store.begin();
region_map.load(_fs);
@ -1300,6 +1363,13 @@ void MyMesh::loop() {
dirty_contacts_expiry = 0;
}
// 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);
}
// update uptime
uint32_t now = millis();
uptime_millis += now - last_millis;

12
examples/simple_repeater/MyMesh.h

@ -104,6 +104,7 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks {
unsigned long pending_discover_until;
bool region_load_active;
unsigned long dirty_contacts_expiry;
unsigned long next_nonce_persist;
#if MAX_NEIGHBOURS
NeighbourInfo neighbours[MAX_NEIGHBOURS];
#endif
@ -172,6 +173,14 @@ protected:
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;
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;
@ -195,6 +204,9 @@ public:
void savePrefs() override {
_cli.savePrefs(_fs);
}
void onBeforeReboot() override {
if (acl.isNonceDirty()) acl.saveNonces();
}
void sendFloodScoped(const TransportKey& scope, mesh::Packet* pkt, uint32_t delay_millis, uint8_t path_hash_size);

80
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, acl.getEncryptionKey(*client), reply_data, len, acl.getEncryptionNonce(*client));
if (reply) {
if (client->out_path_len == OUT_PATH_UNKNOWN) {
unsigned long delay_millis = 0;
@ -414,6 +414,44 @@ 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) {
return acl.peerNextAeadNonce(peer_idx, matching_peer_indexes);
}
void MyMesh::onPeerAeadDetected(int peer_idx) {
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];
@ -507,7 +545,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, acl.getEncryptionKey(*client), temp, 5 + text_len, acl.getEncryptionNonce(*client));
if (reply) {
if (client->out_path_len == OUT_PATH_UNKNOWN) {
sendFloodReply(reply, delay_millis + SERVER_RESPONSE_DELAY, packet->getPathHashSize());
@ -559,15 +597,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);
mesh::Packet *path = createPathReturn(client->id, enc_key, packet->path, packet->path_len,
PAYLOAD_TYPE_RESPONSE, reply_data, reply_len, enc_nonce);
if (path) sendFloodReply(path, SERVER_RESPONSE_DELAY, packet->getPathHashSize());
} else {
mesh::Packet *reply = createDatagram(PAYLOAD_TYPE_RESPONSE, client->id, secret, reply_data, reply_len);
mesh::Packet *reply = createDatagram(PAYLOAD_TYPE_RESPONSE, client->id, enc_key, reply_data, reply_len, enc_nonce);
if (reply) {
if (client->out_path_len != OUT_PATH_UNKNOWN) { // we have an out_path, so send DIRECT
sendDirect(reply, client->out_path, client->out_path_len, SERVER_RESPONSE_DELAY);
@ -622,6 +675,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;
region_load_active = false;
set_radio_at = revert_radio_at = 0;
@ -684,6 +738,13 @@ void MyMesh::begin(FILESYSTEM *fs) {
acl.load(_fs, self_id);
region_map.load(_fs);
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
next_nonce_persist = futureMillis(60000);
// establish default-scope
{
@ -1035,6 +1096,13 @@ void MyMesh::loop() {
dirty_contacts_expiry = 0;
}
// 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);
}
// TODO: periodically check for OLD/inactive entries in known_clients[], and evict
// update uptime

12
examples/simple_room_server/MyMesh.h

@ -101,6 +101,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;
@ -160,6 +161,14 @@ protected:
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;
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;
@ -188,6 +197,9 @@ public:
void savePrefs() override {
_cli.savePrefs(_fs);
}
void onBeforeReboot() override {
if (acl.isNonceDirty()) acl.saveNonces();
}
void sendFloodScoped(const TransportKey& scope, mesh::Packet* pkt, uint32_t delay_millis, uint8_t path_hash_size);

84
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, acl.getEncryptionKey(*c), data, 5 + text_len, acl.getEncryptionNonce(*c));
if (pkt) {
if (c->out_path_len != OUT_PATH_UNKNOWN) { // we have an out_path, so send DIRECT
sendDirect(pkt, c->out_path, c->out_path_len);
@ -500,6 +500,44 @@ 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) {
return acl.peerNextAeadNonce(peer_idx, matching_peer_indexes);
}
void SensorMesh::onPeerAeadDetected(int peer_idx) {
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, uint8_t path_hash_size) {
if (dest.out_path_len == OUT_PATH_UNKNOWN) {
mesh::Packet* ack = createAck(ack_hash);
@ -531,19 +569,34 @@ void SensorMesh::onPeerDataRecv(mesh::Packet* packet, uint8_t type, int sender_i
memcpy(&timestamp, 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, &timestamp, 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);
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, packet->getPathHashSize());
} else {
mesh::Packet* reply = createDatagram(PAYLOAD_TYPE_RESPONSE, from->id, secret, reply_data, reply_len);
mesh::Packet* reply = createDatagram(PAYLOAD_TYPE_RESPONSE, from->id, enc_key, reply_data, reply_len, enc_nonce);
if (reply) {
if (from->out_path_len != OUT_PATH_UNKNOWN) { // we have an out_path, so send DIRECT
sendDirect(reply, from->out_path, from->out_path_len, SERVER_RESPONSE_DELAY);
@ -569,8 +622,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);
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, packet->getPathHashSize());
} else {
sendAckTo(*from, ack_hash, packet->getPathHashSize());
@ -598,7 +651,7 @@ void SensorMesh::onPeerDataRecv(mesh::Packet* packet, uint8_t type, int sender_i
memcpy(temp, &timestamp, 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, acl.getEncryptionKey(*from), temp, 5 + text_len, acl.getEncryptionNonce(*from));
if (reply) {
if (from->out_path_len == OUT_PATH_UNKNOWN) {
sendFlood(reply, CLI_REPLY_DELAY_MILLIS, packet->getPathHashSize());
@ -705,6 +758,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;
@ -747,6 +801,13 @@ void SensorMesh::begin(FILESYSTEM* fs) {
acl.load(_fs, self_id);
region_map.load(_fs);
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
next_nonce_persist = futureMillis(60000);
// establish default-scope
{
@ -976,4 +1037,11 @@ 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(); }
if (acl.isSessionKeysDirty()) { acl.saveSessionKeys(); }
next_nonce_persist = futureMillis(60000);
}
}

12
examples/simple_sensor/SensorMesh.h

@ -60,6 +60,9 @@ public:
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;
@ -125,6 +128,14 @@ protected:
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;
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;
@ -139,6 +150,7 @@ private:
CommonCLI _cli;
uint8_t reply_data[MAX_PACKET_PAYLOAD];
unsigned long dirty_contacts_expiry;
unsigned long next_nonce_persist;
CayenneLPP telemetry;
TransportKeyStore key_store;
RegionMap region_map;

121
src/Mesh.cpp

@ -145,7 +145,7 @@ DispatcherAction Mesh::onRecvPacket(Packet* pkt) {
// FUTURE: could send back multiple paths, using createPathReturn(), and let sender choose which to use(?)
if (self_id.isHashMatch(&dest_hash)) {
// scan contacts DB, for all matching hashes of 'src_hash' (max 4 matches supported ATM)
// scan contacts DB, for all matching hashes of 'src_hash' (max 8 matches supported ATM)
int num = searchPeersByHash(&src_hash);
// for each matching contact, try to decrypt data
bool found = false;
@ -153,10 +153,58 @@ 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;
// 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 = 0;
bool decoded_aead = false;
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);
if (len > 0) decoded_aead = true;
}
}
if (len > 0) { // success!
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++];
@ -173,12 +221,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);
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;
@ -210,9 +258,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] = { (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!
onAnonDataRecv(pkt, secret, sender, data, len);
pkt->markDoNotRetransmit();
@ -237,9 +292,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] = { (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!
onGroupDataRecv(pkt, pkt->getPayloadType(), channels[j], data, len);
break;
@ -437,19 +502,19 @@ Packet* Mesh::createAdvert(const LocalIdentity& id, const uint8_t* app_data, siz
return packet;
}
#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) {
uint8_t path_hash_size = (path_len >> 6) + 1;
uint8_t path_hash_count = path_len & 63;
if (path_hash_count*path_hash_size + extra_len + 5 > MAX_COMBINED_PATH) return NULL; // too long!!
size_t max_overhead = aead_nonce ? (AEAD_NONCE_SIZE + AEAD_TAG_SIZE) : (CIPHER_MAC_SIZE + CIPHER_BLOCK_SIZE-1);
size_t max_combined_path = MAX_PACKET_PAYLOAD - PATH_HASH_SIZE * 2 - max_overhead;
if (path_hash_count*path_hash_size + extra_len + 5 > max_combined_path) return NULL; // too long!!
Packet* packet = obtainNewPacket();
if (packet == NULL) {
@ -477,7 +542,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] = { (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);
}
}
packet->payload_len = len;
@ -485,9 +557,12 @@ 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 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);
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
}
@ -502,7 +577,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] = { (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);
}
packet->payload_len = len;

17
src/Mesh.h

@ -83,6 +83,17 @@ protected:
* \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; }
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).
@ -183,15 +194,15 @@ public:
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(const uint8_t* ack, uint8_t len);
Packet* createAck(uint32_t ack_crc) { return createAck((uint8_t *) &ack_crc, 4); }
Packet* createMultiAck(const uint8_t* ack, uint8_t len, uint8_t remaining);
Packet* createMultiAck(uint32_t ack_crc, uint8_t remaining) { return createMultiAck((uint8_t *)&ack_crc, 4, 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);

29
src/MeshCore.h

@ -10,6 +10,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
@ -17,6 +18,34 @@
#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 0x80 // bit 7 of ContactInfo.flags (bit 0 = favourite, bits 1-3 = telemetry perms)
#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 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_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
#define MAX_PACKET_PAYLOAD 184
#define MAX_GROUP_DATA_LENGTH (MAX_PACKET_PAYLOAD - CIPHER_BLOCK_SIZE - 3)
#define MAX_PATH_SIZE 64

121
src/Utils.cpp

@ -1,6 +1,7 @@
#include "Utils.h"
#include <AES.h>
#include <SHA256.h>
#include <ChaChaPoly.h>
#ifdef ARDUINO
#include <Arduino.h>
@ -84,7 +85,125 @@ 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;
}
/*
* 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 || 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);
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 || 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;
// 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";

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);
/**
* \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.
*/

15
src/helpers/ArduinoHelpers.h

@ -1,6 +1,7 @@
#pragma once
#include <Mesh.h>
#include <MeshCore.h>
#include <Arduino.h>
class VolatileRTCClock : public mesh::RTCClock {
@ -33,3 +34,17 @@ public:
}
}
};
// 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
}

552
src/helpers/BaseChatMesh.cpp

@ -1,5 +1,7 @@
#include <helpers/BaseChatMesh.h>
#include <Utils.h>
#include <SHA256.h>
#include <ed_25519.h>
#ifndef SERVER_RESPONSE_DELAY
#define SERVER_RESPONSE_DELAY 300
@ -9,6 +11,64 @@
#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;
// 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) {
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);
}
@ -21,6 +81,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 +93,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 +177,10 @@ void BaseChatMesh::populateContactFromAdvert(ContactInfo& ci, const mesh::Identi
}
ci.last_advert_timestamp = timestamp;
ci.lastmod = getRTCClock()->getCurrentTime();
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;
}
}
void BaseChatMesh::onAdvertRecv(mesh::Packet* packet, const mesh::Identity& id, uint32_t timestamp, const uint8_t* app_data, size_t app_data_len) {
@ -179,7 +245,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;
}
@ -194,6 +261,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
}
@ -217,6 +289,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 nextAeadNonceFor(contacts[i]);
}
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) {
@ -247,8 +335,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, 6);
mesh::Packet* path = createPathReturn(from.id, getEncryptionKeyFor(from), packet->path, packet->path_len,
PAYLOAD_TYPE_ACK, (uint8_t *) &ack_hash, 6, getEncryptionNonceFor(from));
if (path) sendFloodScoped(from, path, TXT_ACK_DELAY);
} else {
sendAckTo(from, ack_hash, 6);
@ -259,7 +347,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, getEncryptionKeyFor(from), packet->path, packet->path_len, 0, NULL, 0, getEncryptionNonceFor(from));
if (path) sendFloodScoped(from, path);
}
} else if (flags == TXT_TYPE_SIGNED_PLAIN) {
@ -274,8 +362,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);
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, (uint8_t *) &ack_hash);
@ -286,15 +374,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);
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);
mesh::Packet* reply = createDatagram(PAYLOAD_TYPE_RESPONSE, from.id, enc_key, temp_buf, reply_len, enc_nonce);
if (reply) {
if (from.out_path_len != OUT_PATH_UNKNOWN) { // we have an out_path, so send DIRECT
sendDirect(reply, from.out_path, from.out_path_len, SERVER_RESPONSE_DELAY);
@ -305,7 +415,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 != OUT_PATH_UNKNOWN) {
// 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);
@ -339,7 +458,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
}
@ -360,7 +483,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, 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
}
@ -436,7 +559,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, 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) {
@ -467,7 +590,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, getEncryptionKeyFor(recipient), temp, 5 + text_len, getEncryptionNonceFor(recipient));
if (pkt == NULL) return MSG_SEND_FAILED;
uint32_t t = _radio->getEstAirtimeFor(pkt->getRawLength());
@ -639,7 +762,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, getEncryptionKeyFor(recipient), temp, 4 + data_len, getEncryptionNonceFor(recipient));
}
if (pkt) {
uint32_t t = _radio->getEstAirtimeFor(pkt->getRawLength());
@ -666,7 +789,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, getEncryptionKeyFor(recipient), temp, sizeof(temp), getEncryptionNonceFor(recipient));
}
if (pkt) {
uint32_t t = _radio->getEstAirtimeFor(pkt->getRawLength());
@ -789,7 +912,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, getEncryptionKeyFor(*contact), data, 9, getEncryptionNonceFor(*contact));
if (pkt) {
sendDirect(pkt, contact->out_path, contact->out_path_len);
}
@ -851,8 +974,11 @@ ContactInfo* BaseChatMesh::lookupContactByPubKey(const uint8_t* pub_key, int pre
bool BaseChatMesh::addContact(const ContactInfo& contact) {
ContactInfo* dest = allocateContactSlot(contact.type == ADV_TYPE_NONE);
if (dest) {
int idx = dest - contacts;
*dest = contact;
dest->shared_secret_valid = false; // mark shared_secret as needing calculation
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
}
return false;
@ -865,12 +991,20 @@ bool BaseChatMesh::removeContact(ContactInfo& contact) {
}
if (idx >= num_contacts) return false; // not found
// remove from contacts array
removeSessionKey(contact.id.pub_key); // also remove session key if any
// adjust pending rekey index before shifting array
if (_pending_rekey_idx == idx) _pending_rekey_idx = -1;
else if (_pending_rekey_idx > idx) _pending_rekey_idx--;
// 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++;
}
memset(&contacts[num_contacts], 0, sizeof(ContactInfo));
return true; // Success
}
@ -966,4 +1100,384 @@ 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 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_keys_dirty = true;
}
// --- 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 = findSessionKey(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 = findSessionKey(contact.id.pub_key);
if (canUseSessionKey(entry)) {
++entry->nonce; // may reach 65535 → canUseSessionKey() fails next call → falls back to static ECDH
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;
removeSessionKey(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 == OUT_PATH_UNKNOWN) return false;
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;
// 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 = allocateSessionKey(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 = findSessionKey(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 = allocateSessionKey(from.id.pub_key);
if (!entry) {
memset(new_session_key, 0, SESSION_KEY_SIZE);
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
ContactInfo* contact = nullptr;
for (int j = 0; j < num_contacts; j++) {
if (memcmp(contacts[j].id.pub_key, entry->peer_pub_prefix, 4) == 0) {
contact = &contacts[j];
break;
}
}
if (!contact) {
entry->retries_left = 0; // contact gone — fall through to cleanup on next tick
continue;
}
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(*contact, req_data, sizeof(req_data), tag, est_timeout);
} else {
// 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;
}
}
}
// 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 = 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)))
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 = findSessionKey(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 = findSessionKey(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;
onSessionKeysUpdated();
}
entry->sends_since_last_recv = 0;
}
}
}
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);
}

63
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
@ -74,6 +75,15 @@ 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;
// 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, const uint8_t* ack_hash, uint8_t ack_len=4);
@ -90,6 +100,10 @@ protected:
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;
session_keys_dirty = false;
_pending_rekey_idx = -1;
}
void bootstrapRTCfromContacts();
@ -132,10 +146,59 @@ protected:
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;
}
// 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);
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; }
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;
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;

407
src/helpers/ClientACL.cpp

@ -1,4 +1,7 @@
#include "ClientACL.h"
#include <MeshCore.h>
#include <SHA256.h>
#include <ed_25519.h>
static File openWrite(FILESYSTEM* _fs, const char* filename) {
#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM)
@ -111,25 +114,116 @@ 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 = OUT_PATH_UNKNOWN;
if (_rng) {
c->aead_nonce = (uint16_t)_rng->nextInt(NONCE_INITIAL_MIN, NONCE_INITIAL_MAX + 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;
// 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) {
ClientInfo* c;
if ((perms & PERM_ACL_ROLE_MASK) == PERM_ACL_GUEST) { // guest role is not persisted in contacts
c = getClient(pubkey, key_len);
if (c == NULL) return false; // partial pubkey not found
removeSessionKey(c->id.pub_key); // also remove session key if any
num_clients--; // delete from contacts[]
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++;
}
memset(&clients[num_clients], 0, sizeof(ClientInfo));
} else {
if (key_len < PUB_KEY_SIZE) return false; // need complete pubkey when adding/modifying
@ -141,3 +235,316 @@ 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 = allocateSessionKey(client->id.pub_key);
if (!entry) {
memset(new_session_key, 0, SESSION_KEY_SIZE);
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 = findSessionKey(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 = findSessionKey(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 = 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) {
return entry->session_key;
}
return client.shared_secret;
}
uint16_t ClientACL::getEncryptionNonce(const ClientInfo& client) {
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) {
++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;
removeSessionKey(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 = findSessionKey(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;
}
// --- 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
return fs->open(filename, "r", false);
#endif
}
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;
}
// 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);
_session_keys_dirty = true;
}
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() {
if (!_fs) return;
// 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;
}
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();
}

73
src/helpers/ClientACL.h

@ -3,6 +3,7 @@
#include <Arduino.h> // needed for PlatformIO
#include <Mesh.h>
#include <helpers/IdentityStore.h>
#include <helpers/SessionKeyPool.h>
#define PERM_ACL_ROLE_MASK 3 // lower 2 bits
#define PERM_ACL_GUEST 0
@ -15,6 +16,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
uint8_t out_path_len;
uint8_t out_path[MAX_PATH_SIZE];
uint8_t shared_secret[PUB_KEY_SIZE];
@ -29,7 +32,14 @@ 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)
return aead_nonce;
}
return 0;
}
bool isAdmin() const { return (permissions & PERM_ACL_ROLE_MASK) == PERM_ACL_ADMIN; }
};
@ -42,10 +52,23 @@ 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;
bool _session_keys_dirty;
mesh::RNG* _rng;
// Session key pool (Phase 2)
SessionKeyPool session_keys;
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;
_session_keys_dirty = false;
_rng = NULL;
}
void load(FILESYSTEM* _fs, const mesh::LocalIdentity& self_id);
void save(FILESYSTEM* _fs, bool (*filter)(ClientInfo*)=NULL);
@ -57,4 +80,50 @@ public:
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; }
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;
}
// 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();
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
// 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);
};

7
src/helpers/CommonCLI.cpp

@ -168,12 +168,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);
}
}
@ -182,10 +185,12 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, char* command, char* re
if (memcmp(command, "poweroff", 8) == 0 || memcmp(command, "shutdown", 8) == 0) {
_board->powerOff(); // doesn't return
} else 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.zerohop", 14) == 0 && (command[14] == 0 || command[14] == ' ')) {
// send zerohop advert
@ -439,6 +444,8 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, char* command, char* re
_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");
}

4
src/helpers/CommonCLI.h

@ -238,6 +238,10 @@ public:
virtual bool setRxBoostedGain(bool enable) {
return false; // CommonCLI reports unsupported if not overridden by wrapper
};
virtual void onBeforeReboot() {
// no op by default — override to flush nonces, etc.
};
};
class CommonCLI {

18
src/helpers/ContactInfo.h

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

155
src/helpers/SessionKeyPool.h

@ -0,0 +1,155 @@
#pragma once
#include <MeshCore.h>
#include <string.h>
#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
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
uint32_t last_used; // LRU counter (higher = more recent)
};
class SessionKeyPool {
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), 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
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 — 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;
}
}
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++) {
entries[j] = entries[j + 1];
}
memset(&entries[count], 0, sizeof(entries[count]));
return;
}
}
}
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 — 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;
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;
}
};
Loading…
Cancel
Save