diff --git a/examples/companion_radio/DataStore.cpp b/examples/companion_radio/DataStore.cpp index 2ab862f4..35b80bfa 100644 --- a/examples/companion_radio/DataStore.cpp +++ b/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) diff --git a/examples/companion_radio/DataStore.h b/examples/companion_radio/DataStore.h index 6efb66b9..3584cb34 100644 --- a/examples/companion_radio/DataStore.h +++ b/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); diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index 45550750..a07779ff 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/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); } diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index 53ac1ee5..fae98d22 100644 --- a/examples/companion_radio/MyMesh.h +++ b/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; diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index 2e0ec31f..083ca69a 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/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(×tamp, 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, ×tamp, 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, ×tamp, 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); } diff --git a/examples/simple_repeater/MyMesh.h b/examples/simple_repeater/MyMesh.h index a83aad00..dbbfef56 100644 --- a/examples/simple_repeater/MyMesh.h +++ b/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; diff --git a/examples/simple_room_server/MyMesh.cpp b/examples/simple_room_server/MyMesh.cpp index f478061e..ae66907f 100644 --- a/examples/simple_room_server/MyMesh.cpp +++ b/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); } diff --git a/examples/simple_room_server/MyMesh.h b/examples/simple_room_server/MyMesh.h index 252804e6..75e1b56e 100644 --- a/examples/simple_room_server/MyMesh.h +++ b/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; diff --git a/examples/simple_sensor/SensorMesh.cpp b/examples/simple_sensor/SensorMesh.cpp index c439067c..f77292fb 100644 --- a/examples/simple_sensor/SensorMesh.cpp +++ b/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(×tamp, 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, ×tamp, 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, ×tamp, 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); } } diff --git a/examples/simple_sensor/SensorMesh.h b/examples/simple_sensor/SensorMesh.h index 524f2ff8..6874844b 100644 --- a/examples/simple_sensor/SensorMesh.h +++ b/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; diff --git a/src/Mesh.cpp b/src/Mesh.cpp index c8894038..a5e7432b 100644 --- a/src/Mesh.cpp +++ b/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; diff --git a/src/Mesh.h b/src/Mesh.h index 7d0cef28..7a495072 100644 --- a/src/Mesh.h +++ b/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 diff --git a/src/MeshCore.h b/src/MeshCore.h index 5279201c..aae7ca82 100644 --- a/src/MeshCore.h +++ b/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) diff --git a/src/helpers/BaseChatMesh.cpp b/src/helpers/BaseChatMesh.cpp index 342b5908..8f0bd79a 100644 --- a/src/helpers/BaseChatMesh.cpp +++ b/src/helpers/BaseChatMesh.cpp @@ -1,5 +1,7 @@ #include #include +#include +#include #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); } diff --git a/src/helpers/BaseChatMesh.h b/src/helpers/BaseChatMesh.h index 70bc09d7..18e5ed1d 100644 --- a/src/helpers/BaseChatMesh.h +++ b/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; diff --git a/src/helpers/ClientACL.cpp b/src/helpers/ClientACL.cpp index 56b1a79c..1abfa9fa 100644 --- a/src/helpers/ClientACL.cpp +++ b/src/helpers/ClientACL.cpp @@ -1,5 +1,7 @@ #include "ClientACL.h" #include +#include +#include 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(); + } +} diff --git a/src/helpers/ClientACL.h b/src/helpers/ClientACL.h index 31788d27..455c7cce 100644 --- a/src/helpers/ClientACL.h +++ b/src/helpers/ClientACL.h @@ -3,6 +3,7 @@ #include // needed for PlatformIO #include #include +#include #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); }; diff --git a/src/helpers/CommonCLI.cpp b/src/helpers/CommonCLI.cpp index e6add5ac..ecb2e3c9 100644 --- a/src/helpers/CommonCLI.cpp +++ b/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"); } diff --git a/src/helpers/ContactInfo.h b/src/helpers/ContactInfo.h index da2b8b1f..df60aff5 100644 --- a/src/helpers/ContactInfo.h +++ b/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; diff --git a/src/helpers/SessionKeyPool.h b/src/helpers/SessionKeyPool.h new file mode 100644 index 00000000..b2c168a6 --- /dev/null +++ b/src/helpers/SessionKeyPool.h @@ -0,0 +1,117 @@ +#pragma once + +#include +#include + +#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; + } +};