Protect remote admin identity backup replies

This commit is contained in:
mikecarper committed 2026-09-26 18:01:43 -07:00
1 parent 49d4cc3f3e
commit 0ad51a29cb
4 files changed
+110 -4

No files matched your search

+15 -1
View File
@@ -1744,12 +1744,26 @@ for the tested XIAO handoff and host-cache caveats.
- `set prv.key <private_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 <nonce> <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
---
+45
View File
@@ -9,6 +9,7 @@
#endif
#include <Arduino.h> // needed for PlatformIO
#include <ctype.h>
#ifdef ENABLE_WIFI_INTERFACE
#include <WiFi.h>
#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;
+4
View File
@@ -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;
+46 -3
View File
@@ -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) {