diff --git a/examples/companion_radio/DataStore.cpp b/examples/companion_radio/DataStore.cpp index c0f2c0212..9cf11fe10 100644 --- a/examples/companion_radio/DataStore.cpp +++ b/examples/companion_radio/DataStore.cpp @@ -373,6 +373,140 @@ void DataStore::saveChannels(DataStoreHost* host) { } } +void DataStore::loadNonces(DataStoreHost* host) { + File file = openRead(_getContactsChannelsFS(), "/nonces"); + if (file) { + uint8_t rec[6]; // 4-byte pub_key prefix + 2-byte nonce + while (file.read(rec, 6) == 6) { + uint16_t nonce; + memcpy(&nonce, &rec[4], 2); + host->onNonceLoaded(rec, nonce); + } + file.close(); + } +} + +bool DataStore::saveNonces(DataStoreHost* host) { + File file = openWrite(_getContactsChannelsFS(), "/nonces"); + if (file) { + int idx = 0; + uint8_t pub_key_prefix[4]; + uint16_t nonce; + while (host->getNonceForSave(idx, pub_key_prefix, &nonce)) { + file.write(pub_key_prefix, 4); + file.write((uint8_t*)&nonce, 2); + idx++; + } + file.close(); + return true; + } + return false; +} + +void DataStore::loadSessionKeys(DataStoreHost* host) { + File file = openRead(_getContactsChannelsFS(), "/sess_keys"); + if (!file) return; + while (true) { + uint8_t rec[SESSION_KEY_RECORD_MIN_SIZE]; + if (file.read(rec, SESSION_KEY_RECORD_MIN_SIZE) != SESSION_KEY_RECORD_MIN_SIZE) break; + uint8_t flags = rec[4]; + uint16_t nonce; + memcpy(&nonce, &rec[5], 2); + uint8_t prev_key[SESSION_KEY_SIZE]; + if (flags & SESSION_FLAG_PREV_VALID) { + if (file.read(prev_key, SESSION_KEY_SIZE) != SESSION_KEY_SIZE) break; + } else { + memset(prev_key, 0, SESSION_KEY_SIZE); + } + host->onSessionKeyLoaded(rec, flags, nonce, &rec[7], prev_key); + } + file.close(); +} + +bool DataStore::saveSessionKeys(DataStoreHost* host) { + FILESYSTEM* fs = _getContactsChannelsFS(); + + // 1. Read old flash file into buffer (variable-length records) + uint8_t old_buf[MAX_SESSION_KEYS_FLASH * SESSION_KEY_RECORD_SIZE]; + int old_len = 0; + File rf = openRead(fs, "/sess_keys"); + if (rf) { + while (true) { + if (old_len + SESSION_KEY_RECORD_MIN_SIZE > (int)sizeof(old_buf)) break; + if (rf.read(&old_buf[old_len], SESSION_KEY_RECORD_MIN_SIZE) != SESSION_KEY_RECORD_MIN_SIZE) break; + uint8_t flags = old_buf[old_len + 4]; + int rec_len = SESSION_KEY_RECORD_MIN_SIZE; + if (flags & SESSION_FLAG_PREV_VALID) { + if (old_len + SESSION_KEY_RECORD_SIZE > (int)sizeof(old_buf)) break; + if (rf.read(&old_buf[old_len + SESSION_KEY_RECORD_MIN_SIZE], SESSION_KEY_SIZE) != SESSION_KEY_SIZE) break; + rec_len = SESSION_KEY_RECORD_SIZE; + } + old_len += rec_len; + } + rf.close(); + } + + // 2. Write merged file + File wf = openWrite(fs, "/sess_keys"); + if (!wf) return false; + + // Write kept old records (variable-length) + int pos = 0; + while (pos + SESSION_KEY_RECORD_MIN_SIZE <= old_len) { + uint8_t* rec = &old_buf[pos]; + uint8_t flags = rec[4]; + int rec_len = (flags & SESSION_FLAG_PREV_VALID) ? SESSION_KEY_RECORD_SIZE : SESSION_KEY_RECORD_MIN_SIZE; + if (pos + rec_len > old_len) break; + if (!host->isSessionKeyInRAM(rec) && !host->isSessionKeyRemoved(rec)) { + wf.write(rec, rec_len); + } + pos += rec_len; + } + // Write current RAM entries (variable-length) + for (int idx = 0; idx < MAX_SESSION_KEYS_RAM; idx++) { + uint8_t pk[4]; uint8_t fl; uint16_t n; uint8_t sk[32]; uint8_t psk[32]; + if (!host->getSessionKeyForSave(idx, pk, &fl, &n, sk, psk)) continue; + wf.write(pk, 4); wf.write(&fl, 1); wf.write((uint8_t*)&n, 2); + wf.write(sk, 32); + if (fl & SESSION_FLAG_PREV_VALID) { + wf.write(psk, 32); + } + } + wf.close(); + return true; +} + +bool DataStore::loadSessionKeyByPrefix(const uint8_t* prefix, + uint8_t* flags, uint16_t* nonce, uint8_t* session_key, uint8_t* prev_session_key) { + File f = openRead(_getContactsChannelsFS(), "/sess_keys"); + if (!f) return false; + while (true) { + uint8_t rec[SESSION_KEY_RECORD_MIN_SIZE]; + if (f.read(rec, SESSION_KEY_RECORD_MIN_SIZE) != SESSION_KEY_RECORD_MIN_SIZE) break; + uint8_t rec_flags = rec[4]; + bool has_prev = (rec_flags & SESSION_FLAG_PREV_VALID); + if (memcmp(rec, prefix, 4) == 0) { + *flags = rec_flags; + memcpy(nonce, &rec[5], 2); + memcpy(session_key, &rec[7], SESSION_KEY_SIZE); + if (has_prev) { + if (f.read(prev_session_key, SESSION_KEY_SIZE) != SESSION_KEY_SIZE) break; + } else { + memset(prev_session_key, 0, SESSION_KEY_SIZE); + } + f.close(); + return true; + } + // Skip prev_key if present + if (has_prev) { + uint8_t skip[SESSION_KEY_SIZE]; + if (f.read(skip, SESSION_KEY_SIZE) != SESSION_KEY_SIZE) break; + } + } + f.close(); + return false; +} + #if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) #define MAX_ADVERT_PKT_LEN (2 + 32 + PUB_KEY_SIZE + 4 + SIGNATURE_SIZE + MAX_ADVERT_DATA_SIZE) diff --git a/examples/companion_radio/DataStore.h b/examples/companion_radio/DataStore.h index 58b4d5d28..011ce5a3d 100644 --- a/examples/companion_radio/DataStore.h +++ b/examples/companion_radio/DataStore.h @@ -11,6 +11,14 @@ class DataStoreHost { virtual bool getContactForSave(uint32_t idx, ContactInfo& contact) =0; virtual bool onChannelLoaded(uint8_t channel_idx, const ChannelDetails& ch) =0; virtual bool getChannelForSave(uint8_t channel_idx, ChannelDetails& ch) =0; + virtual bool onNonceLoaded(const uint8_t* pub_key_prefix, uint16_t nonce) { return false; } + virtual bool getNonceForSave(int idx, uint8_t* pub_key_prefix, uint16_t* nonce) { return false; } + virtual bool onSessionKeyLoaded(const uint8_t* pub_key_prefix, uint8_t flags, uint16_t nonce, + const uint8_t* session_key, const uint8_t* prev_session_key) { return false; } + virtual bool getSessionKeyForSave(int idx, uint8_t* pub_key_prefix, uint8_t* flags, uint16_t* nonce, + uint8_t* session_key, uint8_t* prev_session_key) { return false; } + virtual bool isSessionKeyInRAM(const uint8_t* pub_key_prefix) { return false; } + virtual bool isSessionKeyRemoved(const uint8_t* pub_key_prefix) { return false; } }; class DataStore { @@ -39,6 +47,12 @@ class DataStore { void saveContacts(DataStoreHost* host); void loadChannels(DataStoreHost* host); void saveChannels(DataStoreHost* host); + void loadNonces(DataStoreHost* host); + bool saveNonces(DataStoreHost* host); + void loadSessionKeys(DataStoreHost* host); + bool saveSessionKeys(DataStoreHost* host); + bool loadSessionKeyByPrefix(const uint8_t* prefix, + uint8_t* flags, uint16_t* nonce, uint8_t* session_key, uint8_t* prev_session_key); void migrateToSecondaryFS(); uint8_t getBlobByKey(const uint8_t key[], int key_len, uint8_t dest_buf[]); bool putBlobByKey(const uint8_t key[], int key_len, const uint8_t src_buf[], uint8_t len); diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index a9ac1cf0a..618150159 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -788,6 +788,7 @@ MyMesh::MyMesh(mesh::Radio &radio, mesh::RNG &rng, mesh::RTCClock &rtc, SimpleMe next_ack_idx = 0; sign_data = NULL; dirty_contacts_expiry = 0; + next_nonce_persist = 0; memset(advert_paths, 0, sizeof(advert_paths)); memset(send_scope.key, 0, sizeof(send_scope.key)); @@ -864,6 +865,16 @@ void MyMesh::begin(bool has_display) { resetContacts(); _store->loadContacts(this); bootstrapRTCfromContacts(); + + // Load persisted AEAD nonces and apply boot bump if needed + _store->loadNonces(this); + bool dirty_reset = wasDirtyReset(board); + finalizeNonceLoad(dirty_reset); + if (dirty_reset) saveNonces(); // persist bumped nonces immediately + next_nonce_persist = futureMillis(60000); + + _store->loadSessionKeys(this); + addChannel("Public", PUBLIC_GROUP_PSK); // pre-configure Andy's public channel _store->loadChannels(this); @@ -1275,6 +1286,8 @@ void MyMesh::handleCmdFrame(size_t len) { if (dirty_contacts_expiry) { // is there are pending dirty contacts write needed? saveContacts(); } + if (isNonceDirty()) saveNonces(); + saveSessionKeys(); board.reboot(); } else if (cmd_frame[0] == CMD_GET_BATT_AND_STORAGE) { uint8_t reply[11]; @@ -1915,7 +1928,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"); @@ -1952,6 +1980,14 @@ void MyMesh::checkSerialInterface() { } } +bool MyMesh::isSessionKeyInRAM(const uint8_t* pub_key_prefix) { + return isSessionKeyInRAMPool(pub_key_prefix); +} + +bool MyMesh::isSessionKeyRemoved(const uint8_t* pub_key_prefix) { + return isSessionKeyRemovedFromPool(pub_key_prefix); +} + void MyMesh::loop() { BaseChatMesh::loop(); @@ -1967,6 +2003,17 @@ void MyMesh::loop() { dirty_contacts_expiry = 0; } + // periodic AEAD nonce and session key persistence + if (next_nonce_persist && millisHasNowPassed(next_nonce_persist)) { + if (isNonceDirty()) { + saveNonces(); + } + if (isSessionKeysDirty()) { + saveSessionKeys(); + } + next_nonce_persist = futureMillis(60000); + } + #ifdef DISPLAY_CLASS if (_ui) _ui->setHasConnection(_serial->isConnected()); #endif diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index 95265a19a..8be9306a6 100644 --- a/examples/companion_radio/MyMesh.h +++ b/examples/companion_radio/MyMesh.h @@ -151,6 +151,18 @@ class MyMesh : public BaseChatMesh, public DataStoreHost { bool getContactForSave(uint32_t idx, ContactInfo& contact) override { return getContactByIdx(idx, contact); } bool onChannelLoaded(uint8_t channel_idx, const ChannelDetails& ch) override { return setChannel(channel_idx, ch); } bool getChannelForSave(uint8_t channel_idx, ChannelDetails& ch) override { return getChannel(channel_idx, ch); } + bool onNonceLoaded(const uint8_t* pub_key_prefix, uint16_t nonce) override { return applyLoadedNonce(pub_key_prefix, nonce); } + bool getNonceForSave(int idx, uint8_t* pub_key_prefix, uint16_t* nonce) override { return getNonceEntry(idx, pub_key_prefix, nonce); } + bool onSessionKeyLoaded(const uint8_t* pub_key_prefix, uint8_t flags, uint16_t nonce, + const uint8_t* session_key, const uint8_t* prev_session_key) override { + return applyLoadedSessionKey(pub_key_prefix, flags, nonce, session_key, prev_session_key); + } + bool getSessionKeyForSave(int idx, uint8_t* pub_key_prefix, uint8_t* flags, uint16_t* nonce, + uint8_t* session_key, uint8_t* prev_session_key) override { + return getSessionKeyEntry(idx, pub_key_prefix, flags, nonce, session_key, prev_session_key); + } + bool isSessionKeyInRAM(const uint8_t* pub_key_prefix) override; + bool isSessionKeyRemoved(const uint8_t* pub_key_prefix) override; void clearPendingReqs() { pending_login = pending_status = pending_telemetry = pending_discovery = pending_req = 0; @@ -180,6 +192,16 @@ class MyMesh : public BaseChatMesh, public DataStoreHost { // helpers, short-cuts void saveChannels() { _store->saveChannels(this); } void saveContacts() { _store->saveContacts(this); } + void saveNonces() { if (_store->saveNonces(this)) clearNonceDirty(); } + void saveSessionKeys() { if (_store->saveSessionKeys(this)) { clearSessionKeysDirty(); clearSessionKeysRemoved(); } } + void onSessionKeysUpdated() override { saveSessionKeys(); } + + // Flash-backed session key overrides + bool loadSessionKeyRecordFromFlash(const uint8_t* pub_key_prefix, + uint8_t* flags, uint16_t* nonce, uint8_t* session_key, uint8_t* prev_session_key) override { + return _store->loadSessionKeyByPrefix(pub_key_prefix, flags, nonce, session_key, prev_session_key); + } + void mergeAndSaveSessionKeys() override { saveSessionKeys(); } DataStore* _store; NodePrefs _prefs; @@ -201,6 +223,7 @@ class MyMesh : public BaseChatMesh, public DataStoreHost { uint8_t *sign_data; uint32_t sign_data_len; unsigned long dirty_contacts_expiry; + unsigned long next_nonce_persist; TransportKey send_scope; diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index 65e0cee52..d002884bf 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -565,6 +565,44 @@ void MyMesh::getPeerSharedSecret(uint8_t *dest_secret, int peer_idx) { } } +uint8_t MyMesh::getPeerFlags(int peer_idx) { + int i = matching_peer_indexes[peer_idx]; + if (i >= 0 && i < acl.getNumClients()) + return acl.getClientByIdx(i)->flags; + return 0; +} + +uint16_t MyMesh::getPeerNextAeadNonce(int peer_idx) { + return acl.peerNextAeadNonce(peer_idx, matching_peer_indexes); +} + +void MyMesh::onPeerAeadDetected(int peer_idx) { + auto* c = acl.resolveClient(peer_idx, matching_peer_indexes); + if (c && !(c->flags & CONTACT_FLAG_AEAD)) { + c->flags |= CONTACT_FLAG_AEAD; + if (c->aead_nonce == 0) { // no persisted nonce — seed from RNG to avoid deterministic start + getRNG()->random((uint8_t*)&c->aead_nonce, sizeof(c->aead_nonce)); + if (c->aead_nonce == 0) c->aead_nonce = 1; + } + } +} + +const uint8_t* MyMesh::getPeerSessionKey(int peer_idx) { + return acl.peerSessionKey(peer_idx, matching_peer_indexes); +} +const uint8_t* MyMesh::getPeerPrevSessionKey(int peer_idx) { + return acl.peerPrevSessionKey(peer_idx, matching_peer_indexes); +} +void MyMesh::onSessionKeyDecryptSuccess(int peer_idx) { + acl.peerSessionKeyDecryptSuccess(peer_idx, matching_peer_indexes); +} +const uint8_t* MyMesh::getPeerEncryptionKey(int peer_idx, const uint8_t* static_secret) { + return acl.peerEncryptionKey(peer_idx, matching_peer_indexes, static_secret); +} +uint16_t MyMesh::getPeerEncryptionNonce(int peer_idx) { + return acl.peerEncryptionNonce(peer_idx, matching_peer_indexes); +} + static bool isShare(const mesh::Packet *packet) { if (packet->hasTransportCodes()) { return packet->transport_codes[0] == 0 && packet->transport_codes[1] == 0; // codes { 0, 0 } means 'send to nowhere' @@ -599,20 +637,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); + mesh::Packet *path = createPathReturn(client->id, enc_key, packet->path, packet->path_len, + PAYLOAD_TYPE_RESPONSE, reply_data, reply_len, enc_nonce); if (path) sendFlood(path, SERVER_RESPONSE_DELAY); } else { mesh::Packet *reply = - createDatagram(PAYLOAD_TYPE_RESPONSE, client->id, secret, reply_data, reply_len); + createDatagram(PAYLOAD_TYPE_RESPONSE, client->id, enc_key, reply_data, reply_len, enc_nonce); if (reply) { if (client->out_path_len >= 0) { // we have an out_path, so send DIRECT sendDirect(reply, client->out_path, client->out_path_len, SERVER_RESPONSE_DELAY); @@ -673,7 +728,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); + 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 < 0) { sendFlood(reply, CLI_REPLY_DELAY_MILLIS); @@ -758,6 +813,7 @@ MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondCloc uptime_millis = 0; next_local_advert = next_flood_advert = 0; dirty_contacts_expiry = 0; + next_nonce_persist = 0; set_radio_at = revert_radio_at = 0; _logging = false; region_load_active = false; @@ -809,6 +865,13 @@ void MyMesh::begin(FILESYSTEM *fs) { // load persisted prefs _cli.loadPrefs(_fs); acl.load(_fs, self_id); + acl.setRNG(getRNG()); + acl.loadNonces(); + acl.loadSessionKeys(); + bool dirty_reset = wasDirtyReset(board); + acl.finalizeNonceLoad(dirty_reset); + if (dirty_reset) acl.saveNonces(); // persist bumped nonces immediately + next_nonce_persist = futureMillis(60000); // TODO: key_store.begin(); region_map.load(_fs); @@ -1211,6 +1274,13 @@ void MyMesh::loop() { dirty_contacts_expiry = 0; } + // persist dirty AEAD nonces + if (next_nonce_persist && millisHasNowPassed(next_nonce_persist)) { + if (acl.isNonceDirty()) { acl.saveNonces(); } + if (acl.isSessionKeysDirty()) { acl.saveSessionKeys(); } + next_nonce_persist = futureMillis(60000); + } + // update uptime uint32_t now = millis(); uptime_millis += now - last_millis; diff --git a/examples/simple_repeater/MyMesh.h b/examples/simple_repeater/MyMesh.h index 7a51b4a97..55a27598f 100644 --- a/examples/simple_repeater/MyMesh.h +++ b/examples/simple_repeater/MyMesh.h @@ -99,6 +99,7 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks { RateLimiter discover_limiter, anon_limiter; bool region_load_active; unsigned long dirty_contacts_expiry; + unsigned long next_nonce_persist; #if MAX_NEIGHBOURS NeighbourInfo neighbours[MAX_NEIGHBOURS]; #endif @@ -163,6 +164,14 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks { void onAnonDataRecv(mesh::Packet* packet, const uint8_t* secret, const mesh::Identity& sender, uint8_t* data, size_t len) override; int searchPeersByHash(const uint8_t* hash) override; void getPeerSharedSecret(uint8_t* dest_secret, int peer_idx) override; + uint8_t getPeerFlags(int peer_idx) override; + uint16_t getPeerNextAeadNonce(int peer_idx) override; + void onPeerAeadDetected(int peer_idx) override; + const uint8_t* getPeerSessionKey(int peer_idx) override; + const uint8_t* getPeerPrevSessionKey(int peer_idx) override; + void onSessionKeyDecryptSuccess(int peer_idx) override; + const uint8_t* getPeerEncryptionKey(int peer_idx, const uint8_t* static_secret) override; + uint16_t getPeerEncryptionNonce(int peer_idx) override; void onAdvertRecv(mesh::Packet* packet, const mesh::Identity& id, uint32_t timestamp, const uint8_t* app_data, size_t app_data_len); void onPeerDataRecv(mesh::Packet* packet, uint8_t type, int sender_idx, const uint8_t* secret, uint8_t* data, size_t len) override; bool onPeerPathRecv(mesh::Packet* packet, int sender_idx, const uint8_t* secret, uint8_t* path, uint8_t path_len, uint8_t extra_type, uint8_t* extra, uint8_t extra_len) override; @@ -184,6 +193,9 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks { void savePrefs() override { _cli.savePrefs(_fs); } + void onBeforeReboot() override { + if (acl.isNonceDirty()) acl.saveNonces(); + } void applyTempRadioParams(float freq, float bw, uint8_t sf, uint8_t cr, int timeout_mins) override; bool formatFileSystem() override; diff --git a/examples/simple_room_server/MyMesh.cpp b/examples/simple_room_server/MyMesh.cpp index 598b14de6..788b33305 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); + 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 < 0) { sendFlood(reply); @@ -387,6 +387,44 @@ void MyMesh::getPeerSharedSecret(uint8_t *dest_secret, int peer_idx) { } } +uint8_t MyMesh::getPeerFlags(int peer_idx) { + int i = matching_peer_indexes[peer_idx]; + if (i >= 0 && i < acl.getNumClients()) + return acl.getClientByIdx(i)->flags; + return 0; +} + +uint16_t MyMesh::getPeerNextAeadNonce(int peer_idx) { + return acl.peerNextAeadNonce(peer_idx, matching_peer_indexes); +} + +void MyMesh::onPeerAeadDetected(int peer_idx) { + auto* c = acl.resolveClient(peer_idx, matching_peer_indexes); + if (c && !(c->flags & CONTACT_FLAG_AEAD)) { + c->flags |= CONTACT_FLAG_AEAD; + if (c->aead_nonce == 0) { // no persisted nonce — seed from RNG to avoid deterministic start + getRNG()->random((uint8_t*)&c->aead_nonce, sizeof(c->aead_nonce)); + if (c->aead_nonce == 0) c->aead_nonce = 1; + } + } +} + +const uint8_t* MyMesh::getPeerSessionKey(int peer_idx) { + return acl.peerSessionKey(peer_idx, matching_peer_indexes); +} +const uint8_t* MyMesh::getPeerPrevSessionKey(int peer_idx) { + return acl.peerPrevSessionKey(peer_idx, matching_peer_indexes); +} +void MyMesh::onSessionKeyDecryptSuccess(int peer_idx) { + acl.peerSessionKeyDecryptSuccess(peer_idx, matching_peer_indexes); +} +const uint8_t* MyMesh::getPeerEncryptionKey(int peer_idx, const uint8_t* static_secret) { + return acl.peerEncryptionKey(peer_idx, matching_peer_indexes, static_secret); +} +uint16_t MyMesh::getPeerEncryptionNonce(int peer_idx) { + return acl.peerEncryptionNonce(peer_idx, matching_peer_indexes); +} + void MyMesh::onPeerDataRecv(mesh::Packet *packet, uint8_t type, int sender_idx, const uint8_t *secret, uint8_t *data, size_t len) { int i = matching_peer_indexes[sender_idx]; @@ -480,7 +518,7 @@ void MyMesh::onPeerDataRecv(mesh::Packet *packet, uint8_t type, int sender_idx, // mesh::Utils::sha256((uint8_t *)&expected_ack_crc, 4, temp, 5 + text_len, self_id.pub_key, // PUB_KEY_SIZE); - auto reply = createDatagram(PAYLOAD_TYPE_TXT_MSG, client->id, secret, temp, 5 + text_len); + auto reply = createDatagram(PAYLOAD_TYPE_TXT_MSG, client->id, acl.getEncryptionKey(*client), temp, 5 + text_len, acl.getEncryptionNonce(*client)); if (reply) { if (client->out_path_len < 0) { sendFlood(reply, delay_millis + SERVER_RESPONSE_DELAY); @@ -532,15 +570,30 @@ void MyMesh::onPeerDataRecv(mesh::Packet *packet, uint8_t type, int sender_idx, } } } else { - int reply_len = handleRequest(client, sender_timestamp, &data[4], len - 4); + int reply_len; + bool use_static_secret = false; + + // Intercept session key INIT before handleRequest + if (data[4] == REQ_TYPE_SESSION_KEY_INIT && len >= 37) { + memcpy(reply_data, &sender_timestamp, 4); + reply_data[4] = RESP_TYPE_SESSION_KEY_ACCEPT; + int n = acl.handleSessionKeyInit(client, &data[5], &reply_data[5], getRNG()); + reply_len = (n > 0) ? 5 + n : 0; + use_static_secret = true; // ACCEPT must use static secret (initiator doesn't have session key yet) + } else { + reply_len = handleRequest(client, sender_timestamp, &data[4], len - 4); + } if (reply_len > 0) { // valid command + const uint8_t* enc_key = use_static_secret ? secret : acl.getEncryptionKey(*client); + uint16_t enc_nonce = use_static_secret ? acl.nextAeadNonceFor(*client) : acl.getEncryptionNonce(*client); + if (packet->isRouteFlood()) { // let this sender know path TO here, so they can use sendDirect(), and ALSO encode the response - mesh::Packet *path = createPathReturn(client->id, secret, packet->path, packet->path_len, - PAYLOAD_TYPE_RESPONSE, reply_data, reply_len); + mesh::Packet *path = createPathReturn(client->id, enc_key, packet->path, packet->path_len, + PAYLOAD_TYPE_RESPONSE, reply_data, reply_len, enc_nonce); if (path) sendFlood(path, SERVER_RESPONSE_DELAY); } else { - mesh::Packet *reply = createDatagram(PAYLOAD_TYPE_RESPONSE, client->id, secret, reply_data, reply_len); + mesh::Packet *reply = createDatagram(PAYLOAD_TYPE_RESPONSE, client->id, enc_key, reply_data, reply_len, enc_nonce); if (reply) { if (client->out_path_len >= 0) { // we have an out_path, so send DIRECT sendDirect(reply, client->out_path, client->out_path_len, SERVER_RESPONSE_DELAY); @@ -592,6 +645,7 @@ MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondCloc uptime_millis = 0; next_local_advert = next_flood_advert = 0; dirty_contacts_expiry = 0; + next_nonce_persist = 0; _logging = false; set_radio_at = revert_radio_at = 0; @@ -638,6 +692,13 @@ void MyMesh::begin(FILESYSTEM *fs) { _cli.loadPrefs(_fs); acl.load(_fs, self_id); + acl.setRNG(getRNG()); + acl.loadNonces(); + acl.loadSessionKeys(); + bool dirty_reset = wasDirtyReset(board); + acl.finalizeNonceLoad(dirty_reset); + if (dirty_reset) acl.saveNonces(); // persist bumped nonces immediately + next_nonce_persist = futureMillis(60000); radio_set_params(_prefs.freq, _prefs.bw, _prefs.sf, _prefs.cr); radio_set_tx_power(_prefs.tx_power_dbm); @@ -885,6 +946,13 @@ void MyMesh::loop() { dirty_contacts_expiry = 0; } + // persist dirty AEAD nonces + if (next_nonce_persist && millisHasNowPassed(next_nonce_persist)) { + if (acl.isNonceDirty()) { acl.saveNonces(); } + if (acl.isSessionKeysDirty()) { acl.saveSessionKeys(); } + next_nonce_persist = futureMillis(60000); + } + // TODO: periodically check for OLD/inactive entries in known_clients[], and evict // update uptime diff --git a/examples/simple_room_server/MyMesh.h b/examples/simple_room_server/MyMesh.h index b4529e776..f9c206074 100644 --- a/examples/simple_room_server/MyMesh.h +++ b/examples/simple_room_server/MyMesh.h @@ -97,6 +97,7 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks { ClientACL acl; CommonCLI _cli; unsigned long dirty_contacts_expiry; + unsigned long next_nonce_persist; uint8_t reply_data[MAX_PACKET_PAYLOAD]; unsigned long next_push; uint16_t _num_posted, _num_post_pushes; @@ -148,6 +149,14 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks { void onAnonDataRecv(mesh::Packet* packet, const uint8_t* secret, const mesh::Identity& sender, uint8_t* data, size_t len) override; int searchPeersByHash(const uint8_t* hash) override ; void getPeerSharedSecret(uint8_t* dest_secret, int peer_idx) override; + uint8_t getPeerFlags(int peer_idx) override; + uint16_t getPeerNextAeadNonce(int peer_idx) override; + void onPeerAeadDetected(int peer_idx) override; + const uint8_t* getPeerSessionKey(int peer_idx) override; + const uint8_t* getPeerPrevSessionKey(int peer_idx) override; + void onSessionKeyDecryptSuccess(int peer_idx) override; + const uint8_t* getPeerEncryptionKey(int peer_idx, const uint8_t* static_secret) override; + uint16_t getPeerEncryptionNonce(int peer_idx) override; void onPeerDataRecv(mesh::Packet* packet, uint8_t type, int sender_idx, const uint8_t* secret, uint8_t* data, size_t len) override; bool onPeerPathRecv(mesh::Packet* packet, int sender_idx, const uint8_t* secret, uint8_t* path, uint8_t path_len, uint8_t extra_type, uint8_t* extra, uint8_t extra_len) override; void onAckRecv(mesh::Packet* packet, uint32_t ack_crc) override; @@ -174,6 +183,9 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks { void savePrefs() override { _cli.savePrefs(_fs); } + void onBeforeReboot() override { + if (acl.isNonceDirty()) acl.saveNonces(); + } void applyTempRadioParams(float freq, float bw, uint8_t sf, uint8_t cr, int timeout_mins) override; bool formatFileSystem() override; diff --git a/examples/simple_sensor/SensorMesh.cpp b/examples/simple_sensor/SensorMesh.cpp index f05fb245c..d8da66173 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); + 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 >= 0) { // we have an out_path, so send DIRECT sendDirect(pkt, c->out_path, c->out_path_len); @@ -496,6 +496,44 @@ void SensorMesh::getPeerSharedSecret(uint8_t* dest_secret, int peer_idx) { } } +uint8_t SensorMesh::getPeerFlags(int peer_idx) { + int i = matching_peer_indexes[peer_idx]; + if (i >= 0 && i < acl.getNumClients()) + return acl.getClientByIdx(i)->flags; + return 0; +} + +uint16_t SensorMesh::getPeerNextAeadNonce(int peer_idx) { + return acl.peerNextAeadNonce(peer_idx, matching_peer_indexes); +} + +void SensorMesh::onPeerAeadDetected(int peer_idx) { + auto* c = acl.resolveClient(peer_idx, matching_peer_indexes); + if (c && !(c->flags & CONTACT_FLAG_AEAD)) { + c->flags |= CONTACT_FLAG_AEAD; + if (c->aead_nonce == 0) { // no persisted nonce — seed from RNG to avoid deterministic start + getRNG()->random((uint8_t*)&c->aead_nonce, sizeof(c->aead_nonce)); + if (c->aead_nonce == 0) c->aead_nonce = 1; + } + } +} + +const uint8_t* SensorMesh::getPeerSessionKey(int peer_idx) { + return acl.peerSessionKey(peer_idx, matching_peer_indexes); +} +const uint8_t* SensorMesh::getPeerPrevSessionKey(int peer_idx) { + return acl.peerPrevSessionKey(peer_idx, matching_peer_indexes); +} +void SensorMesh::onSessionKeyDecryptSuccess(int peer_idx) { + acl.peerSessionKeyDecryptSuccess(peer_idx, matching_peer_indexes); +} +const uint8_t* SensorMesh::getPeerEncryptionKey(int peer_idx, const uint8_t* static_secret) { + return acl.peerEncryptionKey(peer_idx, matching_peer_indexes, static_secret); +} +uint16_t SensorMesh::getPeerEncryptionNonce(int peer_idx) { + return acl.peerEncryptionNonce(peer_idx, matching_peer_indexes); +} + void SensorMesh::sendAckTo(const ClientInfo& dest, uint32_t ack_hash) { if (dest.out_path_len < 0) { mesh::Packet* ack = createAck(ack_hash); @@ -527,19 +565,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); + 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); } else { - mesh::Packet* reply = createDatagram(PAYLOAD_TYPE_RESPONSE, from->id, secret, reply_data, reply_len); + mesh::Packet* reply = createDatagram(PAYLOAD_TYPE_RESPONSE, from->id, enc_key, reply_data, reply_len, enc_nonce); if (reply) { if (from->out_path_len >= 0) { // we have an out_path, so send DIRECT sendDirect(reply, from->out_path, from->out_path_len, SERVER_RESPONSE_DELAY); @@ -565,8 +618,8 @@ void SensorMesh::onPeerDataRecv(mesh::Packet* packet, uint8_t type, int sender_i if (packet->isRouteFlood()) { // let this sender know path TO here, so they can use sendDirect(), and ALSO encode the ACK - mesh::Packet* path = createPathReturn(from->id, secret, packet->path, packet->path_len, - PAYLOAD_TYPE_ACK, (uint8_t *) &ack_hash, 4); + mesh::Packet* path = createPathReturn(from->id, acl.getEncryptionKey(*from), packet->path, packet->path_len, + PAYLOAD_TYPE_ACK, (uint8_t *) &ack_hash, 4, acl.getEncryptionNonce(*from)); if (path) sendFlood(path, TXT_ACK_DELAY); } else { sendAckTo(*from, ack_hash); @@ -594,7 +647,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); + 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 < 0) { sendFlood(reply, CLI_REPLY_DELAY_MILLIS); @@ -699,6 +752,7 @@ SensorMesh::SensorMesh(mesh::MainBoard& board, mesh::Radio& radio, mesh::Millise { next_local_advert = next_flood_advert = 0; dirty_contacts_expiry = 0; + next_nonce_persist = 0; last_read_time = 0; num_alert_tasks = 0; set_radio_at = revert_radio_at = 0; @@ -737,6 +791,13 @@ void SensorMesh::begin(FILESYSTEM* fs) { _cli.loadPrefs(_fs); acl.load(_fs, self_id); + acl.setRNG(getRNG()); + acl.loadNonces(); + acl.loadSessionKeys(); + bool dirty_reset = wasDirtyReset(board); + acl.finalizeNonceLoad(dirty_reset); + if (dirty_reset) acl.saveNonces(); // persist bumped nonces immediately + next_nonce_persist = futureMillis(60000); radio_set_params(_prefs.freq, _prefs.bw, _prefs.sf, _prefs.cr); radio_set_tx_power(_prefs.tx_power_dbm); @@ -944,4 +1005,11 @@ void SensorMesh::loop() { acl.save(_fs); dirty_contacts_expiry = 0; } + + // persist dirty AEAD nonces + if (next_nonce_persist && millisHasNowPassed(next_nonce_persist)) { + if (acl.isNonceDirty()) { acl.saveNonces(); } + if (acl.isSessionKeysDirty()) { acl.saveSessionKeys(); } + next_nonce_persist = futureMillis(60000); + } } diff --git a/examples/simple_sensor/SensorMesh.h b/examples/simple_sensor/SensorMesh.h index 4bc0d784e..79757d582 100644 --- a/examples/simple_sensor/SensorMesh.h +++ b/examples/simple_sensor/SensorMesh.h @@ -59,6 +59,9 @@ class SensorMesh : public mesh::Mesh, public CommonCLICallbacks { const char* getNodeName() { return _prefs.node_name; } NodePrefs* getNodePrefs() { return &_prefs; } void savePrefs() override { _cli.savePrefs(_fs); } + void onBeforeReboot() override { + if (acl.isNonceDirty()) acl.saveNonces(); + } bool formatFileSystem() override; void sendSelfAdvertisement(int delay_millis, bool flood) override; void updateAdvertTimer() override; @@ -123,6 +126,14 @@ class SensorMesh : public mesh::Mesh, public CommonCLICallbacks { void onAnonDataRecv(mesh::Packet* packet, const uint8_t* secret, const mesh::Identity& sender, uint8_t* data, size_t len) override; int searchPeersByHash(const uint8_t* hash) override; void getPeerSharedSecret(uint8_t* dest_secret, int peer_idx) override; + uint8_t getPeerFlags(int peer_idx) override; + uint16_t getPeerNextAeadNonce(int peer_idx) override; + void onPeerAeadDetected(int peer_idx) override; + const uint8_t* getPeerSessionKey(int peer_idx) override; + const uint8_t* getPeerPrevSessionKey(int peer_idx) override; + void onSessionKeyDecryptSuccess(int peer_idx) override; + const uint8_t* getPeerEncryptionKey(int peer_idx, const uint8_t* static_secret) override; + uint16_t getPeerEncryptionNonce(int peer_idx) override; void onPeerDataRecv(mesh::Packet* packet, uint8_t type, int sender_idx, const uint8_t* secret, uint8_t* data, size_t len) override; bool onPeerPathRecv(mesh::Packet* packet, int sender_idx, const uint8_t* secret, uint8_t* path, uint8_t path_len, uint8_t extra_type, uint8_t* extra, uint8_t extra_len) override; void onControlDataRecv(mesh::Packet* packet) override; @@ -137,6 +148,7 @@ class SensorMesh : public mesh::Mesh, public CommonCLICallbacks { CommonCLI _cli; uint8_t reply_data[MAX_PACKET_PAYLOAD]; unsigned long dirty_contacts_expiry; + unsigned long next_nonce_persist; CayenneLPP telemetry; uint32_t last_read_time; int matching_peer_indexes[MAX_SEARCH_RESULTS]; diff --git a/src/Mesh.cpp b/src/Mesh.cpp index 0548c9073..0d0988a75 100644 --- a/src/Mesh.cpp +++ b/src/Mesh.cpp @@ -151,10 +151,58 @@ DispatcherAction Mesh::onRecvPacket(Packet* pkt) { uint8_t secret[PUB_KEY_SIZE]; getPeerSharedSecret(secret, j); - // decrypt, checking MAC is valid uint8_t data[MAX_PACKET_PAYLOAD]; - int len = Utils::MACThenDecrypt(secret, data, macAndData, pkt->payload_len - i); + int macAndDataLen = pkt->payload_len - i; + + // Mask out route type bits — they are set after encryption and vary per hop. + uint8_t assoc[3] = { (uint8_t)(pkt->header & ~PH_ROUTE_MASK), dest_hash, src_hash }; + int len = 0; + bool decoded_aead = false; + bool decoded_session = false; + + // Session key decode path: try session key(s) first if available + const uint8_t* sess_key = getPeerSessionKey(j); + if (sess_key) { + len = Utils::aeadDecrypt(sess_key, data, macAndData, macAndDataLen, assoc, 3, dest_hash, src_hash); + if (len > 0) { + decoded_session = true; + decoded_aead = true; + } else { + // Try prev_session_key (dual-decode window) + const uint8_t* prev_key = getPeerPrevSessionKey(j); + if (prev_key) { + len = Utils::aeadDecrypt(prev_key, data, macAndData, macAndDataLen, assoc, 3, dest_hash, src_hash); + if (len > 0) { + decoded_session = true; + decoded_aead = true; + } + } + } + if (!decoded_session) { + // Session key failed — try static ECDH, then ECB + len = Utils::aeadDecrypt(secret, data, macAndData, macAndDataLen, assoc, 3, dest_hash, src_hash); + if (len > 0) { + decoded_aead = true; + } else { + len = Utils::MACThenDecrypt(secret, data, macAndData, macAndDataLen); + } + } + } else if (getPeerFlags(j) & CONTACT_FLAG_AEAD) { + // No session key — standard AEAD-first decode for AEAD-capable peers + len = Utils::aeadDecrypt(secret, data, macAndData, macAndDataLen, assoc, 3, dest_hash, src_hash); + if (len > 0) decoded_aead = true; + else len = Utils::MACThenDecrypt(secret, data, macAndData, macAndDataLen); + } else { + // Legacy ECB-first decode + len = Utils::MACThenDecrypt(secret, data, macAndData, macAndDataLen); + if (len <= 0) { + len = Utils::aeadDecrypt(secret, data, macAndData, macAndDataLen, assoc, 3, dest_hash, src_hash); + if (len > 0) decoded_aead = true; + } + } if (len > 0) { // success! + if (decoded_session) onSessionKeyDecryptSuccess(j); + else if (decoded_aead) onPeerAeadDetected(j); if (pkt->getPayloadType() == PAYLOAD_TYPE_PATH) { int k = 0; uint8_t path_len = data[k++]; @@ -165,12 +213,12 @@ DispatcherAction Mesh::onRecvPacket(Packet* pkt) { if (onPeerPathRecv(pkt, j, secret, path, path_len, extra_type, extra, extra_len)) { if (pkt->isRouteFlood()) { // send a reciprocal return path to sender, but send DIRECTLY! - mesh::Packet* rpath = createPathReturn(&src_hash, secret, pkt->path, pkt->path_len, 0, NULL, 0); + mesh::Packet* rpath = createPathReturn(&src_hash, getPeerEncryptionKey(j, secret), pkt->path, pkt->path_len, 0, NULL, 0, getPeerEncryptionNonce(j)); if (rpath) sendDirect(rpath, path, path_len, 500); } } } else { - onPeerDataRecv(pkt, pkt->getPayloadType(), j, secret, data, len); + onPeerDataRecv(pkt, pkt->getPayloadType(), j, getPeerEncryptionKey(j, secret), data, len); } found = true; break; @@ -201,9 +249,16 @@ DispatcherAction Mesh::onRecvPacket(Packet* pkt) { uint8_t secret[PUB_KEY_SIZE]; self_id.calcSharedSecret(secret, sender); - // decrypt, checking MAC is valid uint8_t data[MAX_PACKET_PAYLOAD]; - int len = Utils::MACThenDecrypt(secret, data, macAndData, pkt->payload_len - i); + int macAndDataLen = pkt->payload_len - i; + + // Try ECB first (Phase 1), then AEAD-4 fallback. + // Phase 2 MUST swap to AEAD-first (see peer message comment above). + int len = Utils::MACThenDecrypt(secret, data, macAndData, macAndDataLen); + if (len <= 0) { + uint8_t assoc[2] = { (uint8_t)(pkt->header & ~PH_ROUTE_MASK), dest_hash }; + len = Utils::aeadDecrypt(secret, data, macAndData, macAndDataLen, assoc, 2, dest_hash, 0); + } if (len > 0) { // success! onAnonDataRecv(pkt, secret, sender, data, len); pkt->markDoNotRetransmit(); @@ -227,9 +282,19 @@ DispatcherAction Mesh::onRecvPacket(Packet* pkt) { int num = searchChannelsByHash(&channel_hash, channels, 4); // for each matching channel, try to decrypt data for (int j = 0; j < num; j++) { - // decrypt, checking MAC is valid uint8_t data[MAX_PACKET_PAYLOAD]; - int len = Utils::MACThenDecrypt(channels[j].secret, data, macAndData, pkt->payload_len - i); + int macAndDataLen = pkt->payload_len - i; + + // Try ECB first (Phase 1), then AEAD-4 fallback. + // Phase 2 MUST swap to AEAD-first (see peer message comment above). + // Note: group channels share a key, so nonce collisions across senders can leak + // P1 XOR P2 for colliding message pairs (no key recovery). Bounded risk, mainly + // worthwhile for public/hashtag channels where the PSK is already widely known. + int len = Utils::MACThenDecrypt(channels[j].secret, data, macAndData, macAndDataLen); + if (len <= 0) { + uint8_t assoc[2] = { (uint8_t)(pkt->header & ~PH_ROUTE_MASK), channel_hash }; + len = Utils::aeadDecrypt(channels[j].secret, data, macAndData, macAndDataLen, assoc, 2, channel_hash, 0); + } if (len > 0) { // success! onGroupDataRecv(pkt, pkt->getPayloadType(), channels[j], data, len); break; @@ -432,13 +497,13 @@ Packet* Mesh::createAdvert(const LocalIdentity& id, const uint8_t* app_data, siz #define MAX_COMBINED_PATH (MAX_PACKET_PAYLOAD - 2 - CIPHER_BLOCK_SIZE) -Packet* Mesh::createPathReturn(const Identity& dest, const uint8_t* secret, const uint8_t* path, uint8_t path_len, uint8_t extra_type, const uint8_t*extra, size_t extra_len) { +Packet* Mesh::createPathReturn(const Identity& dest, const uint8_t* secret, const uint8_t* path, uint8_t path_len, uint8_t extra_type, const uint8_t*extra, size_t extra_len, uint16_t aead_nonce) { uint8_t dest_hash[PATH_HASH_SIZE]; dest.copyHashTo(dest_hash); - return createPathReturn(dest_hash, secret, path, path_len, extra_type, extra, extra_len); + return createPathReturn(dest_hash, secret, path, path_len, extra_type, extra, extra_len, aead_nonce); } -Packet* Mesh::createPathReturn(const uint8_t* dest_hash, const uint8_t* secret, const uint8_t* path, uint8_t path_len, uint8_t extra_type, const uint8_t*extra, size_t extra_len) { +Packet* Mesh::createPathReturn(const uint8_t* dest_hash, const uint8_t* secret, const uint8_t* path, uint8_t path_len, uint8_t extra_type, const uint8_t*extra, size_t extra_len, uint16_t aead_nonce) { if (path_len + extra_len + 5 > MAX_COMBINED_PATH) return NULL; // too long!! Packet* packet = obtainNewPacket(); @@ -467,7 +532,14 @@ Packet* Mesh::createPathReturn(const uint8_t* dest_hash, const uint8_t* secret, getRNG()->random(&data[data_len], 4); data_len += 4; } - len += Utils::encryptThenMAC(secret, &packet->payload[len], data, data_len); + if (aead_nonce) { + uint8_t dh = packet->payload[0]; + uint8_t sh = packet->payload[1]; + uint8_t assoc[3] = { (uint8_t)(packet->header & ~PH_ROUTE_MASK), dh, sh }; + len += Utils::aeadEncrypt(secret, &packet->payload[len], data, data_len, assoc, 3, aead_nonce, dh, sh); + } else { + len += Utils::encryptThenMAC(secret, &packet->payload[len], data, data_len); + } } packet->payload_len = len; @@ -475,9 +547,12 @@ Packet* Mesh::createPathReturn(const uint8_t* dest_hash, const uint8_t* secret, return packet; } -Packet* Mesh::createDatagram(uint8_t type, const Identity& dest, const uint8_t* secret, const uint8_t* data, size_t data_len) { +Packet* Mesh::createDatagram(uint8_t type, const Identity& dest, const uint8_t* secret, const uint8_t* data, size_t data_len, uint16_t aead_nonce) { if (type == PAYLOAD_TYPE_TXT_MSG || type == PAYLOAD_TYPE_REQ || type == PAYLOAD_TYPE_RESPONSE) { - if (data_len + CIPHER_MAC_SIZE + CIPHER_BLOCK_SIZE-1 > MAX_PACKET_PAYLOAD) return NULL; + size_t hash_prefix = PATH_HASH_SIZE * 2; // dest_hash + src_hash + size_t max_overhead = aead_nonce ? (AEAD_NONCE_SIZE + AEAD_TAG_SIZE) : (CIPHER_MAC_SIZE + CIPHER_BLOCK_SIZE-1); + size_t max_payload_data = MAX_PACKET_PAYLOAD - hash_prefix - max_overhead; + if (data_len > max_payload_data) return NULL; } else { return NULL; // invalid type } @@ -492,7 +567,15 @@ Packet* Mesh::createDatagram(uint8_t type, const Identity& dest, const uint8_t* int len = 0; len += dest.copyHashTo(&packet->payload[len]); // dest hash len += self_id.copyHashTo(&packet->payload[len]); // src hash - len += Utils::encryptThenMAC(secret, &packet->payload[len], data, data_len); + + if (aead_nonce) { + uint8_t dest_hash = packet->payload[0]; + uint8_t src_hash = packet->payload[1]; + uint8_t assoc[3] = { (uint8_t)(packet->header & ~PH_ROUTE_MASK), dest_hash, src_hash }; + len += Utils::aeadEncrypt(secret, &packet->payload[len], data, data_len, assoc, 3, aead_nonce, dest_hash, src_hash); + } else { + len += Utils::encryptThenMAC(secret, &packet->payload[len], data, data_len); + } packet->payload_len = len; diff --git a/src/Mesh.h b/src/Mesh.h index 00f7ed00f..bac6b9d7a 100644 --- a/src/Mesh.h +++ b/src/Mesh.h @@ -82,6 +82,17 @@ class Mesh : public Dispatcher { * \param peer_idx index of peer, [0..n) where n is what searchPeersByHash() returned */ virtual void getPeerSharedSecret(uint8_t* dest_secret, int peer_idx) { } + virtual uint8_t getPeerFlags(int peer_idx) { return 0; } + virtual uint16_t getPeerNextAeadNonce(int peer_idx) { return 0; } + virtual void onPeerAeadDetected(int peer_idx) { } + + // Session key support (Phase 2) + virtual const uint8_t* getPeerSessionKey(int peer_idx) { return NULL; } + virtual const uint8_t* getPeerPrevSessionKey(int peer_idx) { return NULL; } + virtual void onSessionKeyDecryptSuccess(int peer_idx) { } + // Encryption key/nonce for outgoing messages to peer (session key with static ECDH fallback) + virtual const uint8_t* getPeerEncryptionKey(int peer_idx, const uint8_t* static_secret) { return static_secret; } + virtual uint16_t getPeerEncryptionNonce(int peer_idx) { return getPeerNextAeadNonce(peer_idx); } /** * \brief A (now decrypted) data packet has been received (by a known peer). @@ -182,13 +193,13 @@ class Mesh : public Dispatcher { RTCClock* getRTCClock() const { return _rtc; } Packet* createAdvert(const LocalIdentity& id, const uint8_t* app_data=NULL, size_t app_data_len=0); - Packet* createDatagram(uint8_t type, const Identity& dest, const uint8_t* secret, const uint8_t* data, size_t len); + Packet* createDatagram(uint8_t type, const Identity& dest, const uint8_t* secret, const uint8_t* data, size_t len, uint16_t aead_nonce=0); Packet* createAnonDatagram(uint8_t type, const LocalIdentity& sender, const Identity& dest, const uint8_t* secret, const uint8_t* data, size_t data_len); Packet* createGroupDatagram(uint8_t type, const GroupChannel& channel, const uint8_t* data, size_t data_len); Packet* createAck(uint32_t ack_crc); Packet* createMultiAck(uint32_t ack_crc, uint8_t remaining); - Packet* createPathReturn(const uint8_t* dest_hash, const uint8_t* secret, const uint8_t* path, uint8_t path_len, uint8_t extra_type, const uint8_t*extra, size_t extra_len); - Packet* createPathReturn(const Identity& dest, const uint8_t* secret, const uint8_t* path, uint8_t path_len, uint8_t extra_type, const uint8_t*extra, size_t extra_len); + Packet* createPathReturn(const uint8_t* dest_hash, const uint8_t* secret, const uint8_t* path, uint8_t path_len, uint8_t extra_type, const uint8_t*extra, size_t extra_len, uint16_t aead_nonce=0); + Packet* createPathReturn(const Identity& dest, const uint8_t* secret, const uint8_t* path, uint8_t path_len, uint8_t extra_type, const uint8_t*extra, size_t extra_len, uint16_t aead_nonce=0); Packet* createRawData(const uint8_t* data, size_t len); Packet* createTrace(uint32_t tag, uint32_t auth_code, uint8_t flags = 0); Packet* createControlData(const uint8_t* data, size_t len); diff --git a/src/MeshCore.h b/src/MeshCore.h index f194cdeb4..ad73a02ab 100644 --- a/src/MeshCore.h +++ b/src/MeshCore.h @@ -9,6 +9,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 @@ -16,6 +17,34 @@ #define CIPHER_MAC_SIZE 2 #define PATH_HASH_SIZE 1 +// AEAD-4 (ChaChaPoly) encryption +#define AEAD_TAG_SIZE 4 +#define AEAD_NONCE_SIZE 2 +#define CONTACT_FLAG_AEAD 0x02 // bit 1 of ContactInfo.flags (bit 0 = favourite) +#define FEAT1_AEAD_SUPPORT 0x0001 // bit 0 of feat1 uint16_t + +// AEAD nonce persistence +#define NONCE_PERSIST_INTERVAL 50 // persist every N messages per peer +#define NONCE_BOOT_BUMP 50 // add this on load after dirty boot (must be >= PERSIST_INTERVAL) + +// Session key negotiation (Phase 2) +#define REQ_TYPE_SESSION_KEY_INIT 0x08 +#define RESP_TYPE_SESSION_KEY_ACCEPT 0x08 // response type byte in PAYLOAD_TYPE_RESPONSE + +#define NONCE_REKEY_THRESHOLD 60000 // start renegotiation when nonce exceeds this +#define NONCE_INITIAL_MIN 1000 // min random nonce seed for new contacts +#define NONCE_INITIAL_MAX 50000 // max random nonce seed for new contacts +#define SESSION_KEY_TIMEOUT_MS 180000 // 3 minutes per attempt +#define SESSION_KEY_MAX_RETRIES 3 // attempts per negotiation round +#define MAX_SESSION_KEYS_RAM 8 // max concurrent session key entries in RAM (LRU cache) +#define MAX_SESSION_KEYS_FLASH 48 // max entries in flash file +#define SESSION_KEY_RECORD_SIZE 71 // max bytes per record (with prev_key) +#define SESSION_KEY_RECORD_MIN_SIZE 39 // min bytes per record: [pub_prefix:4][flags:1][nonce:2][key:32] +#define SESSION_FLAG_PREV_VALID 0x01 // prev_session_key is valid for dual-decode +#define SESSION_KEY_STALE_THRESHOLD 50 // sends without recv before fallback to static ECDH +#define SESSION_KEY_ECB_THRESHOLD 100 // sends without recv before fallback to ECB +#define SESSION_KEY_ABANDON_THRESHOLD 255 // sends without recv before clearing AEAD + session key + #define MAX_PACKET_PAYLOAD 184 #define MAX_PATH_SIZE 64 #define MAX_TRANS_UNIT 255 diff --git a/src/Utils.cpp b/src/Utils.cpp index 186c8720a..d7b1b324a 100644 --- a/src/Utils.cpp +++ b/src/Utils.cpp @@ -1,6 +1,7 @@ #include "Utils.h" #include #include +#include #ifdef ARDUINO #include @@ -84,7 +85,125 @@ int Utils::MACThenDecrypt(const uint8_t* shared_secret, uint8_t* dest, const uin if (memcmp(hmac, src, CIPHER_MAC_SIZE) == 0) { return decrypt(shared_secret, dest, src + CIPHER_MAC_SIZE, src_len - CIPHER_MAC_SIZE); } - return 0; // invalid HMAC + // No need to zero dest on failure — MAC is checked before decryption, + // so dest is never written to when authentication fails. + return 0; +} + +/* + * AEAD-4: ChaCha20-Poly1305 authenticated encryption with 4-byte tag. + * + * Wire format (replaces ECB's [HMAC:2][ciphertext:N*16]): + * [nonce:2] [ciphertext:M] [tag:4] (M = exact plaintext length) + * + * Key derivation (per-message, eliminates nonce-reuse catastrophe): + * msg_key[32] = HMAC-SHA256(shared_secret[32], nonce_hi || nonce_lo || dest_hash || src_hash) + * Including hashes makes keys direction-dependent: Alice->Bob and Bob->Alice derive + * different keys even with the same nonce (for 255/256 peer pairs; the 1/256 where + * dest_hash == src_hash remains a residual risk inherent to 1-byte hashes). + * + * IV construction (12 bytes, from on-wire fields): + * iv[12] = { nonce_hi, nonce_lo, dest_hash, src_hash, 0, 0, 0, 0, 0, 0, 0, 0 } + * + * Associated data (authenticated but not encrypted): + * Peer msgs: header || dest_hash || src_hash + * Anon reqs: header || dest_hash + * Group msgs: header || channel_hash + * + * Nonce: 16-bit counter per peer, seeded from HW RNG on boot. With per-message + * key derivation, even a nonce collision (across reboots) only leaks P1 XOR P2 + * for that message pair — no key recovery, no impact on other messages. + * + * Group channels: all members share the same key, so cross-sender nonce + * collisions are possible (~300 msgs for 50% chance with random nonces). + * Damage is bounded (message pair leak, no key recovery). + */ +int Utils::aeadEncrypt(const uint8_t* shared_secret, + uint8_t* dest, + const uint8_t* src, int src_len, + const uint8_t* assoc_data, int assoc_len, + uint16_t nonce_counter, + uint8_t dest_hash, uint8_t src_hash) { + if (src_len <= 0 || src_len > MAX_PACKET_PAYLOAD) return 0; + if (assoc_len < 0 || assoc_len > MAX_PACKET_PAYLOAD) return 0; + + // Write nonce to output + dest[0] = (uint8_t)(nonce_counter >> 8); + dest[1] = (uint8_t)(nonce_counter & 0xFF); + + // Derive per-message key: HMAC-SHA256(shared_secret, nonce || dest_hash || src_hash) + // Including hashes makes the key direction-dependent, preventing keystream reuse + // when Alice->Bob and Bob->Alice use the same nonce (255/256 peer pairs). + uint8_t msg_key[32]; + { + uint8_t kdf_input[AEAD_NONCE_SIZE + 2] = { dest[0], dest[1], dest_hash, src_hash }; + SHA256 sha; + sha.resetHMAC(shared_secret, PUB_KEY_SIZE); + sha.update(kdf_input, sizeof(kdf_input)); + sha.finalizeHMAC(shared_secret, PUB_KEY_SIZE, msg_key, 32); + } + + // Build 12-byte IV from on-wire fields + uint8_t iv[12]; + iv[0] = dest[0]; // nonce_hi + iv[1] = dest[1]; // nonce_lo + iv[2] = dest_hash; + iv[3] = src_hash; + memset(&iv[4], 0, 8); + + ChaChaPoly cipher; + cipher.setKey(msg_key, 32); + cipher.setIV(iv, 12); + cipher.addAuthData(assoc_data, assoc_len); + cipher.encrypt(dest + AEAD_NONCE_SIZE, src, src_len); + cipher.computeTag(dest + AEAD_NONCE_SIZE + src_len, AEAD_TAG_SIZE); + cipher.clear(); + memset(msg_key, 0, 32); + + return AEAD_NONCE_SIZE + src_len + AEAD_TAG_SIZE; +} + +int Utils::aeadDecrypt(const uint8_t* shared_secret, + uint8_t* dest, + const uint8_t* src, int src_len, + const uint8_t* assoc_data, int assoc_len, + uint8_t dest_hash, uint8_t src_hash) { + // Minimum: nonce(2) + at least 1 byte ciphertext + tag(4) + if (src_len < AEAD_NONCE_SIZE + 1 + AEAD_TAG_SIZE || src_len > MAX_PACKET_PAYLOAD) return 0; + if (assoc_len < 0 || assoc_len > MAX_PACKET_PAYLOAD) return 0; + + int ct_len = src_len - AEAD_NONCE_SIZE - AEAD_TAG_SIZE; + + // Derive per-message key: HMAC-SHA256(shared_secret, nonce || dest_hash || src_hash) + uint8_t msg_key[32]; + { + uint8_t kdf_input[AEAD_NONCE_SIZE + 2] = { src[0], src[1], dest_hash, src_hash }; + SHA256 sha; + sha.resetHMAC(shared_secret, PUB_KEY_SIZE); + sha.update(kdf_input, sizeof(kdf_input)); + sha.finalizeHMAC(shared_secret, PUB_KEY_SIZE, msg_key, 32); + } + + // Build 12-byte IV from on-wire fields + uint8_t iv[12]; + iv[0] = src[0]; // nonce_hi + iv[1] = src[1]; // nonce_lo + iv[2] = dest_hash; + iv[3] = src_hash; + memset(&iv[4], 0, 8); + + ChaChaPoly cipher; + cipher.setKey(msg_key, 32); + cipher.setIV(iv, 12); + cipher.addAuthData(assoc_data, assoc_len); + cipher.decrypt(dest, src + AEAD_NONCE_SIZE, ct_len); + + bool valid = cipher.checkTag(src + AEAD_NONCE_SIZE + ct_len, AEAD_TAG_SIZE); + cipher.clear(); + memset(msg_key, 0, 32); + if (!valid) memset(dest, 0, ct_len); + + return valid ? ct_len : 0; } static const char hex_chars[] = "0123456789ABCDEF"; diff --git a/src/Utils.h b/src/Utils.h index 5736b8747..dba1989e1 100644 --- a/src/Utils.h +++ b/src/Utils.h @@ -54,6 +54,29 @@ class Utils { */ static int MACThenDecrypt(const uint8_t* shared_secret, uint8_t* dest, const uint8_t* src, int src_len); + /** + * \brief Encrypt with ChaChaPoly AEAD. Derives per-message key via HMAC-SHA256(shared_secret, nonce || dest_hash || src_hash). + * Output: [nonce:2][ciphertext:src_len][tag:4] + * \returns total output length (AEAD_NONCE_SIZE + src_len + AEAD_TAG_SIZE), or 0 on failure + */ + static int aeadEncrypt(const uint8_t* shared_secret, + uint8_t* dest, + const uint8_t* src, int src_len, + const uint8_t* assoc_data, int assoc_len, + uint16_t nonce_counter, + uint8_t dest_hash, uint8_t src_hash); + + /** + * \brief Decrypt with ChaChaPoly AEAD. Derives per-message key via HMAC-SHA256(shared_secret, nonce || dest_hash || src_hash). + * Input: [nonce:2][ciphertext:M][tag:4] + * \returns plaintext length, or 0 if tag verification fails + */ + static int aeadDecrypt(const uint8_t* shared_secret, + uint8_t* dest, + const uint8_t* src, int src_len, + const uint8_t* assoc_data, int assoc_len, + uint8_t dest_hash, uint8_t src_hash); + /** * \brief converts 'src' bytes with given length to Hex representation, and null terminates. */ diff --git a/src/helpers/ArduinoHelpers.h b/src/helpers/ArduinoHelpers.h index 97596daa3..9af0277e8 100644 --- a/src/helpers/ArduinoHelpers.h +++ b/src/helpers/ArduinoHelpers.h @@ -1,6 +1,7 @@ #pragma once #include +#include #include class VolatileRTCClock : public mesh::RTCClock { @@ -33,3 +34,17 @@ class StdRNG : public mesh::RNG { } } }; + +// Returns true for dirty resets (power-on, watchdog, brownout, panic). +// Returns false for clean wakes (deep sleep, software restart). +inline bool wasDirtyReset(mesh::MainBoard& board) { +#if defined(ESP32) + esp_reset_reason_t rst = esp_reset_reason(); + return (rst != ESP_RST_DEEPSLEEP && rst != ESP_RST_SW); +#elif defined(NRF52_PLATFORM) + return !(board.getResetReason() & POWER_RESETREAS_SREQ_Msk); +#else + (void)board; + return true; +#endif +} diff --git a/src/helpers/BaseChatMesh.cpp b/src/helpers/BaseChatMesh.cpp index 6de7469d0..46c3c0cd5 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 @@ -9,6 +11,64 @@ #define TXT_ACK_DELAY 200 #endif +uint16_t BaseChatMesh::nextAeadNonceFor(const ContactInfo& contact) { + uint16_t nonce = contact.nextAeadNonce(); + if (nonce != 0) { + int idx = &contact - contacts; + if (idx >= 0 && idx < num_contacts && + (uint16_t)(contact.aead_nonce - nonce_at_last_persist[idx]) >= NONCE_PERSIST_INTERVAL) { + nonce_dirty = true; + } + } + return nonce; +} + +bool BaseChatMesh::applyLoadedNonce(const uint8_t* pub_key_prefix, uint16_t nonce) { + for (int i = 0; i < num_contacts; i++) { + if (memcmp(contacts[i].id.pub_key, pub_key_prefix, 4) == 0) { + contacts[i].aead_nonce = nonce; + return true; + } + } + return false; +} + +void BaseChatMesh::finalizeNonceLoad(bool needs_bump) { + for (int i = 0; i < num_contacts; i++) { + if (needs_bump) { + uint16_t old = contacts[i].aead_nonce; + contacts[i].aead_nonce += NONCE_BOOT_BUMP; + if (contacts[i].aead_nonce == 0) contacts[i].aead_nonce = 1; + if (contacts[i].aead_nonce < old) { + MESH_DEBUG_PRINTLN("AEAD nonce wrapped after boot bump for peer: %s", contacts[i].name); + } + } + nonce_at_last_persist[i] = contacts[i].aead_nonce; + } + nonce_dirty = false; + + // Apply boot bump to session key nonces too + if (needs_bump) { + for (int i = 0; i < session_keys.getCount(); i++) { + auto entry = session_keys.getByIdx(i); + if (entry && (entry->state == SESSION_STATE_ACTIVE || entry->state == SESSION_STATE_DUAL_DECODE)) { + uint16_t old_nonce = entry->nonce; + entry->nonce += NONCE_BOOT_BUMP; + if (entry->nonce <= old_nonce) { + entry->nonce = 65535; // wrapped — force exhaustion so renegotiation happens + } + } + } + } +} + +bool BaseChatMesh::getNonceEntry(int idx, uint8_t* pub_key_prefix, uint16_t* nonce) { + if (idx >= num_contacts) return false; + memcpy(pub_key_prefix, contacts[idx].id.pub_key, 4); + *nonce = contacts[idx].aead_nonce; + return true; +} + void BaseChatMesh::sendFloodScoped(const ContactInfo& recipient, mesh::Packet* pkt, uint32_t delay_millis) { sendFlood(pkt, delay_millis); } @@ -21,6 +81,7 @@ mesh::Packet* BaseChatMesh::createSelfAdvert(const char* name) { uint8_t app_data_len; { AdvertDataBuilder builder(ADV_TYPE_CHAT, name); + builder.setFeat1(FEAT1_AEAD_SUPPORT); app_data_len = builder.encodeTo(app_data); } @@ -32,6 +93,7 @@ mesh::Packet* BaseChatMesh::createSelfAdvert(const char* name, double lat, doubl uint8_t app_data_len; { AdvertDataBuilder builder(ADV_TYPE_CHAT, name, lat, lon); + builder.setFeat1(FEAT1_AEAD_SUPPORT); app_data_len = builder.encodeTo(app_data); } @@ -101,6 +163,10 @@ void BaseChatMesh::populateContactFromAdvert(ContactInfo& ci, const mesh::Identi } ci.last_advert_timestamp = timestamp; ci.lastmod = getRTCClock()->getCurrentTime(); + ci.aead_nonce = (uint16_t)getRNG()->nextInt(NONCE_INITIAL_MIN, NONCE_INITIAL_MAX + 1); + if (parser.getFeat1() & FEAT1_AEAD_SUPPORT) { + ci.flags |= CONTACT_FLAG_AEAD; + } } void BaseChatMesh::onAdvertRecv(mesh::Packet* packet, const mesh::Identity& id, uint32_t timestamp, const uint8_t* app_data, size_t app_data_len) { @@ -151,7 +217,8 @@ void BaseChatMesh::onAdvertRecv(mesh::Packet* packet, const mesh::Identity& id, return; } - populateContactFromAdvert(*from, id, parser, timestamp); + populateContactFromAdvert(*from, id, parser, timestamp); // seeds aead_nonce from RNG + nonce_at_last_persist[from - contacts] = from->aead_nonce; from->sync_since = 0; from->shared_secret_valid = false; } @@ -165,6 +232,11 @@ void BaseChatMesh::onAdvertRecv(mesh::Packet* packet, const mesh::Identity& id, } from->last_advert_timestamp = timestamp; from->lastmod = getRTCClock()->getCurrentTime(); + if (parser.getFeat1() & FEAT1_AEAD_SUPPORT) { + from->flags |= CONTACT_FLAG_AEAD; + } else { + from->flags &= ~CONTACT_FLAG_AEAD; + } onDiscoveredContact(*from, is_new, packet->path_len, packet->path); // let UI know } @@ -188,6 +260,22 @@ void BaseChatMesh::getPeerSharedSecret(uint8_t* dest_secret, int peer_idx) { } } +uint8_t BaseChatMesh::getPeerFlags(int peer_idx) { + int i = matching_peer_indexes[peer_idx]; + if (i >= 0 && i < num_contacts) { + return contacts[i].flags; + } + return 0; +} + +uint16_t BaseChatMesh::getPeerNextAeadNonce(int peer_idx) { + int i = matching_peer_indexes[peer_idx]; + if (i >= 0 && i < num_contacts) { + return nextAeadNonceFor(contacts[i]); + } + return 0; +} + void BaseChatMesh::onPeerDataRecv(mesh::Packet* packet, uint8_t type, int sender_idx, const uint8_t* secret, uint8_t* data, size_t len) { int i = matching_peer_indexes[sender_idx]; if (i < 0 || i >= num_contacts) { @@ -214,8 +302,8 @@ void BaseChatMesh::onPeerDataRecv(mesh::Packet* packet, uint8_t type, int sender if (packet->isRouteFlood()) { // let this sender know path TO here, so they can use sendDirect(), and ALSO encode the ACK - mesh::Packet* path = createPathReturn(from.id, secret, packet->path, packet->path_len, - PAYLOAD_TYPE_ACK, (uint8_t *) &ack_hash, 4); + mesh::Packet* path = createPathReturn(from.id, getEncryptionKeyFor(from), packet->path, packet->path_len, + PAYLOAD_TYPE_ACK, (uint8_t *) &ack_hash, 4, getEncryptionNonceFor(from)); if (path) sendFloodScoped(from, path, TXT_ACK_DELAY); } else { sendAckTo(from, ack_hash); @@ -226,7 +314,7 @@ void BaseChatMesh::onPeerDataRecv(mesh::Packet* packet, uint8_t type, int sender if (packet->isRouteFlood()) { // let this sender know path TO here, so they can use sendDirect() (NOTE: no ACK as extra) - mesh::Packet* path = createPathReturn(from.id, secret, packet->path, packet->path_len, 0, NULL, 0); + mesh::Packet* path = createPathReturn(from.id, getEncryptionKeyFor(from), packet->path, packet->path_len, 0, NULL, 0, getEncryptionNonceFor(from)); if (path) sendFloodScoped(from, path); } } else if (flags == TXT_TYPE_SIGNED_PLAIN) { @@ -241,8 +329,8 @@ void BaseChatMesh::onPeerDataRecv(mesh::Packet* packet, uint8_t type, int sender if (packet->isRouteFlood()) { // let this sender know path TO here, so they can use sendDirect(), and ALSO encode the ACK - mesh::Packet* path = createPathReturn(from.id, secret, packet->path, packet->path_len, - PAYLOAD_TYPE_ACK, (uint8_t *) &ack_hash, 4); + mesh::Packet* path = createPathReturn(from.id, getEncryptionKeyFor(from), packet->path, packet->path_len, + PAYLOAD_TYPE_ACK, (uint8_t *) &ack_hash, 4, getEncryptionNonceFor(from)); if (path) sendFloodScoped(from, path, TXT_ACK_DELAY); } else { sendAckTo(from, ack_hash); @@ -253,15 +341,37 @@ void BaseChatMesh::onPeerDataRecv(mesh::Packet* packet, uint8_t type, int sender } else if (type == PAYLOAD_TYPE_REQ && len > 4) { uint32_t sender_timestamp; memcpy(&sender_timestamp, data, 4); - uint8_t reply_len = onContactRequest(from, sender_timestamp, &data[4], len - 4, temp_buf); + + uint8_t reply_len = 0; + bool use_static_secret = false; + + // Intercept session key INIT before subclass onContactRequest + if (len >= 5 + PUB_KEY_SIZE && data[4] == REQ_TYPE_SESSION_KEY_INIT) { + memcpy(temp_buf, &sender_timestamp, 4); + temp_buf[4] = RESP_TYPE_SESSION_KEY_ACCEPT; + uint8_t n = handleIncomingSessionKeyInit(from, &data[5], &temp_buf[5]); + if (n > 0) { + reply_len = 5 + n; + use_static_secret = true; // ACCEPT must use static secret (initiator doesn't have session key yet) + } + } + if (reply_len == 0) { + reply_len = onContactRequest(from, sender_timestamp, &data[4], len - 4, temp_buf); + } + if (reply_len > 0) { + // Session key ACCEPT must be encrypted with static ECDH secret, because + // the initiator hasn't derived the session key yet (they need our ephemeral_pub_B first). + const uint8_t* enc_key = use_static_secret ? from.getSharedSecret(self_id) : getEncryptionKeyFor(from); + uint16_t enc_nonce = use_static_secret ? nextAeadNonceFor(from) : getEncryptionNonceFor(from); + if (packet->isRouteFlood()) { // let this sender know path TO here, so they can use sendDirect(), and ALSO encode the response - mesh::Packet* path = createPathReturn(from.id, secret, packet->path, packet->path_len, - PAYLOAD_TYPE_RESPONSE, temp_buf, reply_len); + mesh::Packet* path = createPathReturn(from.id, enc_key, packet->path, packet->path_len, + PAYLOAD_TYPE_RESPONSE, temp_buf, reply_len, enc_nonce); if (path) sendFloodScoped(from, path, SERVER_RESPONSE_DELAY); } else { - mesh::Packet* reply = createDatagram(PAYLOAD_TYPE_RESPONSE, from.id, secret, temp_buf, reply_len); + mesh::Packet* reply = createDatagram(PAYLOAD_TYPE_RESPONSE, from.id, enc_key, temp_buf, reply_len, enc_nonce); if (reply) { if (from.out_path_len >= 0) { // we have an out_path, so send DIRECT sendDirect(reply, from.out_path, from.out_path_len, SERVER_RESPONSE_DELAY); @@ -272,7 +382,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 >= 0) { // 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); @@ -306,7 +425,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 } @@ -327,7 +450,7 @@ void BaseChatMesh::onAckRecv(mesh::Packet* packet, uint32_t ack_crc) { void BaseChatMesh::handleReturnPathRetry(const ContactInfo& contact, const uint8_t* path, uint8_t path_len) { // NOTE: simplest impl is just to re-send a reciprocal return path to sender (DIRECTLY) // override this method in various firmwares, if there's a better strategy - mesh::Packet* rpath = createPathReturn(contact.id, contact.getSharedSecret(self_id), path, path_len, 0, NULL, 0); + mesh::Packet* rpath = createPathReturn(contact.id, getEncryptionKeyFor(contact), path, path_len, 0, NULL, 0, getEncryptionNonceFor(contact)); if (rpath) sendDirect(rpath, contact.out_path, contact.out_path_len, 3000); // 3 second delay } @@ -376,7 +499,7 @@ mesh::Packet* BaseChatMesh::composeMsgPacket(const ContactInfo& recipient, uint3 temp[len++] = attempt; // hide attempt number at tail end of payload } - return createDatagram(PAYLOAD_TYPE_TXT_MSG, recipient.id, recipient.getSharedSecret(self_id), temp, len); + return createDatagram(PAYLOAD_TYPE_TXT_MSG, recipient.id, getEncryptionKeyFor(recipient), temp, len, getEncryptionNonceFor(recipient)); } int BaseChatMesh::sendMessage(const ContactInfo& recipient, uint32_t timestamp, uint8_t attempt, const char* text, uint32_t& expected_ack, uint32_t& est_timeout) { @@ -407,7 +530,7 @@ int BaseChatMesh::sendCommandData(const ContactInfo& recipient, uint32_t timest temp[4] = (attempt & 3) | (TXT_TYPE_CLI_DATA << 2); memcpy(&temp[5], text, text_len + 1); - auto pkt = createDatagram(PAYLOAD_TYPE_TXT_MSG, recipient.id, recipient.getSharedSecret(self_id), temp, 5 + text_len); + auto pkt = createDatagram(PAYLOAD_TYPE_TXT_MSG, recipient.id, getEncryptionKeyFor(recipient), temp, 5 + text_len, getEncryptionNonceFor(recipient)); if (pkt == NULL) return MSG_SEND_FAILED; uint32_t t = _radio->getEstAirtimeFor(pkt->getRawLength()); @@ -548,7 +671,7 @@ int BaseChatMesh::sendRequest(const ContactInfo& recipient, const uint8_t* req_ memcpy(temp, &tag, 4); // mostly an extra blob to help make packet_hash unique memcpy(&temp[4], req_data, data_len); - pkt = createDatagram(PAYLOAD_TYPE_REQ, recipient.id, recipient.getSharedSecret(self_id), temp, 4 + data_len); + pkt = createDatagram(PAYLOAD_TYPE_REQ, recipient.id, getEncryptionKeyFor(recipient), temp, 4 + data_len, getEncryptionNonceFor(recipient)); } if (pkt) { uint32_t t = _radio->getEstAirtimeFor(pkt->getRawLength()); @@ -575,7 +698,7 @@ int BaseChatMesh::sendRequest(const ContactInfo& recipient, uint8_t req_type, u memset(&temp[5], 0, 4); // reserved (possibly for 'since' param) getRNG()->random(&temp[9], 4); // random blob to help make packet-hash unique - pkt = createDatagram(PAYLOAD_TYPE_REQ, recipient.id, recipient.getSharedSecret(self_id), temp, sizeof(temp)); + pkt = createDatagram(PAYLOAD_TYPE_REQ, recipient.id, getEncryptionKeyFor(recipient), temp, sizeof(temp), getEncryptionNonceFor(recipient)); } if (pkt) { uint32_t t = _radio->getEstAirtimeFor(pkt->getRawLength()); @@ -698,7 +821,7 @@ void BaseChatMesh::checkConnections() { // calc expected ACK reply mesh::Utils::sha256((uint8_t *)&connections[i].expected_ack, 4, data, 9, self_id.pub_key, PUB_KEY_SIZE); - auto pkt = createDatagram(PAYLOAD_TYPE_REQ, contact->id, contact->getSharedSecret(self_id), data, 9); + auto pkt = createDatagram(PAYLOAD_TYPE_REQ, contact->id, getEncryptionKeyFor(*contact), data, 9, getEncryptionNonceFor(*contact)); if (pkt) { sendDirect(pkt, contact->out_path, contact->out_path_len); } @@ -760,8 +883,11 @@ ContactInfo* BaseChatMesh::lookupContactByPubKey(const uint8_t* pub_key, int pre bool BaseChatMesh::addContact(const ContactInfo& contact) { ContactInfo* dest = allocateContactSlot(); if (dest) { + int idx = dest - contacts; *dest = contact; dest->shared_secret_valid = false; // mark shared_secret as needing calculation + dest->aead_nonce = (uint16_t)getRNG()->nextInt(NONCE_INITIAL_MIN, NONCE_INITIAL_MAX + 1); + nonce_at_last_persist[idx] = dest->aead_nonce; return true; // success } return false; @@ -774,12 +900,16 @@ bool BaseChatMesh::removeContact(ContactInfo& contact) { } if (idx >= num_contacts) return false; // not found - // remove from contacts array + removeSessionKey(contact.id.pub_key); // also remove session key if any + + // remove from contacts array and parallel nonce tracking num_contacts--; while (idx < num_contacts) { contacts[idx] = contacts[idx + 1]; + nonce_at_last_persist[idx] = nonce_at_last_persist[idx + 1]; idx++; } + memset(&contacts[num_contacts], 0, sizeof(ContactInfo)); return true; // Success } @@ -875,4 +1005,375 @@ void BaseChatMesh::loop() { releasePacket(_pendingLoopback); // undo the obtainNewPacket() _pendingLoopback = NULL; } + + checkSessionKeyTimeouts(); + + // Process deferred session key negotiation (set by getEncryptionNonceFor) + if (_pending_rekey_idx >= 0 && _pending_rekey_idx < num_contacts) { + int idx = _pending_rekey_idx; + _pending_rekey_idx = -1; + initiateSessionKeyNegotiation(contacts[idx]); + } +} + +// --- Session key flash-backed wrappers --- + +SessionKeyEntry* BaseChatMesh::findSessionKey(const uint8_t* pub_key) { + auto entry = session_keys.findByPrefix(pub_key); + if (entry) return entry; + + // Cache miss — try flash + uint8_t flags; uint16_t nonce; + uint8_t sk[SESSION_KEY_SIZE], psk[SESSION_KEY_SIZE]; + if (!loadSessionKeyRecordFromFlash(pub_key, &flags, &nonce, sk, psk)) return nullptr; + + // Save dirty evictee before overwriting + if (session_keys.isFull() && session_keys_dirty) { + mergeAndSaveSessionKeys(); + } + session_keys.applyLoaded(pub_key, flags, nonce, sk, psk); + return session_keys.findByPrefix(pub_key); +} + +SessionKeyEntry* BaseChatMesh::allocateSessionKey(const uint8_t* pub_key) { + // Check RAM and flash first + auto entry = findSessionKey(pub_key); + if (entry) return entry; + + // Not found anywhere — save dirty evictee before allocating + if (session_keys.isFull() && session_keys_dirty) { + mergeAndSaveSessionKeys(); + } + return session_keys.allocate(pub_key); +} + +void BaseChatMesh::removeSessionKey(const uint8_t* pub_key) { + session_keys.remove(pub_key); + session_keys_dirty = true; +} + +// --- Session key support (Phase 2 — initiator) --- + +static bool canUseSessionKey(const SessionKeyEntry* entry) { + if (!entry) return false; + // ACTIVE/DUAL_DECODE: normal session key use + // INIT_SENT with nonce > 1: renegotiation in progress, keep using old session key + // (nonce == 0 means fresh allocation with no prior session key) + bool valid_state = (entry->state == SESSION_STATE_ACTIVE || entry->state == SESSION_STATE_DUAL_DECODE) + || (entry->state == SESSION_STATE_INIT_SENT && entry->nonce > 1); + return valid_state + && entry->sends_since_last_recv < SESSION_KEY_STALE_THRESHOLD + && entry->nonce < 65535; // nonce exhausted → fall back to static ECDH +} + +const uint8_t* BaseChatMesh::getEncryptionKeyFor(const ContactInfo& contact) { + auto entry = findSessionKey(contact.id.pub_key); + if (canUseSessionKey(entry)) { + return entry->session_key; + } + return contact.getSharedSecret(self_id); +} + +uint16_t BaseChatMesh::getEncryptionNonceFor(const ContactInfo& contact) { + uint16_t nonce = 0; + auto entry = findSessionKey(contact.id.pub_key); + if (canUseSessionKey(entry)) { + ++entry->nonce; + if (entry->sends_since_last_recv < 255) entry->sends_since_last_recv++; + session_keys_dirty = true; + nonce = entry->nonce; + } else if (entry && entry->sends_since_last_recv < 255) { + // Progressive fallback: keep incrementing counter even when not using session key + entry->sends_since_last_recv++; + if (entry->sends_since_last_recv >= SESSION_KEY_ABANDON_THRESHOLD) { + // Give up: clear AEAD capability and remove session key + int idx = &contact - contacts; + if (idx >= 0 && idx < num_contacts) + contacts[idx].flags &= ~CONTACT_FLAG_AEAD; + removeSessionKey(contact.id.pub_key); + onSessionKeysUpdated(); + // nonce = 0 (ECB) + } else if (entry->sends_since_last_recv >= SESSION_KEY_ECB_THRESHOLD) { + // nonce = 0 (ECB) + } else { + nonce = nextAeadNonceFor(contact); + } + } else { + nonce = nextAeadNonceFor(contact); + } + + // Trigger session key negotiation on the next loop() tick. + // Checking here (the single funnel for all outgoing encryption) ensures no + // send path can silently skip a trigger — unlike the old per-call-site approach. + if (_pending_rekey_idx < 0 && shouldInitiateSessionKey(contact)) { + _pending_rekey_idx = &contact - contacts; + } + + return nonce; +} + +bool BaseChatMesh::shouldInitiateSessionKey(const ContactInfo& contact) { + // Only for AEAD-capable peers + if (!(contact.flags & CONTACT_FLAG_AEAD)) return false; + + // Need a known path to send the request + if (contact.out_path_len < 0) return false; + + auto entry = findSessionKey(contact.id.pub_key); + + // Don't trigger if negotiation already in progress + if (entry && entry->state == SESSION_STATE_INIT_SENT) return false; + + // Determine intervals based on hop count tier: + // direct (0): static=100, session=100 + // 1–9 hops: static=500, session=300 + // 10+ hops: static=1000, session=300 + uint16_t static_interval, session_interval; + if (contact.out_path_len == 0) { + static_interval = 100; + session_interval = 100; + } else if (contact.out_path_len < 10) { + static_interval = 500; + session_interval = 300; + } else { + static_interval = 1000; + session_interval = 300; + } + + if (entry && (entry->state == SESSION_STATE_ACTIVE || entry->state == SESSION_STATE_DUAL_DECODE)) { + if (entry->nonce < 65535) { + // Active session key with remaining nonces — renegotiate after nonce > 60000 + if (entry->nonce <= NONCE_REKEY_THRESHOLD) return false; + return ((entry->nonce - NONCE_REKEY_THRESHOLD) % session_interval) == 0; + } + // Session key nonce exhausted — fall through to static ECDH trigger + } + + // No session key (or state=NONE) — trigger based on static ECDH nonce vs interval + if (contact.aead_nonce == 0) return false; // no messages sent yet + return (contact.aead_nonce % static_interval) == 0; +} + +bool BaseChatMesh::initiateSessionKeyNegotiation(const ContactInfo& contact) { + auto entry = allocateSessionKey(contact.id.pub_key); + if (!entry) return false; + + // Don't start a new negotiation if one is already pending + if (entry->state == SESSION_STATE_INIT_SENT) return false; + + // Generate ephemeral keypair A + uint8_t seed[SEED_SIZE]; + getRNG()->random(seed, SEED_SIZE); + ed25519_create_keypair(entry->ephemeral_pub, entry->ephemeral_prv, seed); + memset(seed, 0, SEED_SIZE); + + // Send REQ_TYPE_SESSION_KEY_INIT with ephemeral_pub_A + uint8_t req_data[1 + PUB_KEY_SIZE]; + req_data[0] = REQ_TYPE_SESSION_KEY_INIT; + memcpy(&req_data[1], entry->ephemeral_pub, PUB_KEY_SIZE); + + uint32_t tag, est_timeout; + int rc = sendRequest(contact, req_data, sizeof(req_data), tag, est_timeout); + if (rc == MSG_SEND_FAILED) { + memset(entry->ephemeral_prv, 0, PRV_KEY_SIZE); + memset(entry->ephemeral_pub, 0, PUB_KEY_SIZE); + return false; + } + + entry->state = SESSION_STATE_INIT_SENT; + entry->retries_left = SESSION_KEY_MAX_RETRIES - 1; + entry->timeout_at = futureMillis(SESSION_KEY_TIMEOUT_MS); + return true; +} + +bool BaseChatMesh::handleSessionKeyResponse(ContactInfo& contact, const uint8_t* data, uint8_t len) { + // Response format: [timestamp:4][RESP_TYPE_SESSION_KEY_ACCEPT:1][ephemeral_pub_B:32] + if (len < 5 + PUB_KEY_SIZE) return false; + if (data[4] != RESP_TYPE_SESSION_KEY_ACCEPT) return false; + + auto entry = findSessionKey(contact.id.pub_key); + if (!entry || entry->state != SESSION_STATE_INIT_SENT) return false; + + const uint8_t* ephemeral_pub_B = &data[5]; + + // Compute ephemeral_secret via X25519 + uint8_t ephemeral_secret[PUB_KEY_SIZE]; + ed25519_key_exchange(ephemeral_secret, ephemeral_pub_B, entry->ephemeral_prv); + memset(entry->ephemeral_prv, 0, PRV_KEY_SIZE); + memset(entry->ephemeral_pub, 0, PUB_KEY_SIZE); + + // Derive session_key = HMAC-SHA256(static_shared_secret, ephemeral_secret) + const uint8_t* static_secret = contact.getSharedSecret(self_id); + uint8_t new_session_key[SESSION_KEY_SIZE]; + { + SHA256 sha; + sha.resetHMAC(static_secret, PUB_KEY_SIZE); + sha.update(ephemeral_secret, PUB_KEY_SIZE); + sha.finalizeHMAC(static_secret, PUB_KEY_SIZE, new_session_key, SESSION_KEY_SIZE); + } + memset(ephemeral_secret, 0, PUB_KEY_SIZE); + + // Activate session key + memcpy(entry->session_key, new_session_key, SESSION_KEY_SIZE); + memset(new_session_key, 0, SESSION_KEY_SIZE); + entry->nonce = 1; + entry->state = SESSION_STATE_ACTIVE; + entry->sends_since_last_recv = 0; + entry->retries_left = 0; + entry->timeout_at = 0; + + MESH_DEBUG_PRINTLN("Session key established with: %s", contact.name); + onSessionKeysUpdated(); + return true; +} + +uint8_t BaseChatMesh::handleIncomingSessionKeyInit(ContactInfo& from, const uint8_t* ephemeral_pub_A, uint8_t* reply_buf) { + // 1. Generate ephemeral keypair B + uint8_t seed[SEED_SIZE]; + getRNG()->random(seed, SEED_SIZE); + uint8_t ephemeral_pub_B[PUB_KEY_SIZE]; + uint8_t ephemeral_prv_B[PRV_KEY_SIZE]; + ed25519_create_keypair(ephemeral_pub_B, ephemeral_prv_B, seed); + memset(seed, 0, SEED_SIZE); + + // 2. Compute ephemeral_secret via X25519 + uint8_t ephemeral_secret[PUB_KEY_SIZE]; + ed25519_key_exchange(ephemeral_secret, ephemeral_pub_A, ephemeral_prv_B); + memset(ephemeral_prv_B, 0, PRV_KEY_SIZE); + + // 3. Derive session_key = HMAC-SHA256(static_shared_secret, ephemeral_secret) + const uint8_t* static_secret = from.getSharedSecret(self_id); + uint8_t new_session_key[SESSION_KEY_SIZE]; + { + SHA256 sha; + sha.resetHMAC(static_secret, PUB_KEY_SIZE); + sha.update(ephemeral_secret, PUB_KEY_SIZE); + sha.finalizeHMAC(static_secret, PUB_KEY_SIZE, new_session_key, SESSION_KEY_SIZE); + } + memset(ephemeral_secret, 0, PUB_KEY_SIZE); + + // 4. Store in pool (dual-decode: new key active, old key still valid) + auto entry = allocateSessionKey(from.id.pub_key); + if (!entry) 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); + memset(entry->session_key, 0, SESSION_KEY_SIZE); + memset(entry->prev_session_key, 0, SESSION_KEY_SIZE); + entry->state = SESSION_STATE_NONE; + entry->timeout_at = 0; + } + } +} + +// Virtual overrides for session key decrypt path +const uint8_t* BaseChatMesh::getPeerSessionKey(int peer_idx) { + int i = matching_peer_indexes[peer_idx]; + if (i >= 0 && i < num_contacts) { + auto entry = findSessionKey(contacts[i].id.pub_key); + // Also try decode during INIT_SENT renegotiation (nonce > 1 means prior key exists) + if (entry && (entry->state == SESSION_STATE_ACTIVE || entry->state == SESSION_STATE_DUAL_DECODE + || (entry->state == SESSION_STATE_INIT_SENT && entry->nonce > 1))) + return entry->session_key; + } + return nullptr; +} + +const uint8_t* BaseChatMesh::getPeerPrevSessionKey(int peer_idx) { + int i = matching_peer_indexes[peer_idx]; + if (i >= 0 && i < num_contacts) { + auto entry = findSessionKey(contacts[i].id.pub_key); + if (entry && entry->state == SESSION_STATE_DUAL_DECODE) + return entry->prev_session_key; + } + return nullptr; +} + +void BaseChatMesh::onSessionKeyDecryptSuccess(int peer_idx) { + int i = matching_peer_indexes[peer_idx]; + if (i >= 0 && i < num_contacts) { + auto entry = findSessionKey(contacts[i].id.pub_key); + if (entry) { + bool changed = (entry->state == SESSION_STATE_DUAL_DECODE); + if (changed) { + memset(entry->prev_session_key, 0, SESSION_KEY_SIZE); + entry->state = SESSION_STATE_ACTIVE; + } + 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 fd391b980..5578b197b 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 @@ -71,6 +72,15 @@ class BaseChatMesh : public mesh::Mesh { uint8_t temp_buf[MAX_TRANS_UNIT]; ConnectionInfo connections[MAX_CONNECTIONS]; + // Nonce persistence state (parallel to contacts[]) + uint16_t nonce_at_last_persist[MAX_CONTACTS]; + bool nonce_dirty; + + // Session key pool (Phase 2) + SessionKeyPool session_keys; + bool session_keys_dirty; + int _pending_rekey_idx; // contact index needing session key negotiation, -1 = none + mesh::Packet* composeMsgPacket(const ContactInfo& recipient, uint32_t timestamp, uint8_t attempt, const char *text, uint32_t& expected_ack); void sendAckTo(const ContactInfo& dest, uint32_t ack_hash); @@ -86,6 +96,10 @@ class BaseChatMesh : public mesh::Mesh { txt_send_timeout = 0; _pendingLoopback = NULL; memset(connections, 0, sizeof(connections)); + memset(nonce_at_last_persist, 0, sizeof(nonce_at_last_persist)); + nonce_dirty = false; + session_keys_dirty = false; + _pending_rekey_idx = -1; } void bootstrapRTCfromContacts(); @@ -121,10 +135,59 @@ class BaseChatMesh : public mesh::Mesh { virtual int getBlobByKey(const uint8_t key[], int key_len, uint8_t dest_buf[]) { return 0; } // not implemented virtual bool putBlobByKey(const uint8_t key[], int key_len, const uint8_t src_buf[], int len) { return false; } + // AEAD nonce persistence helpers + uint16_t nextAeadNonceFor(const ContactInfo& contact); // wraps nextAeadNonce() with dirty-check + bool applyLoadedNonce(const uint8_t* pub_key_prefix, uint16_t nonce); + void finalizeNonceLoad(bool needs_bump); + bool getNonceEntry(int idx, uint8_t* pub_key_prefix, uint16_t* nonce); + bool isNonceDirty() const { return nonce_dirty; } + void clearNonceDirty() { + for (int i = 0; i < num_contacts; i++) nonce_at_last_persist[i] = contacts[i].aead_nonce; + nonce_dirty = false; + } + + // Session key support (Phase 2 — initiator) + virtual void onSessionKeysUpdated() { session_keys_dirty = true; } // called when session key pool changes; override to persist + virtual bool loadSessionKeyRecordFromFlash(const uint8_t* pub_key_prefix, + uint8_t* flags, uint16_t* nonce, uint8_t* session_key, uint8_t* prev_session_key) { return false; } + virtual void mergeAndSaveSessionKeys() {} // merge RAM + flash, write back + + // Wrappers that add flash fallback on cache miss + SessionKeyEntry* findSessionKey(const uint8_t* pub_key); + SessionKeyEntry* allocateSessionKey(const uint8_t* pub_key); + void removeSessionKey(const uint8_t* pub_key); + + const uint8_t* getEncryptionKeyFor(const ContactInfo& contact); + uint16_t getEncryptionNonceFor(const ContactInfo& contact); + bool shouldInitiateSessionKey(const ContactInfo& contact); + bool initiateSessionKeyNegotiation(const ContactInfo& contact); + bool handleSessionKeyResponse(ContactInfo& contact, const uint8_t* data, uint8_t len); + uint8_t handleIncomingSessionKeyInit(ContactInfo& from, const uint8_t* ephemeral_pub_A, uint8_t* reply_buf); + void checkSessionKeyTimeouts(); + + // Session key persistence helpers (for subclass to call) + bool applyLoadedSessionKey(const uint8_t* pub_key_prefix, uint8_t flags, uint16_t nonce, + const uint8_t* session_key, const uint8_t* prev_session_key); + bool getSessionKeyEntry(int idx, uint8_t* pub_key_prefix, uint8_t* flags, uint16_t* nonce, + uint8_t* session_key, uint8_t* prev_session_key); + int getSessionKeyCount() const { return session_keys.getCount(); } + bool isSessionKeysDirty() const { return session_keys_dirty; } + void clearSessionKeysDirty() { session_keys_dirty = false; } + bool isSessionKeyInRAMPool(const uint8_t* pub_key_prefix) { return session_keys.hasPrefix(pub_key_prefix); } + bool isSessionKeyRemovedFromPool(const uint8_t* pub_key_prefix) { return session_keys.isRemoved(pub_key_prefix); } + void clearSessionKeysRemoved() { session_keys.clearRemoved(); } + // Mesh overrides void onAdvertRecv(mesh::Packet* packet, const mesh::Identity& id, uint32_t timestamp, const uint8_t* app_data, size_t app_data_len) override; int searchPeersByHash(const uint8_t* hash) override; void getPeerSharedSecret(uint8_t* dest_secret, int peer_idx) override; + uint8_t getPeerFlags(int peer_idx) override; + uint16_t getPeerNextAeadNonce(int peer_idx) override; + const uint8_t* getPeerSessionKey(int peer_idx) override; + const uint8_t* getPeerPrevSessionKey(int peer_idx) override; + void onSessionKeyDecryptSuccess(int peer_idx) override; + const uint8_t* getPeerEncryptionKey(int peer_idx, const uint8_t* static_secret) override; + uint16_t getPeerEncryptionNonce(int peer_idx) override; void onPeerDataRecv(mesh::Packet* packet, uint8_t type, int sender_idx, const uint8_t* secret, uint8_t* data, size_t len) override; bool onPeerPathRecv(mesh::Packet* packet, int sender_idx, const uint8_t* secret, uint8_t* path, uint8_t path_len, uint8_t extra_type, uint8_t* extra, uint8_t extra_len) override; void onAckRecv(mesh::Packet* packet, uint32_t ack_crc) override; diff --git a/src/helpers/ClientACL.cpp b/src/helpers/ClientACL.cpp index 55b70ca55..159de8385 100644 --- a/src/helpers/ClientACL.cpp +++ b/src/helpers/ClientACL.cpp @@ -1,4 +1,7 @@ #include "ClientACL.h" +#include +#include +#include static File openWrite(FILESYSTEM* _fs, const char* filename) { #if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) @@ -111,25 +114,116 @@ ClientInfo* ClientACL::putClient(const mesh::Identity& id, uint8_t init_perms) { } else { c = oldest; // evict least active contact } + int idx = c - clients; memset(c, 0, sizeof(*c)); c->permissions = init_perms; c->id = id; c->out_path_len = -1; // initially out_path is unknown + if (_rng) { + c->aead_nonce = (uint16_t)_rng->nextInt(NONCE_INITIAL_MIN, NONCE_INITIAL_MAX + 1); + } + nonce_at_last_persist[idx] = c->aead_nonce; return c; } +uint16_t ClientACL::nextAeadNonceFor(const ClientInfo& client) { + uint16_t nonce = client.nextAeadNonce(); + if (nonce != 0) { + int idx = &client - clients; + if (idx >= 0 && idx < num_clients && + (uint16_t)(client.aead_nonce - nonce_at_last_persist[idx]) >= NONCE_PERSIST_INTERVAL) { + nonce_dirty = true; + } + } + return nonce; +} + +void ClientACL::loadNonces() { + if (!_fs) return; +#if defined(RP2040_PLATFORM) + File file = _fs->open("/s_nonces", "r"); +#elif defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) + File file = _fs->open("/s_nonces", FILE_O_READ); +#else + File file = _fs->open("/s_nonces", "r", false); +#endif + if (file) { + uint8_t rec[6]; // 4-byte pub_key prefix + 2-byte nonce + while (file.read(rec, 6) == 6) { + uint16_t nonce; + memcpy(&nonce, &rec[4], 2); + for (int i = 0; i < num_clients; i++) { + if (memcmp(clients[i].id.pub_key, rec, 4) == 0) { + clients[i].aead_nonce = nonce; + break; + } + } + } + file.close(); + } +} + +void ClientACL::saveNonces() { + if (!_fs) return; + File file = openWrite(_fs, "/s_nonces"); + if (file) { + for (int i = 0; i < num_clients; i++) { + file.write(clients[i].id.pub_key, 4); + file.write((uint8_t*)&clients[i].aead_nonce, 2); + nonce_at_last_persist[i] = clients[i].aead_nonce; + } + file.close(); + nonce_dirty = false; + } +} + +void ClientACL::finalizeNonceLoad(bool needs_bump) { + for (int i = 0; i < num_clients; i++) { + if (needs_bump) { + uint16_t old = clients[i].aead_nonce; + clients[i].aead_nonce += NONCE_BOOT_BUMP; + if (clients[i].aead_nonce == 0) clients[i].aead_nonce = 1; + if (clients[i].aead_nonce < old) { + MESH_DEBUG_PRINTLN("AEAD nonce wrapped after boot bump for client: %02x%02x%02x%02x", + clients[i].id.pub_key[0], clients[i].id.pub_key[1], + clients[i].id.pub_key[2], clients[i].id.pub_key[3]); + } + } + nonce_at_last_persist[i] = clients[i].aead_nonce; + } + nonce_dirty = false; + + // Apply boot bump to session key nonces too + if (needs_bump) { + for (int i = 0; i < session_keys.getCount(); i++) { + auto entry = session_keys.getByIdx(i); + if (entry && (entry->state == SESSION_STATE_ACTIVE || entry->state == SESSION_STATE_DUAL_DECODE)) { + uint16_t old_nonce = entry->nonce; + entry->nonce += NONCE_BOOT_BUMP; + if (entry->nonce <= old_nonce) { + entry->nonce = 65535; // wrapped — force exhaustion so renegotiation happens + } + } + } + } +} + bool ClientACL::applyPermissions(const mesh::LocalIdentity& self_id, const uint8_t* pubkey, int key_len, uint8_t perms) { ClientInfo* c; if ((perms & PERM_ACL_ROLE_MASK) == PERM_ACL_GUEST) { // guest role is not persisted in contacts c = getClient(pubkey, key_len); if (c == NULL) return false; // partial pubkey not found + removeSessionKey(c->id.pub_key); // also remove session key if any + num_clients--; // delete from contacts[] int i = c - clients; while (i < num_clients) { clients[i] = clients[i + 1]; + nonce_at_last_persist[i] = nonce_at_last_persist[i + 1]; i++; } + memset(&clients[num_clients], 0, sizeof(ClientInfo)); } else { if (key_len < PUB_KEY_SIZE) return false; // need complete pubkey when adding/modifying @@ -141,3 +235,313 @@ bool ClientACL::applyPermissions(const mesh::LocalIdentity& self_id, const uint8 } return true; } + +// --- Session key support (Phase 2) --- + +int ClientACL::handleSessionKeyInit(const ClientInfo* client, const uint8_t* ephemeral_pub_A, uint8_t* reply_buf, mesh::RNG* rng) { + // 1. Generate ephemeral keypair B + uint8_t seed[SEED_SIZE]; + rng->random(seed, SEED_SIZE); + uint8_t ephemeral_pub_B[PUB_KEY_SIZE]; + uint8_t ephemeral_prv_B[PRV_KEY_SIZE]; + ed25519_create_keypair(ephemeral_pub_B, ephemeral_prv_B, seed); + memset(seed, 0, SEED_SIZE); + + // 2. Compute ephemeral_secret via X25519 + uint8_t ephemeral_secret[PUB_KEY_SIZE]; + ed25519_key_exchange(ephemeral_secret, ephemeral_pub_A, ephemeral_prv_B); + memset(ephemeral_prv_B, 0, PRV_KEY_SIZE); + + // 3. Derive session_key = HMAC-SHA256(static_shared_secret, ephemeral_secret) + uint8_t new_session_key[SESSION_KEY_SIZE]; + { + SHA256 sha; + sha.resetHMAC(client->shared_secret, PUB_KEY_SIZE); + sha.update(ephemeral_secret, PUB_KEY_SIZE); + sha.finalizeHMAC(client->shared_secret, PUB_KEY_SIZE, new_session_key, SESSION_KEY_SIZE); + } + memset(ephemeral_secret, 0, PUB_KEY_SIZE); + + // 4. Store in pool (dual-decode: new key active, old key still valid) + auto entry = allocateSessionKey(client->id.pub_key); + if (!entry) return 0; + + if (entry->state == SESSION_STATE_ACTIVE || entry->state == SESSION_STATE_DUAL_DECODE) { + memcpy(entry->prev_session_key, entry->session_key, SESSION_KEY_SIZE); + } + memcpy(entry->session_key, new_session_key, SESSION_KEY_SIZE); + entry->nonce = 1; + entry->state = SESSION_STATE_DUAL_DECODE; + entry->sends_since_last_recv = 0; + memset(new_session_key, 0, SESSION_KEY_SIZE); + + // 5. Persist immediately + saveSessionKeys(); + + // 6. Write ephemeral_pub_B to reply + memcpy(reply_buf, ephemeral_pub_B, PUB_KEY_SIZE); + return PUB_KEY_SIZE; +} + +const uint8_t* ClientACL::getSessionKey(const uint8_t* pub_key) { + auto entry = findSessionKey(pub_key); + if (entry && (entry->state == SESSION_STATE_ACTIVE || entry->state == SESSION_STATE_DUAL_DECODE)) { + return entry->session_key; + } + return nullptr; +} + +const uint8_t* ClientACL::getPrevSessionKey(const uint8_t* pub_key) { + auto entry = findSessionKey(pub_key); + if (entry && entry->state == SESSION_STATE_DUAL_DECODE) { + return entry->prev_session_key; + } + return nullptr; +} + +const uint8_t* ClientACL::getEncryptionKey(const ClientInfo& client) { + auto entry = findSessionKey(client.id.pub_key); + if (entry && (entry->state == SESSION_STATE_ACTIVE || entry->state == SESSION_STATE_DUAL_DECODE) + && entry->sends_since_last_recv < SESSION_KEY_STALE_THRESHOLD + && entry->nonce < 65535) { + return entry->session_key; + } + return client.shared_secret; +} + +uint16_t ClientACL::getEncryptionNonce(const ClientInfo& client) { + auto entry = findSessionKey(client.id.pub_key); + if (entry && (entry->state == SESSION_STATE_ACTIVE || entry->state == SESSION_STATE_DUAL_DECODE) + && entry->sends_since_last_recv < SESSION_KEY_STALE_THRESHOLD + && entry->nonce < 65535) { + ++entry->nonce; + if (entry->sends_since_last_recv < 255) entry->sends_since_last_recv++; + _session_keys_dirty = true; + return entry->nonce; + } + // Progressive fallback: keep incrementing counter even when not using session key + if (entry && entry->sends_since_last_recv < 255) { + entry->sends_since_last_recv++; + if (entry->sends_since_last_recv >= SESSION_KEY_ABANDON_THRESHOLD) { + int idx = &client - clients; + if (idx >= 0 && idx < num_clients) + clients[idx].flags &= ~CONTACT_FLAG_AEAD; + removeSessionKey(client.id.pub_key); + saveSessionKeys(); + return 0; // ECB + } + if (entry->sends_since_last_recv >= SESSION_KEY_ECB_THRESHOLD) { + return 0; // ECB + } + } + return nextAeadNonceFor(client); +} + +void ClientACL::onSessionConfirmed(const uint8_t* pub_key) { + auto entry = findSessionKey(pub_key); + if (entry) { + if (entry->state == SESSION_STATE_DUAL_DECODE) { + memset(entry->prev_session_key, 0, SESSION_KEY_SIZE); + entry->state = SESSION_STATE_ACTIVE; + saveSessionKeys(); + } + entry->sends_since_last_recv = 0; + } +} + +// --- Peer-index forwarding helpers --- + +ClientInfo* ClientACL::resolveClient(int peer_idx, const int* matching_indexes) { + int i = matching_indexes[peer_idx]; + if (i >= 0 && i < num_clients) return &clients[i]; + return nullptr; +} + +uint16_t ClientACL::peerNextAeadNonce(int peer_idx, const int* matching_indexes) { + auto* c = resolveClient(peer_idx, matching_indexes); + return c ? nextAeadNonceFor(*c) : 0; +} + +const uint8_t* ClientACL::peerSessionKey(int peer_idx, const int* matching_indexes) { + auto* c = resolveClient(peer_idx, matching_indexes); + return c ? getSessionKey(c->id.pub_key) : nullptr; +} + +const uint8_t* ClientACL::peerPrevSessionKey(int peer_idx, const int* matching_indexes) { + auto* c = resolveClient(peer_idx, matching_indexes); + return c ? getPrevSessionKey(c->id.pub_key) : nullptr; +} + +void ClientACL::peerSessionKeyDecryptSuccess(int peer_idx, const int* matching_indexes) { + auto* c = resolveClient(peer_idx, matching_indexes); + if (c) onSessionConfirmed(c->id.pub_key); +} + +const uint8_t* ClientACL::peerEncryptionKey(int peer_idx, const int* matching_indexes, const uint8_t* fallback) { + auto* c = resolveClient(peer_idx, matching_indexes); + return c ? getEncryptionKey(*c) : fallback; +} + +uint16_t ClientACL::peerEncryptionNonce(int peer_idx, const int* matching_indexes) { + auto* c = resolveClient(peer_idx, matching_indexes); + return c ? getEncryptionNonce(*c) : 0; +} + +// --- Flash-backed session key wrappers --- + +static File openReadACL(FILESYSTEM* fs, const char* filename) { +#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) + return fs->open(filename, FILE_O_READ); +#elif defined(RP2040_PLATFORM) + return fs->open(filename, "r"); +#else + return fs->open(filename, "r", false); +#endif +} + +bool ClientACL::loadSessionKeyRecordFromFlash(const uint8_t* prefix, + uint8_t* flags, uint16_t* nonce, uint8_t* session_key, uint8_t* prev_session_key) { + if (!_fs) return false; + File f = openReadACL(_fs, "/s_sess_keys"); + if (!f) return false; + while (true) { + uint8_t rec[SESSION_KEY_RECORD_MIN_SIZE]; + if (f.read(rec, SESSION_KEY_RECORD_MIN_SIZE) != SESSION_KEY_RECORD_MIN_SIZE) break; + uint8_t rec_flags = rec[4]; + bool has_prev = (rec_flags & SESSION_FLAG_PREV_VALID); + if (memcmp(rec, prefix, 4) == 0) { + *flags = rec_flags; + memcpy(nonce, &rec[5], 2); + memcpy(session_key, &rec[7], SESSION_KEY_SIZE); + if (has_prev) { + if (f.read(prev_session_key, SESSION_KEY_SIZE) != SESSION_KEY_SIZE) break; + } else { + memset(prev_session_key, 0, SESSION_KEY_SIZE); + } + f.close(); + return true; + } + // Skip prev_key if present + if (has_prev) { + uint8_t skip[SESSION_KEY_SIZE]; + if (f.read(skip, SESSION_KEY_SIZE) != SESSION_KEY_SIZE) break; + } + } + f.close(); + return false; +} + +SessionKeyEntry* ClientACL::findSessionKey(const uint8_t* pub_key) { + auto entry = session_keys.findByPrefix(pub_key); + if (entry) return entry; + + // Cache miss — try flash + uint8_t flags; uint16_t nonce; + uint8_t sk[SESSION_KEY_SIZE], psk[SESSION_KEY_SIZE]; + if (!loadSessionKeyRecordFromFlash(pub_key, &flags, &nonce, sk, psk)) return nullptr; + + // Save dirty evictee before overwriting + if (session_keys.isFull() && _session_keys_dirty) { + saveSessionKeys(); + } + session_keys.applyLoaded(pub_key, flags, nonce, sk, psk); + return session_keys.findByPrefix(pub_key); +} + +SessionKeyEntry* ClientACL::allocateSessionKey(const uint8_t* pub_key) { + auto entry = findSessionKey(pub_key); + if (entry) return entry; + + // Not found anywhere — save dirty evictee before allocating + if (session_keys.isFull() && _session_keys_dirty) { + saveSessionKeys(); + } + return session_keys.allocate(pub_key); +} + +void ClientACL::removeSessionKey(const uint8_t* pub_key) { + session_keys.remove(pub_key); + _session_keys_dirty = true; +} + +void ClientACL::loadSessionKeys() { + if (!_fs) return; + File file = openReadACL(_fs, "/s_sess_keys"); + if (!file) return; + while (true) { + uint8_t rec[SESSION_KEY_RECORD_MIN_SIZE]; + if (file.read(rec, SESSION_KEY_RECORD_MIN_SIZE) != SESSION_KEY_RECORD_MIN_SIZE) break; + uint8_t flags = rec[4]; + uint16_t nonce; + memcpy(&nonce, &rec[5], 2); + uint8_t prev_key[SESSION_KEY_SIZE]; + if (flags & SESSION_FLAG_PREV_VALID) { + if (file.read(prev_key, SESSION_KEY_SIZE) != SESSION_KEY_SIZE) break; + } else { + memset(prev_key, 0, SESSION_KEY_SIZE); + } + session_keys.applyLoaded(rec, flags, nonce, &rec[7], prev_key); + } + file.close(); +} + +void ClientACL::saveSessionKeys() { + if (!_fs) return; + + // 1. Read old flash file into buffer (variable-length records) + uint8_t old_buf[MAX_SESSION_KEYS_FLASH * SESSION_KEY_RECORD_SIZE]; + int old_len = 0; + File rf = openReadACL(_fs, "/s_sess_keys"); + if (rf) { + while (true) { + if (old_len + SESSION_KEY_RECORD_MIN_SIZE > (int)sizeof(old_buf)) break; + if (rf.read(&old_buf[old_len], SESSION_KEY_RECORD_MIN_SIZE) != SESSION_KEY_RECORD_MIN_SIZE) break; + uint8_t flags = old_buf[old_len + 4]; + int rec_len = SESSION_KEY_RECORD_MIN_SIZE; + if (flags & SESSION_FLAG_PREV_VALID) { + if (old_len + SESSION_KEY_RECORD_SIZE > (int)sizeof(old_buf)) break; + if (rf.read(&old_buf[old_len + SESSION_KEY_RECORD_MIN_SIZE], SESSION_KEY_SIZE) != SESSION_KEY_SIZE) break; + rec_len = SESSION_KEY_RECORD_SIZE; + } + old_len += rec_len; + } + rf.close(); + } + + // 2. Write merged file + File wf = openWrite(_fs, "/s_sess_keys"); + if (!wf) return; + + // Write kept old records (variable-length) + int pos = 0; + while (pos + SESSION_KEY_RECORD_MIN_SIZE <= old_len) { + uint8_t* rec = &old_buf[pos]; + uint8_t flags = rec[4]; + int rec_len = (flags & SESSION_FLAG_PREV_VALID) ? SESSION_KEY_RECORD_SIZE : SESSION_KEY_RECORD_MIN_SIZE; + if (pos + rec_len > old_len) break; + if (!session_keys.hasPrefix(rec) && !session_keys.isRemoved(rec)) { + wf.write(rec, rec_len); + } + pos += rec_len; + } + // Write current RAM entries (variable-length) + for (int i = 0; i < session_keys.getCount(); i++) { + uint8_t pub_key_prefix[4]; + uint8_t flags; + uint16_t nonce; + uint8_t session_key[SESSION_KEY_SIZE]; + uint8_t prev_session_key[SESSION_KEY_SIZE]; + if (session_keys.getEntryForSave(i, pub_key_prefix, &flags, &nonce, session_key, prev_session_key)) { + wf.write(pub_key_prefix, 4); + wf.write(&flags, 1); + wf.write((uint8_t*)&nonce, 2); + wf.write(session_key, SESSION_KEY_SIZE); + if (flags & SESSION_FLAG_PREV_VALID) { + wf.write(prev_session_key, SESSION_KEY_SIZE); + } + } + } + wf.close(); + _session_keys_dirty = false; + session_keys.clearRemoved(); +} diff --git a/src/helpers/ClientACL.h b/src/helpers/ClientACL.h index dfbc3fce1..2ba828c01 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 @@ -13,6 +14,8 @@ struct ClientInfo { mesh::Identity id; uint8_t permissions; + uint8_t flags; // transient — includes CONTACT_FLAG_AEAD + mutable uint16_t aead_nonce; // transient — per-peer nonce counter int8_t out_path_len; uint8_t out_path[MAX_PATH_SIZE]; uint8_t shared_secret[PUB_KEY_SIZE]; @@ -27,7 +30,14 @@ struct ClientInfo { uint8_t push_failures; } room; } extra; - + + uint16_t nextAeadNonce() const { + if (flags & CONTACT_FLAG_AEAD) { + if (++aead_nonce == 0) ++aead_nonce; // skip 0 (means ECB) + return aead_nonce; + } + return 0; + } bool isAdmin() const { return (permissions & PERM_ACL_ROLE_MASK) == PERM_ACL_ADMIN; } }; @@ -40,10 +50,23 @@ class ClientACL { ClientInfo clients[MAX_CLIENTS]; int num_clients; + // Nonce persistence state (parallel to clients[]) + uint16_t nonce_at_last_persist[MAX_CLIENTS]; + bool nonce_dirty; + bool _session_keys_dirty; + mesh::RNG* _rng; + + // Session key pool (Phase 2) + SessionKeyPool session_keys; + public: - ClientACL() { + ClientACL() { memset(clients, 0, sizeof(clients)); + memset(nonce_at_last_persist, 0, sizeof(nonce_at_last_persist)); num_clients = 0; + nonce_dirty = false; + _session_keys_dirty = false; + _rng = NULL; } void load(FILESYSTEM* _fs, const mesh::LocalIdentity& self_id); void save(FILESYSTEM* _fs, bool (*filter)(ClientInfo*)=NULL); @@ -55,4 +78,50 @@ class ClientACL { int getNumClients() const { return num_clients; } ClientInfo* getClientByIdx(int idx) { return &clients[idx]; } + int getSessionKeyCount() const { return session_keys.getCount(); } + + // AEAD nonce persistence + void setRNG(mesh::RNG* rng) { _rng = rng; } + uint16_t nextAeadNonceFor(const ClientInfo& client); + void loadNonces(); + void saveNonces(); + void finalizeNonceLoad(bool needs_bump); + bool isNonceDirty() const { return nonce_dirty; } + void clearNonceDirty() { + for (int i = 0; i < num_clients; i++) nonce_at_last_persist[i] = clients[i].aead_nonce; + nonce_dirty = false; + } + + // Session key support (Phase 2) + int handleSessionKeyInit(const ClientInfo* client, const uint8_t* ephemeral_pub_A, uint8_t* reply_buf, mesh::RNG* rng); + const uint8_t* getSessionKey(const uint8_t* pub_key); + const uint8_t* getPrevSessionKey(const uint8_t* pub_key); + const uint8_t* getEncryptionKey(const ClientInfo& client); + uint16_t getEncryptionNonce(const ClientInfo& client); + void onSessionConfirmed(const uint8_t* pub_key); + bool isSessionKeysDirty() const { return _session_keys_dirty; } + void loadSessionKeys(); + void saveSessionKeys(); + +private: + // Flash-backed session key wrappers + SessionKeyEntry* findSessionKey(const uint8_t* pub_key); + SessionKeyEntry* allocateSessionKey(const uint8_t* pub_key); + void removeSessionKey(const uint8_t* pub_key); + bool loadSessionKeyRecordFromFlash(const uint8_t* pub_key_prefix, + uint8_t* flags, uint16_t* nonce, uint8_t* session_key, uint8_t* prev_session_key); + +public: + + // Peer-index forwarding helpers for server-side Mesh overrides. + // These resolve peer_idx → ClientInfo via matching_indexes[], then delegate + // to the corresponding method above. Eliminates repeated boilerplate in + // repeater/room/sensor examples. + ClientInfo* resolveClient(int peer_idx, const int* matching_indexes); + uint16_t peerNextAeadNonce(int peer_idx, const int* matching_indexes); + const uint8_t* peerSessionKey(int peer_idx, const int* matching_indexes); + const uint8_t* peerPrevSessionKey(int peer_idx, const int* matching_indexes); + void peerSessionKeyDecryptSuccess(int peer_idx, const int* matching_indexes); + const uint8_t* peerEncryptionKey(int peer_idx, const int* matching_indexes, const uint8_t* fallback); + uint16_t peerEncryptionNonce(int peer_idx, const int* matching_indexes); }; diff --git a/src/helpers/CommonCLI.cpp b/src/helpers/CommonCLI.cpp index 6dcf7018e..affb9343b 100644 --- a/src/helpers/CommonCLI.cpp +++ b/src/helpers/CommonCLI.cpp @@ -183,22 +183,27 @@ void CommonCLI::savePrefs() { uint8_t CommonCLI::buildAdvertData(uint8_t node_type, uint8_t* app_data) { if (_prefs->advert_loc_policy == ADVERT_LOC_NONE) { AdvertDataBuilder builder(node_type, _prefs->node_name); + builder.setFeat1(FEAT1_AEAD_SUPPORT); return builder.encodeTo(app_data); } else if (_prefs->advert_loc_policy == ADVERT_LOC_SHARE) { AdvertDataBuilder builder(node_type, _prefs->node_name, _sensors->node_lat, _sensors->node_lon); + builder.setFeat1(FEAT1_AEAD_SUPPORT); return builder.encodeTo(app_data); } else { AdvertDataBuilder builder(node_type, _prefs->node_name, _prefs->node_lat, _prefs->node_lon); + builder.setFeat1(FEAT1_AEAD_SUPPORT); return builder.encodeTo(app_data); } } void CommonCLI::handleCommand(uint32_t sender_timestamp, const char* command, char* reply) { if (memcmp(command, "reboot", 6) == 0) { + _callbacks->onBeforeReboot(); _board->reboot(); // doesn't return } else if (memcmp(command, "clkreboot", 9) == 0) { // Reset clock getRTCClock()->setCurrentTime(1715770351); // 15 May 2024, 8:50pm + _callbacks->onBeforeReboot(); _board->reboot(); // doesn't return } else if (memcmp(command, "advert", 6) == 0) { // send flood advert @@ -778,6 +783,8 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, const char* command, ch _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/CommonCLI.h b/src/helpers/CommonCLI.h index 146e1c6e2..3df5c7049 100644 --- a/src/helpers/CommonCLI.h +++ b/src/helpers/CommonCLI.h @@ -87,6 +87,10 @@ class CommonCLICallbacks { virtual void restartBridge() { // no op by default }; + + virtual void onBeforeReboot() { + // no op by default — override to flush nonces, etc. + }; }; class CommonCLI { diff --git a/src/helpers/ContactInfo.h b/src/helpers/ContactInfo.h index eff07741a..bd7977987 100644 --- a/src/helpers/ContactInfo.h +++ b/src/helpers/ContactInfo.h @@ -15,6 +15,24 @@ struct ContactInfo { uint32_t lastmod; // by OUR clock int32_t gps_lat, gps_lon; // 6 dec places uint32_t sync_since; + mutable uint16_t aead_nonce; // per-peer AEAD nonce counter for DMs (not used for group messages), seeded from HW RNG + + // Returns next AEAD nonce (post-increment) if peer supports AEAD, 0 otherwise. + // When 0, callers use ECB encryption. + uint16_t nextAeadNonce() const { + if (flags & CONTACT_FLAG_AEAD) { + if (++aead_nonce == 0) { + ++aead_nonce; // skip 0 (sentinel for ECB) + MESH_DEBUG_PRINTLN("AEAD nonce wrapped for peer: %s", name); + } + if (aead_nonce < NONCE_INITIAL_MIN) { + aead_nonce = 1; // stay stuck in exhaustion zone, always return ECB + return 0; + } + return aead_nonce; + } + return 0; + } const uint8_t* getSharedSecret(const mesh::LocalIdentity& self_id) const { if (!shared_secret_valid) { diff --git a/src/helpers/SessionKeyPool.h b/src/helpers/SessionKeyPool.h new file mode 100644 index 000000000..d1f100fc0 --- /dev/null +++ b/src/helpers/SessionKeyPool.h @@ -0,0 +1,155 @@ +#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 + + +struct SessionKeyEntry { + uint8_t peer_pub_prefix[4]; // first 4 bytes of peer's public key + uint8_t session_key[SESSION_KEY_SIZE]; + uint8_t prev_session_key[SESSION_KEY_SIZE]; + uint16_t nonce; // session key nonce counter (starts at 1) + uint8_t state; // SESSION_STATE_* + uint8_t sends_since_last_recv; // RAM-only counter, threshold SESSION_KEY_STALE_THRESHOLD + uint8_t retries_left; // remaining INIT retries this round + unsigned long timeout_at; // millis timestamp for INIT timeout + uint8_t ephemeral_prv[PRV_KEY_SIZE]; // initiator-only: ephemeral private key (zeroed after use) + uint8_t ephemeral_pub[PUB_KEY_SIZE]; // initiator-only: ephemeral public key + uint32_t last_used; // LRU counter (higher = more recent) +}; + +class SessionKeyPool { + SessionKeyEntry entries[MAX_SESSION_KEYS_RAM]; + int count; + uint32_t lru_counter; + + // Track prefixes removed since last save, so merge-save doesn't resurrect them + uint8_t removed_prefixes[MAX_SESSION_KEYS_RAM][4]; + int removed_count; + + void touch(SessionKeyEntry* entry) { + entry->last_used = ++lru_counter; + } + +public: + SessionKeyPool() : count(0), lru_counter(0), removed_count(0) { + memset(entries, 0, sizeof(entries)); + memset(removed_prefixes, 0, sizeof(removed_prefixes)); + } + + bool isFull() const { return count >= MAX_SESSION_KEYS_RAM; } + + SessionKeyEntry* findByPrefix(const uint8_t* pub_key) { + for (int i = 0; i < count; i++) { + if (memcmp(entries[i].peer_pub_prefix, pub_key, 4) == 0) { + touch(&entries[i]); + return &entries[i]; + } + } + return nullptr; + } + + // Lookup without updating LRU — use during save/merge to avoid perturbing eviction order + bool hasPrefix(const uint8_t* pub_key) const { + for (int i = 0; i < count; i++) { + if (memcmp(entries[i].peer_pub_prefix, pub_key, 4) == 0) return true; + } + return false; + } + + SessionKeyEntry* allocate(const uint8_t* pub_key) { + // Check if already exists + auto existing = findByPrefix(pub_key); + if (existing) return existing; + + // Find free slot + if (count < MAX_SESSION_KEYS_RAM) { + auto e = &entries[count++]; + memset(e, 0, sizeof(*e)); + memcpy(e->peer_pub_prefix, pub_key, 4); + touch(e); + return e; + } + // Pool full — LRU eviction, skip INIT_SENT entries (ephemeral keys are RAM-only) + int evict_idx = -1; + uint32_t min_used = 0xFFFFFFFF; + for (int i = 0; i < MAX_SESSION_KEYS_RAM; i++) { + if (entries[i].state == SESSION_STATE_INIT_SENT) continue; + if (entries[i].last_used < min_used) { + min_used = entries[i].last_used; + evict_idx = i; + } + } + if (evict_idx < 0) evict_idx = 0; // all INIT_SENT — shouldn't happen, fall back to [0] + memset(&entries[evict_idx], 0, sizeof(entries[evict_idx])); + memcpy(entries[evict_idx].peer_pub_prefix, pub_key, 4); + touch(&entries[evict_idx]); + return &entries[evict_idx]; + } + + void remove(const uint8_t* pub_key) { + for (int i = 0; i < count; i++) { + if (memcmp(entries[i].peer_pub_prefix, pub_key, 4) == 0) { + // Track removed prefix for merge-save + if (removed_count < MAX_SESSION_KEYS_RAM) { + memcpy(removed_prefixes[removed_count++], entries[i].peer_pub_prefix, 4); + } + // Shift remaining entries down + count--; + for (int j = i; j < count; j++) { + entries[j] = entries[j + 1]; + } + memset(&entries[count], 0, sizeof(entries[count])); + return; + } + } + } + + bool isRemoved(const uint8_t* pub_key_prefix) const { + for (int i = 0; i < removed_count; i++) { + if (memcmp(removed_prefixes[i], pub_key_prefix, 4) == 0) return true; + } + return false; + } + + void clearRemoved() { removed_count = 0; } + + int getCount() const { return count; } + SessionKeyEntry* getByIdx(int idx) { return (idx >= 0 && idx < count) ? &entries[idx] : nullptr; } + + // Persistence helpers — variable-length records: [pub_prefix:4][flags:1][nonce:2][session_key:32][prev_session_key:32 if flags & PREV_VALID] + // Returns false when idx is past end or entry is not persistable + bool getEntryForSave(int idx, uint8_t* pub_key_prefix, uint8_t* flags, uint16_t* nonce, + uint8_t* session_key, uint8_t* prev_session_key) { + if (idx >= count) return false; + auto& e = entries[idx]; + if (e.state == SESSION_STATE_NONE || e.state == SESSION_STATE_INIT_SENT) return false; // don't persist pending negotiations + memcpy(pub_key_prefix, e.peer_pub_prefix, 4); + *flags = (e.state == SESSION_STATE_DUAL_DECODE) ? SESSION_FLAG_PREV_VALID : 0; + *nonce = e.nonce; + memcpy(session_key, e.session_key, SESSION_KEY_SIZE); + memcpy(prev_session_key, e.prev_session_key, SESSION_KEY_SIZE); + return true; + } + + bool applyLoaded(const uint8_t* pub_key_prefix, uint8_t flags, uint16_t nonce, + const uint8_t* session_key, const uint8_t* prev_session_key) { + auto e = allocate(pub_key_prefix); + if (!e) return false; + e->nonce = nonce; + e->state = (flags & SESSION_FLAG_PREV_VALID) ? SESSION_STATE_DUAL_DECODE : SESSION_STATE_ACTIVE; + e->sends_since_last_recv = 0; + e->retries_left = 0; + e->timeout_at = 0; + memcpy(e->session_key, session_key, SESSION_KEY_SIZE); + memcpy(e->prev_session_key, prev_session_key, SESSION_KEY_SIZE); + memset(e->ephemeral_prv, 0, sizeof(e->ephemeral_prv)); + memset(e->ephemeral_pub, 0, sizeof(e->ephemeral_pub)); + return true; + } +};