From 0ad51a29cb7ed077f70171420cada267e8fef8bb Mon Sep 17 00:00:00 2001 From: mikecarper Date: Sat, 26 Sep 2026 18:01:43 -0700 Subject: [PATCH] Protect remote admin identity backup replies --- docs/cli_commands.md | 16 +++++++++- examples/companion_radio/MyMesh.cpp | 45 ++++++++++++++++++++++++++ examples/companion_radio/MyMesh.h | 4 +++ examples/simple_repeater/MyMesh.cpp | 49 +++++++++++++++++++++++++++-- 4 files changed, 110 insertions(+), 4 deletions(-) diff --git a/docs/cli_commands.md b/docs/cli_commands.md index ef454087c..5df0c5df2 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -1744,12 +1744,26 @@ for the tested XIAO handoff and host-cache caveats. - `set prv.key ` **Parameters:** -- `private_key`: Private key in hex format (64 hex characters) +- `private_key`: MeshCore expanded private key in hex format (128 hex characters) **Local connection only:** `get prv.key`. Companion also requires `ENABLE_PRIVATE_KEY_EXPORT=1`; use binary command `0x42` or the text terminal. Infrastructure `set prv.key` retains its administrator access rules. +Infrastructure repeaters with `ENABLE_PRIVATE_KEY_EXPORT=1` also support +`backup prv.key <16-hex-nonce>` for authenticated remote administrators. It +returns `KEYBACKUP <128-hex-private-key>` only as an end-to-end encrypted +direct CLI reply. This separate command does not make `get prv.key` remotely +available. Treat the reply as a secret: clients must not put it in chat history +or debug logs and should encrypt any saved backup. An administrator password +alone is not a safe backup-file encryption key. Firmware does not retain this +reply in its remote CLI retry cache. +Companion firmware must advertise `get key.backup.transport` as +`ephemeral-routed-v1` before the phone requests it; that build delivers the +reply only to the live attached client that requested it, without offline +queuing or terminal echo. +Older Companion firmware is not a safe transport for this backup flow. + **Note:** Requires reboot to take effect after setting --- diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index dcc68fc4b..0c352127b 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -9,6 +9,7 @@ #endif #include // needed for PlatformIO +#include #ifdef ENABLE_WIFI_INTERFACE #include #endif @@ -1027,6 +1028,31 @@ void MyMesh::queueMessage(const ContactInfo &from, uint8_t txt_type, mesh::Packe } memcpy(&out_frame[i], text, tlen); i += tlen; + const char* sensitive_reply = text; + if (isxdigit((unsigned char)text[0]) && isxdigit((unsigned char)text[1]) + && text[2] == '|') sensitive_reply += 3; + if (txt_type == TXT_TYPE_CLI_DATA + && strncmp(sensitive_reply, "KEYBACKUP ", 10) == 0) { + // Identity exports must never enter the normal offline message queue or + // text terminal (both retain/echo plaintext). Deliver once to a live + // directly attached app; if that link is gone the requester can retry. + const bool expected = strlen(sensitive_reply) == 10 + 16 + 1 + PRV_KEY_SIZE * 2 + && private_key_backup_route != NULL + && _serial->isReplyRouteAvailable(private_key_backup_route) + && !millisHasNowPassed(private_key_backup_deadline) + && memcmp(from.id.pub_key, private_key_backup_sender, 6) == 0 + && strncmp(sensitive_reply + 10, private_key_backup_nonce, 16) == 0 + && sensitive_reply[26] == ' '; + if (expected) { + _serial->writeFrameToRoute(private_key_backup_route, out_frame, i); + } + private_key_backup_route = NULL; + private_key_backup_deadline = 0; + memset(private_key_backup_nonce, 0, sizeof(private_key_backup_nonce)); + memset(private_key_backup_sender, 0, sizeof(private_key_backup_sender)); + memset(out_frame, 0, i); + return; + } const bool queued = addToOfflineQueue(out_frame, i); if (_serial->isConnected()) { @@ -4392,6 +4418,12 @@ void MyMesh::cancelSerialResponseStream() { void MyMesh::cancelSerialOperationsForRoute(BaseSerialInterface* route) { if (route == NULL) return; + if (private_key_backup_route == route) { + private_key_backup_route = NULL; + private_key_backup_deadline = 0; + memset(private_key_backup_nonce, 0, sizeof(private_key_backup_nonce)); + memset(private_key_backup_sender, 0, sizeof(private_key_backup_sender)); + } if (pending_serial_reply_route == route) clearPendingReqs(); if (command_radio_reply_route == route) cancelPendingRadioParamApply(); if (binary_trace_reply_route == route) clearBinaryTraceReply(); @@ -4583,6 +4615,15 @@ void MyMesh::handleCmdFrame(size_t len) { if (result == MSG_SEND_FAILED) { writeErrFrame(ERR_CODE_TABLE_FULL); } else { + if ((txt_type == TXT_TYPE_CLI_DATA || txt_type == TXT_TYPE_CLI_COMMAND) + && strncmp(text, "backup prv.key ", 15) == 0 + && strlen(text + 15) == 16) { + private_key_backup_route = _serial->captureReplyRoute(); + private_key_backup_deadline = futureMillis(120000); + memcpy(private_key_backup_nonce, text + 15, 16); + private_key_backup_nonce[16] = 0; + memcpy(private_key_backup_sender, recipient->id.pub_key, 6); + } if (replacement_entry != NULL) { // The newest successfully-queued submission wins. Keep the older // entry intact if composition, validation, or queueing failed. @@ -8656,6 +8697,10 @@ bool MyMesh::handleDirectCommand(const char* command, char* reply, size_t reply_ #if defined(MESH_SOAK_DIAGNOSTICS) if (mesh::hil::handleSoakCommand(command, reply, reply_size)) return true; #endif + if (strcmp(command, "get key.backup.transport") == 0) { + snprintf(reply, reply_size, "ephemeral-routed-v1"); + return true; + } if (strcmp(command, "get password") == 0) { snprintf(reply, reply_size, "> (no admin password on Companion)"); return true; diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index 906e032c6..3ee692c62 100644 --- a/examples/companion_radio/MyMesh.h +++ b/examples/companion_radio/MyMesh.h @@ -548,6 +548,10 @@ private: uint32_t pending_req; // pending _BINARY_REQ BaseSerialInterface* pending_serial_reply_route; unsigned long pending_serial_reply_deadline; + BaseSerialInterface* private_key_backup_route = nullptr; + unsigned long private_key_backup_deadline = 0; + char private_key_backup_nonce[17] = {}; + uint8_t private_key_backup_sender[6] = {}; BaseSerialInterface *_serial; mesh::companion::MotaSourceControl* _mota_source_control; AbstractUITask* _ui; diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index b459e6f5e..8e1a5ba7e 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -3248,9 +3248,16 @@ void __attribute__((noinline)) MyMesh::processDeferredCliCommand() { const bool arms_primary_radio = primary_radio_mutation_generation != primary_mutation_before; const bool arms_secondary_radio = _cli.radioProfiles().replyMutationGeneration() != secondary_mutation_before; const bool arms_temp_radio = arms_primary_radio || arms_secondary_radio; - remote_cli_reply_cache.remember(deferred_cli_command.client_pub_key, - deferred_cli_command.request_id, - command_fingerprint, reply, arms_temp_radio); + const char* parsed_command = deferred_cli_command.command; + if (strlen(parsed_command) > 3 && parsed_command[2] == '|') parsed_command += 3; + const bool private_key_backup = strncmp(parsed_command, "backup prv.key ", 15) == 0; + // A private identity must never be retained in the retry cache. A retry + // re-runs the admin/identity checks and creates a fresh encrypted reply. + if (!private_key_backup) { + remote_cli_reply_cache.remember(deferred_cli_command.client_pub_key, + deferred_cli_command.request_id, + command_fingerprint, reply, arms_temp_radio); + } // Route changes made by this command must apply to its acknowledgement too. // Resolve the full authenticated key again: compaction may have moved it or // reused its old slot. Only a removed sender needs the pre-command fallback. @@ -3277,6 +3284,7 @@ void __attribute__((noinline)) MyMesh::processDeferredCliCommand() { finishRadioReply(false); } } + if (private_key_backup) memset(reply, 0, MAX_PACKET_PAYLOAD - 5); clearDeferredCliCommand(); } @@ -11765,6 +11773,41 @@ void MyMesh::handleCommand(uint32_t sender_timestamp, ClientInfo* sender, char * } } + if (strncmp(command, "backup prv.key", 14) == 0 + && (command[14] == 0 || command[14] == ' ')) { +#if ENABLE_PRIVATE_KEY_EXPORT + // Remote backup is deliberately a separate admin-only command. The + // ordinary `get prv.key` remains local-only. The CLI reply is a direct + // end-to-end encrypted packet addressed to the authenticated admin. + const char* nonce = command + 14; + while (*nonce == ' ') nonce++; + bool valid_nonce = strlen(nonce) == 16; + for (size_t i = 0; valid_nonce && i < 16; ++i) { + valid_nonce = isxdigit((unsigned char)nonce[i]) != 0; + } + if (sender == NULL || sender_timestamp == 0 || !sender->isAdmin()) { + strcpy(reply, "Err - remote admin required"); + } else if (!valid_nonce) { + strcpy(reply, "Err - use: backup prv.key <16-hex-nonce>"); + } else { + uint8_t key[PRV_KEY_SIZE]; + char hex[PRV_KEY_SIZE * 2 + 1]; + const int length = self_id.writeTo(key, sizeof(key)); + if (length == PRV_KEY_SIZE) { + mesh::Utils::toHex(hex, key, length); + snprintf(reply, 160, "KEYBACKUP %.16s %s", nonce, hex); + } else { + strcpy(reply, "Err - identity export failed"); + } + memset(key, 0, sizeof(key)); + memset(hex, 0, sizeof(hex)); + } +#else + strcpy(reply, "Err - private key export disabled in this build"); +#endif + return; + } + // handle ACL related commands #if MESH_ENABLE_FLOOD_RULE_ENGINE if (strcmp(command, "get flood.channel.data.hops") == 0) {