Browse Source

Implement session keys using

pull/1677/head
Wessel Nieboer 6 months ago
parent
commit
fc944c0825
No known key found for this signature in database GPG Key ID: 929C8E45E33B5FD2
  1. 36
      examples/companion_radio/DataStore.cpp
  2. 6
      examples/companion_radio/DataStore.h
  3. 22
      examples/companion_radio/MyMesh.cpp
  4. 10
      examples/companion_radio/MyMesh.h
  5. 65
      examples/simple_repeater/MyMesh.cpp
  6. 5
      examples/simple_repeater/MyMesh.h
  7. 65
      examples/simple_room_server/MyMesh.cpp
  8. 5
      examples/simple_room_server/MyMesh.h
  9. 69
      examples/simple_sensor/SensorMesh.cpp
  10. 5
      examples/simple_sensor/SensorMesh.h
  11. 44
      src/Mesh.cpp
  12. 8
      src/Mesh.h
  13. 17
      src/MeshCore.h
  14. 426
      src/helpers/BaseChatMesh.cpp
  15. 32
      src/helpers/BaseChatMesh.h
  16. 216
      src/helpers/ClientACL.cpp
  17. 30
      src/helpers/ClientACL.h
  18. 2
      src/helpers/CommonCLI.cpp
  19. 4
      src/helpers/ContactInfo.h
  20. 117
      src/helpers/SessionKeyPool.h

36
examples/companion_radio/DataStore.cpp

@ -417,6 +417,42 @@ bool DataStore::saveNonces(DataStoreHost* host) {
return false;
}
void DataStore::loadSessionKeys(DataStoreHost* host) {
File file = openRead(_getContactsChannelsFS(), "/sess_keys");
if (file) {
uint8_t rec[71]; // 4-byte pub_key prefix + 1 flags + 2 nonce + 32 session_key + 32 prev_session_key
while (file.read(rec, 71) == 71) {
uint16_t nonce;
memcpy(&nonce, &rec[5], 2);
host->onSessionKeyLoaded(rec, rec[4], nonce, &rec[7], &rec[39]);
}
file.close();
}
}
bool DataStore::saveSessionKeys(DataStoreHost* host) {
File file = openWrite(_getContactsChannelsFS(), "/sess_keys");
if (file) {
uint8_t pub_key_prefix[4];
uint8_t flags;
uint16_t nonce;
uint8_t session_key[32];
uint8_t prev_session_key[32];
for (int idx = 0; idx < MAX_SESSION_KEYS; idx++) {
if (host->getSessionKeyForSave(idx, pub_key_prefix, &flags, &nonce, session_key, prev_session_key)) {
file.write(pub_key_prefix, 4);
file.write(&flags, 1);
file.write((uint8_t*)&nonce, 2);
file.write(session_key, 32);
file.write(prev_session_key, 32);
}
}
file.close();
return true;
}
return false;
}
#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM)
#define MAX_ADVERT_PKT_LEN (2 + 32 + PUB_KEY_SIZE + 4 + SIGNATURE_SIZE + MAX_ADVERT_DATA_SIZE)

6
examples/companion_radio/DataStore.h

@ -13,6 +13,10 @@ public:
virtual bool getChannelForSave(uint8_t channel_idx, ChannelDetails& ch) =0;
virtual bool onNonceLoaded(const uint8_t* pub_key_prefix, uint16_t nonce) { return false; }
virtual bool getNonceForSave(int idx, uint8_t* pub_key_prefix, uint16_t* nonce) { return false; }
virtual bool onSessionKeyLoaded(const uint8_t* pub_key_prefix, uint8_t flags, uint16_t nonce,
const uint8_t* session_key, const uint8_t* prev_session_key) { return false; }
virtual bool getSessionKeyForSave(int idx, uint8_t* pub_key_prefix, uint8_t* flags, uint16_t* nonce,
uint8_t* session_key, uint8_t* prev_session_key) { return false; }
};
class DataStore {
@ -43,6 +47,8 @@ public:
void saveChannels(DataStoreHost* host);
void loadNonces(DataStoreHost* host);
bool saveNonces(DataStoreHost* host);
void loadSessionKeys(DataStoreHost* host);
bool saveSessionKeys(DataStoreHost* host);
void migrateToSecondaryFS();
uint8_t getBlobByKey(const uint8_t key[], int key_len, uint8_t dest_buf[]);
bool putBlobByKey(const uint8_t key[], int key_len, const uint8_t src_buf[], uint8_t len);

22
examples/companion_radio/MyMesh.cpp

@ -975,6 +975,8 @@ void MyMesh::begin(bool has_display) {
if (dirty_reset) saveNonces(); // persist bumped nonces immediately
next_nonce_persist = futureMillis(60000);
_store->loadSessionKeys(this);
addChannel("Public", PUBLIC_GROUP_PSK); // pre-configure Andy's public channel
_store->loadChannels(this);
@ -1472,6 +1474,7 @@ void MyMesh::handleCmdFrame(size_t len) {
saveContacts();
}
if (isNonceDirty()) saveNonces();
saveSessionKeys();
board.reboot();
} else if (cmd_frame[0] == CMD_GET_BATT_AND_STORAGE) {
uint8_t reply[11];
@ -2193,8 +2196,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");
@ -2246,11 +2263,14 @@ void MyMesh::loop() {
dirty_contacts_expiry = 0;
}
// periodic AEAD nonce persistence
// periodic AEAD nonce and session key persistence
if (next_nonce_persist && millisHasNowPassed(next_nonce_persist)) {
if (isNonceDirty()) {
saveNonces();
}
if (isSessionKeysDirty()) {
saveSessionKeys();
}
next_nonce_persist = futureMillis(60000);
}

10
examples/companion_radio/MyMesh.h

@ -161,6 +161,14 @@ protected:
bool getChannelForSave(uint8_t channel_idx, ChannelDetails& ch) override { return getChannel(channel_idx, ch); }
bool onNonceLoaded(const uint8_t* pub_key_prefix, uint16_t nonce) override { return applyLoadedNonce(pub_key_prefix, nonce); }
bool getNonceForSave(int idx, uint8_t* pub_key_prefix, uint16_t* nonce) override { return getNonceEntry(idx, pub_key_prefix, nonce); }
bool onSessionKeyLoaded(const uint8_t* pub_key_prefix, uint8_t flags, uint16_t nonce,
const uint8_t* session_key, const uint8_t* prev_session_key) override {
return applyLoadedSessionKey(pub_key_prefix, flags, nonce, session_key, prev_session_key);
}
bool getSessionKeyForSave(int idx, uint8_t* pub_key_prefix, uint8_t* flags, uint16_t* nonce,
uint8_t* session_key, uint8_t* prev_session_key) override {
return getSessionKeyEntry(idx, pub_key_prefix, flags, nonce, session_key, prev_session_key);
}
void clearPendingReqs() {
pending_login = pending_status = pending_telemetry = pending_discovery = pending_req = 0;
@ -206,6 +214,8 @@ private:
void saveChannels() { _store->saveChannels(this); }
void saveContacts();
void saveNonces() { if (_store->saveNonces(this)) clearNonceDirty(); }
void saveSessionKeys() { if (_store->saveSessionKeys(this)) clearSessionKeysDirty(); }
void onSessionKeysUpdated() override { saveSessionKeys(); }
DataStore* _store;
NodePrefs _prefs;

65
examples/simple_repeater/MyMesh.cpp

@ -633,26 +633,36 @@ uint8_t MyMesh::getPeerFlags(int peer_idx) {
}
uint16_t MyMesh::getPeerNextAeadNonce(int peer_idx) {
int i = matching_peer_indexes[peer_idx];
if (i >= 0 && i < acl.getNumClients())
return acl.nextAeadNonceFor(*acl.getClientByIdx(i));
return 0;
return acl.peerNextAeadNonce(peer_idx, matching_peer_indexes);
}
void MyMesh::onPeerAeadDetected(int peer_idx) {
int i = matching_peer_indexes[peer_idx];
if (i >= 0 && i < acl.getNumClients()) {
auto c = acl.getClientByIdx(i);
if (!(c->flags & CONTACT_FLAG_AEAD)) {
c->flags |= CONTACT_FLAG_AEAD;
if (c->aead_nonce == 0) { // no persisted nonce — seed from RNG to avoid deterministic start
getRNG()->random((uint8_t*)&c->aead_nonce, sizeof(c->aead_nonce));
if (c->aead_nonce == 0) c->aead_nonce = 1;
}
auto* c = acl.resolveClient(peer_idx, matching_peer_indexes);
if (c && !(c->flags & CONTACT_FLAG_AEAD)) {
c->flags |= CONTACT_FLAG_AEAD;
if (c->aead_nonce == 0) { // no persisted nonce — seed from RNG to avoid deterministic start
getRNG()->random((uint8_t*)&c->aead_nonce, sizeof(c->aead_nonce));
if (c->aead_nonce == 0) c->aead_nonce = 1;
}
}
}
const uint8_t* MyMesh::getPeerSessionKey(int peer_idx) {
return acl.peerSessionKey(peer_idx, matching_peer_indexes);
}
const uint8_t* MyMesh::getPeerPrevSessionKey(int peer_idx) {
return acl.peerPrevSessionKey(peer_idx, matching_peer_indexes);
}
void MyMesh::onSessionKeyDecryptSuccess(int peer_idx) {
acl.peerSessionKeyDecryptSuccess(peer_idx, matching_peer_indexes);
}
const uint8_t* MyMesh::getPeerEncryptionKey(int peer_idx, const uint8_t* static_secret) {
return acl.peerEncryptionKey(peer_idx, matching_peer_indexes, static_secret);
}
uint16_t MyMesh::getPeerEncryptionNonce(int peer_idx) {
return acl.peerEncryptionNonce(peer_idx, matching_peer_indexes);
}
static bool isShare(const mesh::Packet *packet) {
if (packet->hasTransportCodes()) {
return packet->transport_codes[0] == 0 && packet->transport_codes[1] == 0; // codes { 0, 0 } means 'send to nowhere'
@ -687,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, acl.nextAeadNonceFor(*client));
mesh::Packet *path = createPathReturn(client->id, enc_key, packet->path, packet->path_len,
PAYLOAD_TYPE_RESPONSE, reply_data, reply_len, enc_nonce);
if (path) sendFloodReply(path, SERVER_RESPONSE_DELAY, packet->getPathHashSize());
} else {
mesh::Packet *reply =
createDatagram(PAYLOAD_TYPE_RESPONSE, client->id, secret, reply_data, reply_len, acl.nextAeadNonceFor(*client));
createDatagram(PAYLOAD_TYPE_RESPONSE, client->id, enc_key, reply_data, reply_len, enc_nonce);
if (reply) {
if (client->out_path_len != OUT_PATH_UNKNOWN) { // we have an out_path, so send DIRECT
sendDirect(reply, client->out_path, client->out_path_len, SERVER_RESPONSE_DELAY);
@ -761,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, acl.nextAeadNonceFor(*client));
auto reply = createDatagram(PAYLOAD_TYPE_TXT_MSG, client->id, acl.getEncryptionKey(*client), temp, 5 + text_len, acl.getEncryptionNonce(*client));
if (reply) {
if (client->out_path_len == OUT_PATH_UNKNOWN) {
sendFloodReply(reply, CLI_REPLY_DELAY_MILLIS, packet->getPathHashSize());
@ -962,6 +989,7 @@ void MyMesh::begin(FILESYSTEM *fs) {
acl.load(_fs, self_id);
acl.setRNG(getRNG());
acl.loadNonces();
acl.loadSessionKeys();
bool dirty_reset = wasDirtyReset(board);
acl.finalizeNonceLoad(dirty_reset);
if (dirty_reset) acl.saveNonces(); // persist bumped nonces immediately
@ -1339,6 +1367,7 @@ void MyMesh::loop() {
// persist dirty AEAD nonces
if (next_nonce_persist && millisHasNowPassed(next_nonce_persist)) {
if (acl.isNonceDirty()) { acl.saveNonces(); }
if (acl.isSessionKeysDirty()) { acl.saveSessionKeys(); }
next_nonce_persist = futureMillis(60000);
}

5
examples/simple_repeater/MyMesh.h

@ -175,6 +175,11 @@ protected:
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;

65
examples/simple_room_server/MyMesh.cpp

@ -71,7 +71,7 @@ void MyMesh::pushPostToClient(ClientInfo *client, PostInfo &post) {
mesh::Utils::sha256((uint8_t *)&client->extra.room.pending_ack, 4, reply_data, len, client->id.pub_key, PUB_KEY_SIZE);
client->extra.room.push_post_timestamp = post.post_timestamp;
auto reply = createDatagram(PAYLOAD_TYPE_TXT_MSG, client->id, client->shared_secret, reply_data, len, acl.nextAeadNonceFor(*client));
auto reply = createDatagram(PAYLOAD_TYPE_TXT_MSG, client->id, acl.getEncryptionKey(*client), reply_data, len, acl.getEncryptionNonce(*client));
if (reply) {
if (client->out_path_len == OUT_PATH_UNKNOWN) {
unsigned long delay_millis = 0;
@ -422,26 +422,36 @@ uint8_t MyMesh::getPeerFlags(int peer_idx) {
}
uint16_t MyMesh::getPeerNextAeadNonce(int peer_idx) {
int i = matching_peer_indexes[peer_idx];
if (i >= 0 && i < acl.getNumClients())
return acl.nextAeadNonceFor(*acl.getClientByIdx(i));
return 0;
return acl.peerNextAeadNonce(peer_idx, matching_peer_indexes);
}
void MyMesh::onPeerAeadDetected(int peer_idx) {
int i = matching_peer_indexes[peer_idx];
if (i >= 0 && i < acl.getNumClients()) {
auto c = acl.getClientByIdx(i);
if (!(c->flags & CONTACT_FLAG_AEAD)) {
c->flags |= CONTACT_FLAG_AEAD;
if (c->aead_nonce == 0) { // no persisted nonce — seed from RNG to avoid deterministic start
getRNG()->random((uint8_t*)&c->aead_nonce, sizeof(c->aead_nonce));
if (c->aead_nonce == 0) c->aead_nonce = 1;
}
auto* c = acl.resolveClient(peer_idx, matching_peer_indexes);
if (c && !(c->flags & CONTACT_FLAG_AEAD)) {
c->flags |= CONTACT_FLAG_AEAD;
if (c->aead_nonce == 0) { // no persisted nonce — seed from RNG to avoid deterministic start
getRNG()->random((uint8_t*)&c->aead_nonce, sizeof(c->aead_nonce));
if (c->aead_nonce == 0) c->aead_nonce = 1;
}
}
}
const uint8_t* MyMesh::getPeerSessionKey(int peer_idx) {
return acl.peerSessionKey(peer_idx, matching_peer_indexes);
}
const uint8_t* MyMesh::getPeerPrevSessionKey(int peer_idx) {
return acl.peerPrevSessionKey(peer_idx, matching_peer_indexes);
}
void MyMesh::onSessionKeyDecryptSuccess(int peer_idx) {
acl.peerSessionKeyDecryptSuccess(peer_idx, matching_peer_indexes);
}
const uint8_t* MyMesh::getPeerEncryptionKey(int peer_idx, const uint8_t* static_secret) {
return acl.peerEncryptionKey(peer_idx, matching_peer_indexes, static_secret);
}
uint16_t MyMesh::getPeerEncryptionNonce(int peer_idx) {
return acl.peerEncryptionNonce(peer_idx, matching_peer_indexes);
}
void MyMesh::onPeerDataRecv(mesh::Packet *packet, uint8_t type, int sender_idx, const uint8_t *secret,
uint8_t *data, size_t len) {
int i = matching_peer_indexes[sender_idx];
@ -535,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, acl.nextAeadNonceFor(*client));
auto reply = createDatagram(PAYLOAD_TYPE_TXT_MSG, client->id, acl.getEncryptionKey(*client), temp, 5 + text_len, acl.getEncryptionNonce(*client));
if (reply) {
if (client->out_path_len == OUT_PATH_UNKNOWN) {
sendFloodReply(reply, delay_millis + SERVER_RESPONSE_DELAY, packet->getPathHashSize());
@ -587,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, acl.nextAeadNonceFor(*client));
mesh::Packet *path = createPathReturn(client->id, enc_key, packet->path, packet->path_len,
PAYLOAD_TYPE_RESPONSE, reply_data, reply_len, enc_nonce);
if (path) sendFloodReply(path, SERVER_RESPONSE_DELAY, packet->getPathHashSize());
} else {
mesh::Packet *reply = createDatagram(PAYLOAD_TYPE_RESPONSE, client->id, secret, reply_data, reply_len, acl.nextAeadNonceFor(*client));
mesh::Packet *reply = createDatagram(PAYLOAD_TYPE_RESPONSE, client->id, enc_key, reply_data, reply_len, enc_nonce);
if (reply) {
if (client->out_path_len != OUT_PATH_UNKNOWN) { // we have an out_path, so send DIRECT
sendDirect(reply, client->out_path, client->out_path_len, SERVER_RESPONSE_DELAY);
@ -708,6 +733,7 @@ void MyMesh::begin(FILESYSTEM *fs) {
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
@ -1061,6 +1087,7 @@ void MyMesh::loop() {
// persist dirty AEAD nonces
if (next_nonce_persist && millisHasNowPassed(next_nonce_persist)) {
if (acl.isNonceDirty()) { acl.saveNonces(); }
if (acl.isSessionKeysDirty()) { acl.saveSessionKeys(); }
next_nonce_persist = futureMillis(60000);
}

5
examples/simple_room_server/MyMesh.h

@ -164,6 +164,11 @@ protected:
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;

69
examples/simple_sensor/SensorMesh.cpp

@ -256,7 +256,7 @@ void SensorMesh::sendAlert(const ClientInfo* c, Trigger* t) {
mesh::Utils::sha256((uint8_t *)&t->expected_acks[t->attempt], 4, data, 5 + text_len, self_id.pub_key, PUB_KEY_SIZE);
t->attempt++;
auto pkt = createDatagram(PAYLOAD_TYPE_TXT_MSG, c->id, c->shared_secret, data, 5 + text_len, acl.nextAeadNonceFor(*c));
auto pkt = createDatagram(PAYLOAD_TYPE_TXT_MSG, c->id, acl.getEncryptionKey(*c), data, 5 + text_len, acl.getEncryptionNonce(*c));
if (pkt) {
if (c->out_path_len != OUT_PATH_UNKNOWN) { // we have an out_path, so send DIRECT
sendDirect(pkt, c->out_path, c->out_path_len);
@ -508,26 +508,36 @@ uint8_t SensorMesh::getPeerFlags(int peer_idx) {
}
uint16_t SensorMesh::getPeerNextAeadNonce(int peer_idx) {
int i = matching_peer_indexes[peer_idx];
if (i >= 0 && i < acl.getNumClients())
return acl.nextAeadNonceFor(*acl.getClientByIdx(i));
return 0;
return acl.peerNextAeadNonce(peer_idx, matching_peer_indexes);
}
void SensorMesh::onPeerAeadDetected(int peer_idx) {
int i = matching_peer_indexes[peer_idx];
if (i >= 0 && i < acl.getNumClients()) {
auto c = acl.getClientByIdx(i);
if (!(c->flags & CONTACT_FLAG_AEAD)) {
c->flags |= CONTACT_FLAG_AEAD;
if (c->aead_nonce == 0) { // no persisted nonce — seed from RNG to avoid deterministic start
getRNG()->random((uint8_t*)&c->aead_nonce, sizeof(c->aead_nonce));
if (c->aead_nonce == 0) c->aead_nonce = 1;
}
auto* c = acl.resolveClient(peer_idx, matching_peer_indexes);
if (c && !(c->flags & CONTACT_FLAG_AEAD)) {
c->flags |= CONTACT_FLAG_AEAD;
if (c->aead_nonce == 0) { // no persisted nonce — seed from RNG to avoid deterministic start
getRNG()->random((uint8_t*)&c->aead_nonce, sizeof(c->aead_nonce));
if (c->aead_nonce == 0) c->aead_nonce = 1;
}
}
}
const uint8_t* SensorMesh::getPeerSessionKey(int peer_idx) {
return acl.peerSessionKey(peer_idx, matching_peer_indexes);
}
const uint8_t* SensorMesh::getPeerPrevSessionKey(int peer_idx) {
return acl.peerPrevSessionKey(peer_idx, matching_peer_indexes);
}
void SensorMesh::onSessionKeyDecryptSuccess(int peer_idx) {
acl.peerSessionKeyDecryptSuccess(peer_idx, matching_peer_indexes);
}
const uint8_t* SensorMesh::getPeerEncryptionKey(int peer_idx, const uint8_t* static_secret) {
return acl.peerEncryptionKey(peer_idx, matching_peer_indexes, static_secret);
}
uint16_t SensorMesh::getPeerEncryptionNonce(int peer_idx) {
return acl.peerEncryptionNonce(peer_idx, matching_peer_indexes);
}
void SensorMesh::sendAckTo(const ClientInfo& dest, uint32_t ack_hash, uint8_t path_hash_size) {
if (dest.out_path_len == OUT_PATH_UNKNOWN) {
mesh::Packet* ack = createAck(ack_hash);
@ -559,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, acl.nextAeadNonceFor(*from));
mesh::Packet* path = createPathReturn(from->id, enc_key, packet->path, packet->path_len,
PAYLOAD_TYPE_RESPONSE, reply_data, reply_len, enc_nonce);
if (path) sendFlood(path, SERVER_RESPONSE_DELAY, packet->getPathHashSize());
} else {
mesh::Packet* reply = createDatagram(PAYLOAD_TYPE_RESPONSE, from->id, secret, reply_data, reply_len, acl.nextAeadNonceFor(*from));
mesh::Packet* reply = createDatagram(PAYLOAD_TYPE_RESPONSE, from->id, enc_key, reply_data, reply_len, enc_nonce);
if (reply) {
if (from->out_path_len != OUT_PATH_UNKNOWN) { // we have an out_path, so send DIRECT
sendDirect(reply, from->out_path, from->out_path_len, SERVER_RESPONSE_DELAY);
@ -597,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, acl.nextAeadNonceFor(*from));
mesh::Packet* path = createPathReturn(from->id, acl.getEncryptionKey(*from), packet->path, packet->path_len,
PAYLOAD_TYPE_ACK, (uint8_t *) &ack_hash, 4, acl.getEncryptionNonce(*from));
if (path) sendFlood(path, TXT_ACK_DELAY, packet->getPathHashSize());
} else {
sendAckTo(*from, ack_hash, packet->getPathHashSize());
@ -626,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, acl.nextAeadNonceFor(*from));
auto reply = createDatagram(PAYLOAD_TYPE_TXT_MSG, from->id, acl.getEncryptionKey(*from), temp, 5 + text_len, acl.getEncryptionNonce(*from));
if (reply) {
if (from->out_path_len == OUT_PATH_UNKNOWN) {
sendFlood(reply, CLI_REPLY_DELAY_MILLIS, packet->getPathHashSize());
@ -779,6 +804,7 @@ void SensorMesh::begin(FILESYSTEM* fs) {
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
@ -1016,6 +1042,7 @@ void SensorMesh::loop() {
// persist dirty AEAD nonces
if (next_nonce_persist && millisHasNowPassed(next_nonce_persist)) {
if (acl.isNonceDirty()) { acl.saveNonces(); }
if (acl.isSessionKeysDirty()) { acl.saveSessionKeys(); }
next_nonce_persist = futureMillis(60000);
}
}

5
examples/simple_sensor/SensorMesh.h

@ -131,6 +131,11 @@ protected:
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;

44
src/Mesh.cpp

@ -156,17 +156,46 @@ DispatcherAction Mesh::onRecvPacket(Packet* pkt) {
uint8_t data[MAX_PACKET_PAYLOAD];
int macAndDataLen = pkt->payload_len - i;
// Try-both decode: AEAD-first for peers known to support it (avoids 1/65536
// ECB false-positive on AEAD packets), ECB-first for unknown/legacy peers.
// Mask out route type bits — they are set after encryption and vary per hop.
uint8_t assoc[3] = { (uint8_t)(pkt->header & ~PH_ROUTE_MASK), dest_hash, src_hash };
int len;
int len = 0;
bool decoded_aead = false;
if (getPeerFlags(j) & CONTACT_FLAG_AEAD) {
bool decoded_session = false;
// Session key decode path: try session key(s) first if available
const uint8_t* sess_key = getPeerSessionKey(j);
if (sess_key) {
len = Utils::aeadDecrypt(sess_key, data, macAndData, macAndDataLen, assoc, 3, dest_hash, src_hash);
if (len > 0) {
decoded_session = true;
decoded_aead = true;
} else {
// Try prev_session_key (dual-decode window)
const uint8_t* prev_key = getPeerPrevSessionKey(j);
if (prev_key) {
len = Utils::aeadDecrypt(prev_key, data, macAndData, macAndDataLen, assoc, 3, dest_hash, src_hash);
if (len > 0) {
decoded_session = true;
decoded_aead = true;
}
}
}
if (!decoded_session) {
// Session key failed — try static ECDH, then ECB
len = Utils::aeadDecrypt(secret, data, macAndData, macAndDataLen, assoc, 3, dest_hash, src_hash);
if (len > 0) {
decoded_aead = true;
} else {
len = Utils::MACThenDecrypt(secret, data, macAndData, macAndDataLen);
}
}
} else if (getPeerFlags(j) & CONTACT_FLAG_AEAD) {
// No session key — standard AEAD-first decode for AEAD-capable peers
len = Utils::aeadDecrypt(secret, data, macAndData, macAndDataLen, assoc, 3, dest_hash, src_hash);
if (len > 0) decoded_aead = true;
else len = Utils::MACThenDecrypt(secret, data, macAndData, macAndDataLen);
} else {
// Legacy ECB-first decode
len = Utils::MACThenDecrypt(secret, data, macAndData, macAndDataLen);
if (len <= 0) {
len = Utils::aeadDecrypt(secret, data, macAndData, macAndDataLen, assoc, 3, dest_hash, src_hash);
@ -174,7 +203,8 @@ DispatcherAction Mesh::onRecvPacket(Packet* pkt) {
}
}
if (len > 0) { // success!
if (decoded_aead) onPeerAeadDetected(j);
if (decoded_session) onSessionKeyDecryptSuccess(j);
else if (decoded_aead) onPeerAeadDetected(j);
if (pkt->getPayloadType() == PAYLOAD_TYPE_PATH) {
int k = 0;
uint8_t path_len = data[k++];
@ -191,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, getPeerNextAeadNonce(j));
mesh::Packet* rpath = createPathReturn(&src_hash, getPeerEncryptionKey(j, secret), pkt->path, pkt->path_len, 0, NULL, 0, getPeerEncryptionNonce(j));
if (rpath) sendDirect(rpath, path, path_len, 500);
}
}
} else {
onPeerDataRecv(pkt, pkt->getPayloadType(), j, secret, data, len);
onPeerDataRecv(pkt, pkt->getPayloadType(), j, getPeerEncryptionKey(j, secret), data, len);
}
found = true;
break;

8
src/Mesh.h

@ -87,6 +87,14 @@ protected:
virtual uint16_t getPeerNextAeadNonce(int peer_idx) { return 0; }
virtual void onPeerAeadDetected(int peer_idx) { }
// Session key support (Phase 2)
virtual const uint8_t* getPeerSessionKey(int peer_idx) { return NULL; }
virtual const uint8_t* getPeerPrevSessionKey(int peer_idx) { return NULL; }
virtual void onSessionKeyDecryptSuccess(int peer_idx) { }
// Encryption key/nonce for outgoing messages to peer (session key with static ECDH fallback)
virtual const uint8_t* getPeerEncryptionKey(int peer_idx, const uint8_t* static_secret) { return static_secret; }
virtual uint16_t getPeerEncryptionNonce(int peer_idx) { return getPeerNextAeadNonce(peer_idx); }
/**
* \brief A (now decrypted) data packet has been received (by a known peer).
* NOTE: these can be received multiple times (per sender/msg-id), via different routes

17
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
@ -25,7 +26,21 @@
// AEAD nonce persistence
#define NONCE_PERSIST_INTERVAL 50 // persist every N messages per peer
#define NONCE_BOOT_BUMP 100 // add this on load after dirty boot (must be >= 2 * PERSIST_INTERVAL)
#define NONCE_BOOT_BUMP 50 // add this on load after dirty boot (must be >= PERSIST_INTERVAL)
// Session key negotiation (Phase 2)
#define REQ_TYPE_SESSION_KEY_INIT 0x08
#define RESP_TYPE_SESSION_KEY_ACCEPT 0x08 // response type byte in PAYLOAD_TYPE_RESPONSE
#define NONCE_REKEY_THRESHOLD 60000 // start renegotiation when nonce exceeds this
#define NONCE_INITIAL_MIN 1000 // min random nonce seed for new contacts
#define NONCE_INITIAL_MAX 50000 // max random nonce seed for new contacts
#define SESSION_KEY_TIMEOUT_MS 180000 // 3 minutes per attempt
#define SESSION_KEY_MAX_RETRIES 3 // attempts per negotiation round
#define MAX_SESSION_KEYS 8 // max concurrent session key entries
#define SESSION_KEY_STALE_THRESHOLD 50 // sends without recv before fallback to static ECDH
#define SESSION_KEY_ECB_THRESHOLD 100 // sends without recv before fallback to ECB
#define SESSION_KEY_ABANDON_THRESHOLD 255 // sends without recv before clearing AEAD + session key
#define MAX_PACKET_PAYLOAD 184
#define MAX_GROUP_DATA_LENGTH (MAX_PACKET_PAYLOAD - CIPHER_BLOCK_SIZE - 3)

426
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
@ -44,6 +46,20 @@ void BaseChatMesh::finalizeNonceLoad(bool needs_bump) {
nonce_at_last_persist[i] = contacts[i].aead_nonce;
}
nonce_dirty = false;
// Apply boot bump to session key nonces too
if (needs_bump) {
for (int i = 0; i < session_keys.getCount(); i++) {
auto entry = session_keys.getByIdx(i);
if (entry && (entry->state == SESSION_STATE_ACTIVE || entry->state == SESSION_STATE_DUAL_DECODE)) {
uint16_t old_nonce = entry->nonce;
entry->nonce += NONCE_BOOT_BUMP;
if (entry->nonce <= old_nonce) {
entry->nonce = 65535; // wrapped — force exhaustion so renegotiation happens
}
}
}
}
}
bool BaseChatMesh::getNonceEntry(int idx, uint8_t* pub_key_prefix, uint16_t* nonce) {
@ -161,8 +177,7 @@ void BaseChatMesh::populateContactFromAdvert(ContactInfo& ci, const mesh::Identi
}
ci.last_advert_timestamp = timestamp;
ci.lastmod = getRTCClock()->getCurrentTime();
getRNG()->random((uint8_t*)&ci.aead_nonce, sizeof(ci.aead_nonce)); // seed AEAD nonce from HW RNG
if (ci.aead_nonce == 0) ci.aead_nonce = 1;
ci.aead_nonce = (uint16_t)getRNG()->nextInt(NONCE_INITIAL_MIN, NONCE_INITIAL_MAX + 1);
if (parser.getFeat1() & FEAT1_AEAD_SUPPORT) {
ci.flags |= CONTACT_FLAG_AEAD;
}
@ -320,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, nextAeadNonceFor(from));
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);
@ -332,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, nextAeadNonceFor(from));
mesh::Packet* path = createPathReturn(from.id, getEncryptionKeyFor(from), packet->path, packet->path_len, 0, NULL, 0, getEncryptionNonceFor(from));
if (path) sendFloodScoped(from, path);
}
} else if (flags == TXT_TYPE_SIGNED_PLAIN) {
@ -347,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, nextAeadNonceFor(from));
mesh::Packet* path = createPathReturn(from.id, getEncryptionKeyFor(from), packet->path, packet->path_len,
PAYLOAD_TYPE_ACK, (uint8_t *) &ack_hash, 4, getEncryptionNonceFor(from));
if (path) sendFloodScoped(from, path, TXT_ACK_DELAY);
} else {
sendAckTo(from, (uint8_t *) &ack_hash);
@ -359,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, nextAeadNonceFor(from));
mesh::Packet* path = createPathReturn(from.id, enc_key, packet->path, packet->path_len,
PAYLOAD_TYPE_RESPONSE, temp_buf, reply_len, enc_nonce);
if (path) sendFloodScoped(from, path, SERVER_RESPONSE_DELAY);
} else {
mesh::Packet* reply = createDatagram(PAYLOAD_TYPE_RESPONSE, from.id, secret, temp_buf, reply_len, nextAeadNonceFor(from));
mesh::Packet* reply = createDatagram(PAYLOAD_TYPE_RESPONSE, from.id, enc_key, temp_buf, reply_len, enc_nonce);
if (reply) {
if (from.out_path_len != OUT_PATH_UNKNOWN) { // we have an out_path, so send DIRECT
sendDirect(reply, from.out_path, from.out_path_len, SERVER_RESPONSE_DELAY);
@ -378,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);
@ -412,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
}
@ -433,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, nextAeadNonceFor(contact));
mesh::Packet* rpath = createPathReturn(contact.id, getEncryptionKeyFor(contact), path, path_len, 0, NULL, 0, getEncryptionNonceFor(contact));
if (rpath) sendDirect(rpath, contact.out_path, contact.out_path_len, 3000); // 3 second delay
}
@ -509,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, nextAeadNonceFor(recipient));
return createDatagram(PAYLOAD_TYPE_TXT_MSG, recipient.id, getEncryptionKeyFor(recipient), temp, len, getEncryptionNonceFor(recipient));
}
int BaseChatMesh::sendMessage(const ContactInfo& recipient, uint32_t timestamp, uint8_t attempt, const char* text, uint32_t& expected_ack, uint32_t& est_timeout) {
@ -540,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, nextAeadNonceFor(recipient));
auto pkt = createDatagram(PAYLOAD_TYPE_TXT_MSG, recipient.id, getEncryptionKeyFor(recipient), temp, 5 + text_len, getEncryptionNonceFor(recipient));
if (pkt == NULL) return MSG_SEND_FAILED;
uint32_t t = _radio->getEstAirtimeFor(pkt->getRawLength());
@ -712,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, nextAeadNonceFor(recipient));
pkt = createDatagram(PAYLOAD_TYPE_REQ, recipient.id, getEncryptionKeyFor(recipient), temp, 4 + data_len, getEncryptionNonceFor(recipient));
}
if (pkt) {
uint32_t t = _radio->getEstAirtimeFor(pkt->getRawLength());
@ -739,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), nextAeadNonceFor(recipient));
pkt = createDatagram(PAYLOAD_TYPE_REQ, recipient.id, getEncryptionKeyFor(recipient), temp, sizeof(temp), getEncryptionNonceFor(recipient));
}
if (pkt) {
uint32_t t = _radio->getEstAirtimeFor(pkt->getRawLength());
@ -862,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, nextAeadNonceFor(*contact));
auto pkt = createDatagram(PAYLOAD_TYPE_REQ, contact->id, getEncryptionKeyFor(*contact), data, 9, getEncryptionNonceFor(*contact));
if (pkt) {
sendDirect(pkt, contact->out_path, contact->out_path_len);
}
@ -927,8 +977,7 @@ bool BaseChatMesh::addContact(const ContactInfo& contact) {
int idx = dest - contacts;
*dest = contact;
dest->shared_secret_valid = false; // mark shared_secret as needing calculation
getRNG()->random((uint8_t*)&dest->aead_nonce, sizeof(dest->aead_nonce)); // always seed fresh from HW RNG
if (dest->aead_nonce == 0) dest->aead_nonce = 1;
dest->aead_nonce = (uint16_t)getRNG()->nextInt(NONCE_INITIAL_MIN, NONCE_INITIAL_MAX + 1);
nonce_at_last_persist[idx] = dest->aead_nonce;
return true; // success
}
@ -942,6 +991,8 @@ bool BaseChatMesh::removeContact(ContactInfo& contact) {
}
if (idx >= num_contacts) return false; // not found
session_keys.remove(contact.id.pub_key); // also remove session key if any
// remove from contacts array and parallel nonce tracking
num_contacts--;
while (idx < num_contacts) {
@ -1044,4 +1095,337 @@ void BaseChatMesh::loop() {
releasePacket(_pendingLoopback); // undo the obtainNewPacket()
_pendingLoopback = NULL;
}
checkSessionKeyTimeouts();
// Process deferred session key negotiation (set by getEncryptionNonceFor)
if (_pending_rekey_idx >= 0 && _pending_rekey_idx < num_contacts) {
int idx = _pending_rekey_idx;
_pending_rekey_idx = -1;
initiateSessionKeyNegotiation(contacts[idx]);
}
}
// --- Session key support (Phase 2 — initiator) ---
static bool canUseSessionKey(const SessionKeyEntry* entry) {
if (!entry) return false;
// ACTIVE/DUAL_DECODE: normal session key use
// INIT_SENT with nonce > 1: renegotiation in progress, keep using old session key
// (nonce == 0 means fresh allocation with no prior session key)
bool valid_state = (entry->state == SESSION_STATE_ACTIVE || entry->state == SESSION_STATE_DUAL_DECODE)
|| (entry->state == SESSION_STATE_INIT_SENT && entry->nonce > 1);
return valid_state
&& entry->sends_since_last_recv < SESSION_KEY_STALE_THRESHOLD
&& entry->nonce < 65535; // nonce exhausted → fall back to static ECDH
}
const uint8_t* BaseChatMesh::getEncryptionKeyFor(const ContactInfo& contact) {
auto entry = session_keys.findByPrefix(contact.id.pub_key);
if (canUseSessionKey(entry)) {
return entry->session_key;
}
return contact.getSharedSecret(self_id);
}
uint16_t BaseChatMesh::getEncryptionNonceFor(const ContactInfo& contact) {
uint16_t nonce = 0;
auto entry = session_keys.findByPrefix(contact.id.pub_key);
if (canUseSessionKey(entry)) {
++entry->nonce;
if (entry->sends_since_last_recv < 255) entry->sends_since_last_recv++;
session_keys_dirty = true;
nonce = entry->nonce;
} else if (entry && entry->sends_since_last_recv < 255) {
// Progressive fallback: keep incrementing counter even when not using session key
entry->sends_since_last_recv++;
if (entry->sends_since_last_recv >= SESSION_KEY_ABANDON_THRESHOLD) {
// Give up: clear AEAD capability and remove session key
int idx = &contact - contacts;
if (idx >= 0 && idx < num_contacts)
contacts[idx].flags &= ~CONTACT_FLAG_AEAD;
session_keys.remove(contact.id.pub_key);
onSessionKeysUpdated();
// nonce = 0 (ECB)
} else if (entry->sends_since_last_recv >= SESSION_KEY_ECB_THRESHOLD) {
// nonce = 0 (ECB)
} else {
nonce = nextAeadNonceFor(contact);
}
} else {
nonce = nextAeadNonceFor(contact);
}
// Trigger session key negotiation on the next loop() tick.
// Checking here (the single funnel for all outgoing encryption) ensures no
// send path can silently skip a trigger — unlike the old per-call-site approach.
if (_pending_rekey_idx < 0 && shouldInitiateSessionKey(contact)) {
_pending_rekey_idx = &contact - contacts;
}
return nonce;
}
bool BaseChatMesh::shouldInitiateSessionKey(const ContactInfo& contact) {
// Only for AEAD-capable peers
if (!(contact.flags & CONTACT_FLAG_AEAD)) return false;
// Need a known path to send the request
if (contact.out_path_len < 0) return false;
auto entry = session_keys.findByPrefix(contact.id.pub_key);
// Don't trigger if negotiation already in progress
if (entry && entry->state == SESSION_STATE_INIT_SENT) return false;
// Determine intervals based on hop count tier:
// direct (0): static=100, session=100
// 1–9 hops: static=500, session=300
// 10+ hops: static=1000, session=300
uint16_t static_interval, session_interval;
if (contact.out_path_len == 0) {
static_interval = 100;
session_interval = 100;
} else if (contact.out_path_len < 10) {
static_interval = 500;
session_interval = 300;
} else {
static_interval = 1000;
session_interval = 300;
}
if (entry && (entry->state == SESSION_STATE_ACTIVE || entry->state == SESSION_STATE_DUAL_DECODE)) {
if (entry->nonce < 65535) {
// Active session key with remaining nonces — renegotiate after nonce > 60000
if (entry->nonce <= NONCE_REKEY_THRESHOLD) return false;
return ((entry->nonce - NONCE_REKEY_THRESHOLD) % session_interval) == 0;
}
// Session key nonce exhausted — fall through to static ECDH trigger
}
// No session key (or state=NONE) — trigger based on static ECDH nonce vs interval
if (contact.aead_nonce == 0) return false; // no messages sent yet
return (contact.aead_nonce % static_interval) == 0;
}
bool BaseChatMesh::initiateSessionKeyNegotiation(const ContactInfo& contact) {
auto entry = session_keys.allocate(contact.id.pub_key);
if (!entry) return false;
// Don't start a new negotiation if one is already pending
if (entry->state == SESSION_STATE_INIT_SENT) return false;
// Generate ephemeral keypair A
uint8_t seed[SEED_SIZE];
getRNG()->random(seed, SEED_SIZE);
ed25519_create_keypair(entry->ephemeral_pub, entry->ephemeral_prv, seed);
memset(seed, 0, SEED_SIZE);
// Send REQ_TYPE_SESSION_KEY_INIT with ephemeral_pub_A
uint8_t req_data[1 + PUB_KEY_SIZE];
req_data[0] = REQ_TYPE_SESSION_KEY_INIT;
memcpy(&req_data[1], entry->ephemeral_pub, PUB_KEY_SIZE);
uint32_t tag, est_timeout;
int rc = sendRequest(contact, req_data, sizeof(req_data), tag, est_timeout);
if (rc == MSG_SEND_FAILED) {
memset(entry->ephemeral_prv, 0, PRV_KEY_SIZE);
memset(entry->ephemeral_pub, 0, PUB_KEY_SIZE);
return false;
}
entry->state = SESSION_STATE_INIT_SENT;
entry->retries_left = SESSION_KEY_MAX_RETRIES - 1;
entry->timeout_at = futureMillis(SESSION_KEY_TIMEOUT_MS);
return true;
}
bool BaseChatMesh::handleSessionKeyResponse(ContactInfo& contact, const uint8_t* data, uint8_t len) {
// Response format: [timestamp:4][RESP_TYPE_SESSION_KEY_ACCEPT:1][ephemeral_pub_B:32]
if (len < 5 + PUB_KEY_SIZE) return false;
if (data[4] != RESP_TYPE_SESSION_KEY_ACCEPT) return false;
auto entry = session_keys.findByPrefix(contact.id.pub_key);
if (!entry || entry->state != SESSION_STATE_INIT_SENT) return false;
const uint8_t* ephemeral_pub_B = &data[5];
// Compute ephemeral_secret via X25519
uint8_t ephemeral_secret[PUB_KEY_SIZE];
ed25519_key_exchange(ephemeral_secret, ephemeral_pub_B, entry->ephemeral_prv);
memset(entry->ephemeral_prv, 0, PRV_KEY_SIZE);
memset(entry->ephemeral_pub, 0, PUB_KEY_SIZE);
// Derive session_key = HMAC-SHA256(static_shared_secret, ephemeral_secret)
const uint8_t* static_secret = contact.getSharedSecret(self_id);
uint8_t new_session_key[SESSION_KEY_SIZE];
{
SHA256 sha;
sha.resetHMAC(static_secret, PUB_KEY_SIZE);
sha.update(ephemeral_secret, PUB_KEY_SIZE);
sha.finalizeHMAC(static_secret, PUB_KEY_SIZE, new_session_key, SESSION_KEY_SIZE);
}
memset(ephemeral_secret, 0, PUB_KEY_SIZE);
// Activate session key
memcpy(entry->session_key, new_session_key, SESSION_KEY_SIZE);
memset(new_session_key, 0, SESSION_KEY_SIZE);
entry->nonce = 1;
entry->state = SESSION_STATE_ACTIVE;
entry->sends_since_last_recv = 0;
entry->retries_left = 0;
entry->timeout_at = 0;
MESH_DEBUG_PRINTLN("Session key established with: %s", contact.name);
onSessionKeysUpdated();
return true;
}
uint8_t BaseChatMesh::handleIncomingSessionKeyInit(ContactInfo& from, const uint8_t* ephemeral_pub_A, uint8_t* reply_buf) {
// 1. Generate ephemeral keypair B
uint8_t seed[SEED_SIZE];
getRNG()->random(seed, SEED_SIZE);
uint8_t ephemeral_pub_B[PUB_KEY_SIZE];
uint8_t ephemeral_prv_B[PRV_KEY_SIZE];
ed25519_create_keypair(ephemeral_pub_B, ephemeral_prv_B, seed);
memset(seed, 0, SEED_SIZE);
// 2. Compute ephemeral_secret via X25519
uint8_t ephemeral_secret[PUB_KEY_SIZE];
ed25519_key_exchange(ephemeral_secret, ephemeral_pub_A, ephemeral_prv_B);
memset(ephemeral_prv_B, 0, PRV_KEY_SIZE);
// 3. Derive session_key = HMAC-SHA256(static_shared_secret, ephemeral_secret)
const uint8_t* static_secret = from.getSharedSecret(self_id);
uint8_t new_session_key[SESSION_KEY_SIZE];
{
SHA256 sha;
sha.resetHMAC(static_secret, PUB_KEY_SIZE);
sha.update(ephemeral_secret, PUB_KEY_SIZE);
sha.finalizeHMAC(static_secret, PUB_KEY_SIZE, new_session_key, SESSION_KEY_SIZE);
}
memset(ephemeral_secret, 0, PUB_KEY_SIZE);
// 4. Store in pool (dual-decode: new key active, old key still valid)
auto entry = session_keys.allocate(from.id.pub_key);
if (!entry) return 0;
if (entry->state == SESSION_STATE_ACTIVE || entry->state == SESSION_STATE_DUAL_DECODE) {
memcpy(entry->prev_session_key, entry->session_key, SESSION_KEY_SIZE);
}
memcpy(entry->session_key, new_session_key, SESSION_KEY_SIZE);
entry->nonce = 1;
entry->state = SESSION_STATE_DUAL_DECODE;
entry->sends_since_last_recv = 0;
memset(new_session_key, 0, SESSION_KEY_SIZE);
// 5. Persist immediately
onSessionKeysUpdated();
// 6. Write ephemeral_pub_B to reply
memcpy(reply_buf, ephemeral_pub_B, PUB_KEY_SIZE);
MESH_DEBUG_PRINTLN("Session key INIT accepted from: %s", from.name);
return PUB_KEY_SIZE;
}
void BaseChatMesh::checkSessionKeyTimeouts() {
for (int i = 0; i < session_keys.getCount(); i++) {
auto entry = session_keys.getByIdx(i);
if (!entry || entry->state != SESSION_STATE_INIT_SENT) continue;
if (entry->timeout_at == 0 || !millisHasNowPassed(entry->timeout_at)) continue;
if (entry->retries_left > 0) {
// Retry: find the contact and resend INIT
for (int j = 0; j < num_contacts; j++) {
if (memcmp(contacts[j].id.pub_key, entry->peer_pub_prefix, 4) == 0) {
entry->retries_left--;
entry->timeout_at = futureMillis(SESSION_KEY_TIMEOUT_MS);
// Regenerate ephemeral keypair for retry
uint8_t seed[SEED_SIZE];
getRNG()->random(seed, SEED_SIZE);
ed25519_create_keypair(entry->ephemeral_pub, entry->ephemeral_prv, seed);
memset(seed, 0, SEED_SIZE);
uint8_t req_data[1 + PUB_KEY_SIZE];
req_data[0] = REQ_TYPE_SESSION_KEY_INIT;
memcpy(&req_data[1], entry->ephemeral_pub, PUB_KEY_SIZE);
uint32_t tag, est_timeout;
sendRequest(contacts[j], req_data, sizeof(req_data), tag, est_timeout);
break;
}
}
} else {
// All retries exhausted — clean up
memset(entry->ephemeral_prv, 0, PRV_KEY_SIZE);
memset(entry->ephemeral_pub, 0, PUB_KEY_SIZE);
entry->state = SESSION_STATE_NONE;
entry->timeout_at = 0;
}
}
}
// Virtual overrides for session key decrypt path
const uint8_t* BaseChatMesh::getPeerSessionKey(int peer_idx) {
int i = matching_peer_indexes[peer_idx];
if (i >= 0 && i < num_contacts) {
auto entry = session_keys.findByPrefix(contacts[i].id.pub_key);
// Also try decode during INIT_SENT renegotiation (nonce > 1 means prior key exists)
if (entry && (entry->state == SESSION_STATE_ACTIVE || entry->state == SESSION_STATE_DUAL_DECODE
|| (entry->state == SESSION_STATE_INIT_SENT && entry->nonce > 1)))
return entry->session_key;
}
return nullptr;
}
const uint8_t* BaseChatMesh::getPeerPrevSessionKey(int peer_idx) {
int i = matching_peer_indexes[peer_idx];
if (i >= 0 && i < num_contacts) {
auto entry = session_keys.findByPrefix(contacts[i].id.pub_key);
if (entry && entry->state == SESSION_STATE_DUAL_DECODE)
return entry->prev_session_key;
}
return nullptr;
}
void BaseChatMesh::onSessionKeyDecryptSuccess(int peer_idx) {
int i = matching_peer_indexes[peer_idx];
if (i >= 0 && i < num_contacts) {
auto entry = session_keys.findByPrefix(contacts[i].id.pub_key);
if (entry) {
bool changed = (entry->state == SESSION_STATE_DUAL_DECODE);
if (changed) {
memset(entry->prev_session_key, 0, SESSION_KEY_SIZE);
entry->state = SESSION_STATE_ACTIVE;
}
entry->sends_since_last_recv = 0;
if (changed) onSessionKeysUpdated();
}
}
}
const uint8_t* BaseChatMesh::getPeerEncryptionKey(int peer_idx, const uint8_t* static_secret) {
int i = matching_peer_indexes[peer_idx];
if (i >= 0 && i < num_contacts)
return getEncryptionKeyFor(contacts[i]);
return static_secret;
}
uint16_t BaseChatMesh::getPeerEncryptionNonce(int peer_idx) {
int i = matching_peer_indexes[peer_idx];
if (i >= 0 && i < num_contacts)
return getEncryptionNonceFor(contacts[i]);
return getPeerNextAeadNonce(peer_idx);
}
// Session key persistence helpers (delegated to subclass for file I/O)
bool BaseChatMesh::applyLoadedSessionKey(const uint8_t* pub_key_prefix, uint8_t flags, uint16_t nonce,
const uint8_t* session_key, const uint8_t* prev_session_key) {
return session_keys.applyLoaded(pub_key_prefix, flags, nonce, session_key, prev_session_key);
}
bool BaseChatMesh::getSessionKeyEntry(int idx, uint8_t* pub_key_prefix, uint8_t* flags, uint16_t* nonce,
uint8_t* session_key, uint8_t* prev_session_key) {
return session_keys.getEntryForSave(idx, pub_key_prefix, flags, nonce, session_key, prev_session_key);
}

32
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
@ -78,6 +79,11 @@ class BaseChatMesh : public mesh::Mesh {
uint16_t nonce_at_last_persist[MAX_CONTACTS];
bool nonce_dirty;
// Session key pool (Phase 2)
SessionKeyPool session_keys;
bool session_keys_dirty;
int _pending_rekey_idx; // contact index needing session key negotiation, -1 = none
mesh::Packet* composeMsgPacket(const ContactInfo& recipient, uint32_t timestamp, uint8_t attempt, const char *text, uint32_t& expected_ack);
void sendAckTo(const ContactInfo& dest, const uint8_t* ack_hash, uint8_t ack_len=4);
@ -96,6 +102,8 @@ protected:
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();
@ -149,12 +157,36 @@ protected:
nonce_dirty = false;
}
// Session key support (Phase 2 — initiator)
virtual void onSessionKeysUpdated() { session_keys_dirty = true; } // called when session key pool changes; override to persist
const uint8_t* getEncryptionKeyFor(const ContactInfo& contact);
uint16_t getEncryptionNonceFor(const ContactInfo& contact);
bool shouldInitiateSessionKey(const ContactInfo& contact);
bool initiateSessionKeyNegotiation(const ContactInfo& contact);
bool handleSessionKeyResponse(ContactInfo& contact, const uint8_t* data, uint8_t len);
uint8_t handleIncomingSessionKeyInit(ContactInfo& from, const uint8_t* ephemeral_pub_A, uint8_t* reply_buf);
void checkSessionKeyTimeouts();
// Session key persistence helpers (for subclass to call)
bool applyLoadedSessionKey(const uint8_t* pub_key_prefix, uint8_t flags, uint16_t nonce,
const uint8_t* session_key, const uint8_t* prev_session_key);
bool getSessionKeyEntry(int idx, uint8_t* pub_key_prefix, uint8_t* flags, uint16_t* nonce,
uint8_t* session_key, uint8_t* prev_session_key);
int getSessionKeyCount() const { return session_keys.getCount(); }
bool isSessionKeysDirty() const { return session_keys_dirty; }
void clearSessionKeysDirty() { session_keys_dirty = false; }
// Mesh overrides
void onAdvertRecv(mesh::Packet* packet, const mesh::Identity& id, uint32_t timestamp, const uint8_t* app_data, size_t app_data_len) override;
int searchPeersByHash(const uint8_t* hash) override;
void getPeerSharedSecret(uint8_t* dest_secret, int peer_idx) override;
uint8_t getPeerFlags(int peer_idx) override;
uint16_t getPeerNextAeadNonce(int peer_idx) override;
const uint8_t* getPeerSessionKey(int peer_idx) override;
const uint8_t* getPeerPrevSessionKey(int peer_idx) override;
void onSessionKeyDecryptSuccess(int peer_idx) override;
const uint8_t* getPeerEncryptionKey(int peer_idx, const uint8_t* static_secret) override;
uint16_t getPeerEncryptionNonce(int peer_idx) override;
void onPeerDataRecv(mesh::Packet* packet, uint8_t type, int sender_idx, const uint8_t* secret, uint8_t* data, size_t len) override;
bool onPeerPathRecv(mesh::Packet* packet, int sender_idx, const uint8_t* secret, uint8_t* path, uint8_t path_len, uint8_t extra_type, uint8_t* extra, uint8_t extra_len) override;
void onAckRecv(mesh::Packet* packet, uint32_t ack_crc) override;

216
src/helpers/ClientACL.cpp

@ -1,5 +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)
@ -118,8 +120,7 @@ ClientInfo* ClientACL::putClient(const mesh::Identity& id, uint8_t init_perms) {
c->id = id;
c->out_path_len = OUT_PATH_UNKNOWN;
if (_rng) {
_rng->random((uint8_t*)&c->aead_nonce, sizeof(c->aead_nonce));
if (c->aead_nonce == 0) c->aead_nonce = 1;
c->aead_nonce = (uint16_t)_rng->nextInt(NONCE_INITIAL_MIN, NONCE_INITIAL_MAX + 1);
}
nonce_at_last_persist[idx] = c->aead_nonce;
return c;
@ -191,6 +192,20 @@ void ClientACL::finalizeNonceLoad(bool needs_bump) {
nonce_at_last_persist[i] = clients[i].aead_nonce;
}
nonce_dirty = false;
// Apply boot bump to session key nonces too
if (needs_bump) {
for (int i = 0; i < session_keys.getCount(); i++) {
auto entry = session_keys.getByIdx(i);
if (entry && (entry->state == SESSION_STATE_ACTIVE || entry->state == SESSION_STATE_DUAL_DECODE)) {
uint16_t old_nonce = entry->nonce;
entry->nonce += NONCE_BOOT_BUMP;
if (entry->nonce <= old_nonce) {
entry->nonce = 65535; // wrapped — force exhaustion so renegotiation happens
}
}
}
}
}
bool ClientACL::applyPermissions(const mesh::LocalIdentity& self_id, const uint8_t* pubkey, int key_len, uint8_t perms) {
@ -199,6 +214,8 @@ bool ClientACL::applyPermissions(const mesh::LocalIdentity& self_id, const uint8
c = getClient(pubkey, key_len);
if (c == NULL) return false; // partial pubkey not found
session_keys.remove(c->id.pub_key); // also remove session key if any
num_clients--; // delete from contacts[]
int i = c - clients;
while (i < num_clients) {
@ -217,3 +234,198 @@ bool ClientACL::applyPermissions(const mesh::LocalIdentity& self_id, const uint8
}
return true;
}
// --- Session key support (Phase 2) ---
int ClientACL::handleSessionKeyInit(const ClientInfo* client, const uint8_t* ephemeral_pub_A, uint8_t* reply_buf, mesh::RNG* rng) {
// 1. Generate ephemeral keypair B
uint8_t seed[SEED_SIZE];
rng->random(seed, SEED_SIZE);
uint8_t ephemeral_pub_B[PUB_KEY_SIZE];
uint8_t ephemeral_prv_B[PRV_KEY_SIZE];
ed25519_create_keypair(ephemeral_pub_B, ephemeral_prv_B, seed);
memset(seed, 0, SEED_SIZE);
// 2. Compute ephemeral_secret via X25519
uint8_t ephemeral_secret[PUB_KEY_SIZE];
ed25519_key_exchange(ephemeral_secret, ephemeral_pub_A, ephemeral_prv_B);
memset(ephemeral_prv_B, 0, PRV_KEY_SIZE);
// 3. Derive session_key = HMAC-SHA256(static_shared_secret, ephemeral_secret)
uint8_t new_session_key[SESSION_KEY_SIZE];
{
SHA256 sha;
sha.resetHMAC(client->shared_secret, PUB_KEY_SIZE);
sha.update(ephemeral_secret, PUB_KEY_SIZE);
sha.finalizeHMAC(client->shared_secret, PUB_KEY_SIZE, new_session_key, SESSION_KEY_SIZE);
}
memset(ephemeral_secret, 0, PUB_KEY_SIZE);
// 4. Store in pool (dual-decode: new key active, old key still valid)
auto entry = session_keys.allocate(client->id.pub_key);
if (!entry) return 0;
if (entry->state == SESSION_STATE_ACTIVE || entry->state == SESSION_STATE_DUAL_DECODE) {
memcpy(entry->prev_session_key, entry->session_key, SESSION_KEY_SIZE);
}
memcpy(entry->session_key, new_session_key, SESSION_KEY_SIZE);
entry->nonce = 1;
entry->state = SESSION_STATE_DUAL_DECODE;
entry->sends_since_last_recv = 0;
memset(new_session_key, 0, SESSION_KEY_SIZE);
// 5. Persist immediately
saveSessionKeys();
// 6. Write ephemeral_pub_B to reply
memcpy(reply_buf, ephemeral_pub_B, PUB_KEY_SIZE);
return PUB_KEY_SIZE;
}
const uint8_t* ClientACL::getSessionKey(const uint8_t* pub_key) {
auto entry = session_keys.findByPrefix(pub_key);
if (entry && (entry->state == SESSION_STATE_ACTIVE || entry->state == SESSION_STATE_DUAL_DECODE)) {
return entry->session_key;
}
return nullptr;
}
const uint8_t* ClientACL::getPrevSessionKey(const uint8_t* pub_key) {
auto entry = session_keys.findByPrefix(pub_key);
if (entry && entry->state == SESSION_STATE_DUAL_DECODE) {
return entry->prev_session_key;
}
return nullptr;
}
const uint8_t* ClientACL::getEncryptionKey(const ClientInfo& client) {
auto entry = session_keys.findByPrefix(client.id.pub_key);
if (entry && (entry->state == SESSION_STATE_ACTIVE || entry->state == SESSION_STATE_DUAL_DECODE)
&& entry->sends_since_last_recv < SESSION_KEY_STALE_THRESHOLD
&& entry->nonce < 65535) {
return entry->session_key;
}
return client.shared_secret;
}
uint16_t ClientACL::getEncryptionNonce(const ClientInfo& client) {
auto entry = session_keys.findByPrefix(client.id.pub_key);
if (entry && (entry->state == SESSION_STATE_ACTIVE || entry->state == SESSION_STATE_DUAL_DECODE)
&& entry->sends_since_last_recv < SESSION_KEY_STALE_THRESHOLD
&& entry->nonce < 65535) {
++entry->nonce;
if (entry->sends_since_last_recv < 255) entry->sends_since_last_recv++;
_session_keys_dirty = true;
return entry->nonce;
}
// Progressive fallback: keep incrementing counter even when not using session key
if (entry && entry->sends_since_last_recv < 255) {
entry->sends_since_last_recv++;
if (entry->sends_since_last_recv >= SESSION_KEY_ABANDON_THRESHOLD) {
int idx = &client - clients;
if (idx >= 0 && idx < num_clients)
clients[idx].flags &= ~CONTACT_FLAG_AEAD;
session_keys.remove(client.id.pub_key);
saveSessionKeys();
return 0; // ECB
}
if (entry->sends_since_last_recv >= SESSION_KEY_ECB_THRESHOLD) {
return 0; // ECB
}
}
return nextAeadNonceFor(client);
}
void ClientACL::onSessionConfirmed(const uint8_t* pub_key) {
auto entry = session_keys.findByPrefix(pub_key);
if (entry) {
if (entry->state == SESSION_STATE_DUAL_DECODE) {
memset(entry->prev_session_key, 0, SESSION_KEY_SIZE);
entry->state = SESSION_STATE_ACTIVE;
saveSessionKeys();
}
entry->sends_since_last_recv = 0;
}
}
// --- Peer-index forwarding helpers ---
ClientInfo* ClientACL::resolveClient(int peer_idx, const int* matching_indexes) {
int i = matching_indexes[peer_idx];
if (i >= 0 && i < num_clients) return &clients[i];
return nullptr;
}
uint16_t ClientACL::peerNextAeadNonce(int peer_idx, const int* matching_indexes) {
auto* c = resolveClient(peer_idx, matching_indexes);
return c ? nextAeadNonceFor(*c) : 0;
}
const uint8_t* ClientACL::peerSessionKey(int peer_idx, const int* matching_indexes) {
auto* c = resolveClient(peer_idx, matching_indexes);
return c ? getSessionKey(c->id.pub_key) : nullptr;
}
const uint8_t* ClientACL::peerPrevSessionKey(int peer_idx, const int* matching_indexes) {
auto* c = resolveClient(peer_idx, matching_indexes);
return c ? getPrevSessionKey(c->id.pub_key) : nullptr;
}
void ClientACL::peerSessionKeyDecryptSuccess(int peer_idx, const int* matching_indexes) {
auto* c = resolveClient(peer_idx, matching_indexes);
if (c) onSessionConfirmed(c->id.pub_key);
}
const uint8_t* ClientACL::peerEncryptionKey(int peer_idx, const int* matching_indexes, const uint8_t* fallback) {
auto* c = resolveClient(peer_idx, matching_indexes);
return c ? getEncryptionKey(*c) : fallback;
}
uint16_t ClientACL::peerEncryptionNonce(int peer_idx, const int* matching_indexes) {
auto* c = resolveClient(peer_idx, matching_indexes);
return c ? getEncryptionNonce(*c) : 0;
}
void ClientACL::loadSessionKeys() {
if (!_fs) return;
#if defined(RP2040_PLATFORM)
File file = _fs->open("/s_sess_keys", "r");
#elif defined(NRF52_PLATFORM) || defined(STM32_PLATFORM)
File file = _fs->open("/s_sess_keys", FILE_O_READ);
#else
File file = _fs->open("/s_sess_keys", "r", false);
#endif
if (file) {
uint8_t rec[71]; // [pub_prefix:4][flags:1][nonce:2][session_key:32][prev_session_key:32]
while (file.read(rec, 71) == 71) {
uint8_t flags = rec[4];
uint16_t nonce;
memcpy(&nonce, &rec[5], 2);
session_keys.applyLoaded(rec, flags, nonce, &rec[7], &rec[39]);
}
file.close();
}
}
void ClientACL::saveSessionKeys() {
_session_keys_dirty = false;
if (!_fs) return;
File file = openWrite(_fs, "/s_sess_keys");
if (file) {
for (int i = 0; i < session_keys.getCount(); i++) {
uint8_t pub_key_prefix[4];
uint8_t flags;
uint16_t nonce;
uint8_t session_key[SESSION_KEY_SIZE];
uint8_t prev_session_key[SESSION_KEY_SIZE];
if (session_keys.getEntryForSave(i, pub_key_prefix, &flags, &nonce, session_key, prev_session_key)) {
file.write(pub_key_prefix, 4);
file.write(&flags, 1);
file.write((uint8_t*)&nonce, 2);
file.write(session_key, SESSION_KEY_SIZE);
file.write(prev_session_key, SESSION_KEY_SIZE);
}
}
file.close();
}
}

30
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
@ -54,14 +55,19 @@ class ClientACL {
// Nonce persistence state (parallel to clients[])
uint16_t nonce_at_last_persist[MAX_CLIENTS];
bool nonce_dirty;
bool _session_keys_dirty;
mesh::RNG* _rng;
// Session key pool (Phase 2)
SessionKeyPool session_keys;
public:
ClientACL() {
memset(clients, 0, sizeof(clients));
memset(nonce_at_last_persist, 0, sizeof(nonce_at_last_persist));
num_clients = 0;
nonce_dirty = false;
_session_keys_dirty = false;
_rng = NULL;
}
void load(FILESYSTEM* _fs, const mesh::LocalIdentity& self_id);
@ -74,6 +80,7 @@ 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; }
@ -86,4 +93,27 @@ public:
for (int i = 0; i < num_clients; i++) nonce_at_last_persist[i] = clients[i].aead_nonce;
nonce_dirty = false;
}
// Session key support (Phase 2)
int handleSessionKeyInit(const ClientInfo* client, const uint8_t* ephemeral_pub_A, uint8_t* reply_buf, mesh::RNG* rng);
const uint8_t* getSessionKey(const uint8_t* pub_key);
const uint8_t* getPrevSessionKey(const uint8_t* pub_key);
const uint8_t* getEncryptionKey(const ClientInfo& client);
uint16_t getEncryptionNonce(const ClientInfo& client);
void onSessionConfirmed(const uint8_t* pub_key);
bool isSessionKeysDirty() const { return _session_keys_dirty; }
void loadSessionKeys();
void saveSessionKeys();
// Peer-index forwarding helpers for server-side Mesh overrides.
// These resolve peer_idx → ClientInfo via matching_indexes[], then delegate
// to the corresponding method above. Eliminates repeated boilerplate in
// repeater/room/sensor examples.
ClientInfo* resolveClient(int peer_idx, const int* matching_indexes);
uint16_t peerNextAeadNonce(int peer_idx, const int* matching_indexes);
const uint8_t* peerSessionKey(int peer_idx, const int* matching_indexes);
const uint8_t* peerPrevSessionKey(int peer_idx, const int* matching_indexes);
void peerSessionKeyDecryptSuccess(int peer_idx, const int* matching_indexes);
const uint8_t* peerEncryptionKey(int peer_idx, const int* matching_indexes, const uint8_t* fallback);
uint16_t peerEncryptionNonce(int peer_idx, const int* matching_indexes);
};

2
src/helpers/CommonCLI.cpp

@ -484,6 +484,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/ContactInfo.h

@ -27,6 +27,10 @@ struct ContactInfo {
++aead_nonce; // skip 0 (sentinel for ECB)
MESH_DEBUG_PRINTLN("AEAD nonce wrapped for peer: %s", name);
}
if (aead_nonce < NONCE_INITIAL_MIN) {
aead_nonce = 1; // stay stuck in exhaustion zone, always return ECB
return 0;
}
return aead_nonce;
}
return 0;

117
src/helpers/SessionKeyPool.h

@ -0,0 +1,117 @@
#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
#define SESSION_FLAG_PREV_VALID 0x01 // prev_session_key is valid for dual-decode
struct SessionKeyEntry {
uint8_t peer_pub_prefix[4]; // first 4 bytes of peer's public key
uint8_t session_key[SESSION_KEY_SIZE];
uint8_t prev_session_key[SESSION_KEY_SIZE];
uint16_t nonce; // session key nonce counter (starts at 1)
uint8_t state; // SESSION_STATE_*
uint8_t sends_since_last_recv; // RAM-only counter, threshold SESSION_KEY_STALE_THRESHOLD
uint8_t retries_left; // remaining INIT retries this round
unsigned long timeout_at; // millis timestamp for INIT timeout
uint8_t ephemeral_prv[PRV_KEY_SIZE]; // initiator-only: ephemeral private key (zeroed after use)
uint8_t ephemeral_pub[PUB_KEY_SIZE]; // initiator-only: ephemeral public key
};
class SessionKeyPool {
SessionKeyEntry entries[MAX_SESSION_KEYS];
int count;
public:
SessionKeyPool() : count(0) {
memset(entries, 0, sizeof(entries));
}
SessionKeyEntry* findByPrefix(const uint8_t* pub_key) {
for (int i = 0; i < count; i++) {
if (memcmp(entries[i].peer_pub_prefix, pub_key, 4) == 0) {
return &entries[i];
}
}
return nullptr;
}
SessionKeyEntry* allocate(const uint8_t* pub_key) {
// Check if already exists
auto existing = findByPrefix(pub_key);
if (existing) return existing;
// Find free slot or evict oldest
if (count < MAX_SESSION_KEYS) {
auto e = &entries[count++];
memset(e, 0, sizeof(*e));
memcpy(e->peer_pub_prefix, pub_key, 4);
return e;
}
// Pool full — evict the entry with state NONE, or the first one
for (int i = 0; i < MAX_SESSION_KEYS; i++) {
if (entries[i].state == SESSION_STATE_NONE) {
memset(&entries[i], 0, sizeof(entries[i]));
memcpy(entries[i].peer_pub_prefix, pub_key, 4);
return &entries[i];
}
}
// All slots active — evict first entry
memset(&entries[0], 0, sizeof(entries[0]));
memcpy(entries[0].peer_pub_prefix, pub_key, 4);
return &entries[0];
}
void remove(const uint8_t* pub_key) {
for (int i = 0; i < count; i++) {
if (memcmp(entries[i].peer_pub_prefix, pub_key, 4) == 0) {
// Shift remaining entries down
count--;
for (int j = i; j < count; j++) {
entries[j] = entries[j + 1];
}
memset(&entries[count], 0, sizeof(entries[count]));
return;
}
}
}
int getCount() const { return count; }
SessionKeyEntry* getByIdx(int idx) { return (idx >= 0 && idx < count) ? &entries[idx] : nullptr; }
// Persistence helpers — 71-byte records: [pub_prefix:4][flags:1][nonce:2][session_key:32][prev_session_key:32]
// Returns false when idx is past end
bool getEntryForSave(int idx, uint8_t* pub_key_prefix, uint8_t* flags, uint16_t* nonce,
uint8_t* session_key, uint8_t* prev_session_key) {
if (idx >= count) return false;
auto& e = entries[idx];
if (e.state == SESSION_STATE_NONE || e.state == SESSION_STATE_INIT_SENT) return false; // don't persist pending negotiations
memcpy(pub_key_prefix, e.peer_pub_prefix, 4);
*flags = (e.state == SESSION_STATE_DUAL_DECODE) ? SESSION_FLAG_PREV_VALID : 0;
*nonce = e.nonce;
memcpy(session_key, e.session_key, SESSION_KEY_SIZE);
memcpy(prev_session_key, e.prev_session_key, SESSION_KEY_SIZE);
return true;
}
bool applyLoaded(const uint8_t* pub_key_prefix, uint8_t flags, uint16_t nonce,
const uint8_t* session_key, const uint8_t* prev_session_key) {
auto e = allocate(pub_key_prefix);
if (!e) return false;
e->nonce = nonce;
e->state = (flags & SESSION_FLAG_PREV_VALID) ? SESSION_STATE_DUAL_DECODE : SESSION_STATE_ACTIVE;
e->sends_since_last_recv = 0;
e->retries_left = 0;
e->timeout_at = 0;
memcpy(e->session_key, session_key, SESSION_KEY_SIZE);
memcpy(e->prev_session_key, prev_session_key, SESSION_KEY_SIZE);
memset(e->ephemeral_prv, 0, sizeof(e->ephemeral_prv));
memset(e->ephemeral_pub, 0, sizeof(e->ephemeral_pub));
return true;
}
};
Loading…
Cancel
Save