Protect remote admin identity backup replies
This commit is contained in:
1 parent
49d4cc3f3e
commit
0ad51a29cb
4 files changed
+110
-4
No files matched your search
+15
-1
@@ -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
|
||||
|
||||
---
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user