Add rak4631_lockdown hardened build variant

Introduces a new RAK4631 build variant (-DMESHTASTIC_LOCKDOWN=1) with
encrypted flash storage, BLE/USB config access control, and hardened
defaults.

New files:
- variants/nrf52840/rak4631_lockdown/ — PlatformIO env, variant header
- src/security/EncryptedStorage.h/.cpp — AES-128-CTR + HMAC-SHA256
  encrypted proto storage using CC310 hardware, passphrase-gated DEK,
  TTL/boot-count unlock token, backoff on failed attempts
- src/security/APProtect.h/.cpp — UICR APPROTECT (SWD lockout)

Modified files:
- configuration.h — MESHTASTIC_LOCKDOWN flag derives
  PHONEAPI_ACCESS_CONTROL, ENCRYPTED_STORAGE, ENABLE_APPROTECT,
  HARDENED_DEFAULTS (nRF52 only)
- NodeDB.cpp/.h — encrypted loadProto/saveProto, locked-boot early
  return, migration of plaintext files to MENC, hardened defaults,
  saveToDiskNoRetry guard prevents FSCom.format() when storage locked
- PhoneAPI.cpp/.h — per-connection auth reset, redacts channel PSKs
  and security keys for unauthorized clients, revokeAllAuth() for
  Lock Now, post-config TAK_LOCKED/TAK_NEEDS_PROVISION notification
- AdminModule.cpp — passphrase delivery via set_config(security),
  provision/unlock/re-verify flow, Lock Now sentinel (0xFF),
  PKC admin auth callback to PhoneAPI
- PowerFSM.cpp — guard serialEnter() BLE disable on nRF52
This commit is contained in:
niccellular
2026-02-26 22:15:40 -05:00
parent 02d42f87d3
commit fd7fc40255
14 changed files with 2294 additions and 16 deletions
+169 -14
View File
@@ -3,6 +3,9 @@
#include "GPS.h"
#endif
#ifdef MESHTASTIC_ENCRYPTED_STORAGE
#include "security/EncryptedStorage.h"
#endif
#include "Channels.h"
#include "Default.h"
#include "FSCommon.h"
@@ -54,6 +57,14 @@ void PhoneAPI::handleStartConfig()
observe(&service->fromNumChanged);
#ifdef FSCom
observe(&xModem.packetReady);
#endif
#ifdef MESHTASTIC_PHONEAPI_ACCESS_CONTROL
// New physical connection: reset auth so the client must present the passphrase.
// Do NOT reset here on re-requests (want_config_id sent again within the same
// connection after auth) — that would strip auth from a client who just unlocked
// and is re-fetching the full unredacted config.
isAdminAuthorized = false;
s_localAdminAuthorized = false;
#endif
}
@@ -127,6 +138,10 @@ void PhoneAPI::close()
config_state = 0;
pauseBluetoothLogging = false;
heartbeatReceived = false;
#ifdef MESHTASTIC_PHONEAPI_ACCESS_CONTROL
isAdminAuthorized = false;
s_localAdminAuthorized = false;
#endif
}
}
@@ -155,6 +170,21 @@ bool PhoneAPI::handleToRadio(const uint8_t *buf, size_t bufLength)
if (pb_decode_from_bytes(buf, bufLength, &meshtastic_ToRadio_msg, &toRadioScratch)) {
switch (toRadioScratch.which_payload_variant) {
case meshtastic_ToRadio_packet_tag:
#ifdef MESHTASTIC_PHONEAPI_ACCESS_CONTROL
if (!isAdminAuthorized && !s_localAdminAuthorized) {
// Allow admin messages addressed to this device — passphrase delivery must get through.
// AdminModule handles its own is_managed gate for those.
// Block everything else — unauthorized clients cannot inject mesh traffic.
// Require the packet to carry a decoded (not encrypted) payload so portnum is valid.
bool isLocalAdmin = toRadioScratch.packet.which_payload_variant == meshtastic_MeshPacket_decoded_tag &&
toRadioScratch.packet.decoded.portnum == meshtastic_PortNum_ADMIN_APP &&
toRadioScratch.packet.to == nodeDB->getNodeNum();
if (!isLocalAdmin) {
LOG_INFO("TAK: Dropping non-admin ToRadio packet from unauthorized client");
return false;
}
}
#endif
return handleToRadioPacket(toRadioScratch.packet);
case meshtastic_ToRadio_want_config_id_tag:
config_nonce = toRadioScratch.want_config_id;
@@ -166,6 +196,12 @@ bool PhoneAPI::handleToRadio(const uint8_t *buf, size_t bufLength)
close();
break;
case meshtastic_ToRadio_xmodemPacket_tag:
#ifdef MESHTASTIC_PHONEAPI_ACCESS_CONTROL
if (!isAdminAuthorized && !s_localAdminAuthorized) {
LOG_INFO("TAK: Dropping xmodem packet from unauthorized client");
break;
}
#endif
LOG_INFO("Got xmodem packet");
#ifdef FSCom
xModem.handlePacket(toRadioScratch.xmodemPacket);
@@ -286,8 +322,15 @@ size_t PhoneAPI::getFromRadio(uint8_t *buf)
}
if (config_nonce == SPECIAL_NONCE_ONLY_NODES) {
// If client only wants node info, jump directly to sending nodes
state = STATE_SEND_OTHER_NODEINFOS;
onNowHasData(0);
#ifdef MESHTASTIC_PHONEAPI_ACCESS_CONTROL
if (!isAdminAuthorized && !s_localAdminAuthorized) {
state = STATE_SEND_COMPLETE_ID; // Unauthorized: skip node DB
} else
#endif
{
state = STATE_SEND_OTHER_NODEINFOS;
onNowHasData(0);
}
} else {
state = STATE_SEND_METADATA;
}
@@ -303,7 +346,17 @@ size_t PhoneAPI::getFromRadio(uint8_t *buf)
case STATE_SEND_CHANNELS:
fromRadioScratch.which_payload_variant = meshtastic_FromRadio_channel_tag;
fromRadioScratch.channel = channels.getByIndex(config_state);
#ifdef MESHTASTIC_PHONEAPI_ACCESS_CONTROL
if (!isAdminAuthorized && !s_localAdminAuthorized) {
// Send channel structure but zero out PSK to prevent key extraction
fromRadioScratch.channel = channels.getByIndex(config_state);
memset(fromRadioScratch.channel.settings.psk.bytes, 0, sizeof(fromRadioScratch.channel.settings.psk.bytes));
fromRadioScratch.channel.settings.psk.size = 0;
} else
#endif
{
fromRadioScratch.channel = channels.getByIndex(config_state);
}
config_state++;
// Advance when we have sent all of our Channels
if (config_state >= MAX_NUM_CHANNELS) {
@@ -336,6 +389,13 @@ size_t PhoneAPI::getFromRadio(uint8_t *buf)
LOG_DEBUG("Send config: network");
fromRadioScratch.config.which_payload_variant = meshtastic_Config_network_tag;
fromRadioScratch.config.payload_variant.network = config.network;
#ifdef MESHTASTIC_PHONEAPI_ACCESS_CONTROL
if (!isAdminAuthorized && !s_localAdminAuthorized) {
// Redact wifi PSK
memset(fromRadioScratch.config.payload_variant.network.wifi_psk, 0,
sizeof(fromRadioScratch.config.payload_variant.network.wifi_psk));
}
#endif
break;
case meshtastic_Config_display_tag:
LOG_DEBUG("Send config: display");
@@ -356,6 +416,30 @@ size_t PhoneAPI::getFromRadio(uint8_t *buf)
LOG_DEBUG("Send config: security");
fromRadioScratch.config.which_payload_variant = meshtastic_Config_security_tag;
fromRadioScratch.config.payload_variant.security = config.security;
#ifdef MESHTASTIC_PHONEAPI_ACCESS_CONTROL
if (!isAdminAuthorized && !s_localAdminAuthorized) {
// Redact private key
memset(fromRadioScratch.config.payload_variant.security.private_key.bytes, 0,
sizeof(fromRadioScratch.config.payload_variant.security.private_key.bytes));
fromRadioScratch.config.payload_variant.security.private_key.size = 0;
// Redact admin keys
for (int i = 0; i < 3; i++) {
memset(fromRadioScratch.config.payload_variant.security.admin_key[i].bytes, 0,
sizeof(fromRadioScratch.config.payload_variant.security.admin_key[i].bytes));
fromRadioScratch.config.payload_variant.security.admin_key[i].size = 0;
}
#ifdef MESHTASTIC_ENCRYPTED_STORAGE
// Signal provisioning state via admin_key[0] so the client knows
// whether to show "Set passphrase" vs "Enter passphrase":
// admin_key[0].size == 0 → not provisioned (first time setup)
// admin_key[0].size == 1, bytes[0] == 0x01 → provisioned, locked
if (EncryptedStorage::isProvisioned()) {
fromRadioScratch.config.payload_variant.security.admin_key[0].bytes[0] = 0x01;
fromRadioScratch.config.payload_variant.security.admin_key[0].size = 1;
}
#endif
}
#endif
break;
case meshtastic_Config_sessionkey_tag:
LOG_DEBUG("Send config: sessionkey");
@@ -454,6 +538,12 @@ size_t PhoneAPI::getFromRadio(uint8_t *buf)
config_state++;
// Advance when we have sent all of our ModuleConfig objects
if (config_state > (_meshtastic_AdminMessage_ModuleConfigType_MAX + 1)) {
#ifdef MESHTASTIC_PHONEAPI_ACCESS_CONTROL
if (!isAdminAuthorized && !s_localAdminAuthorized) {
// Unauthorized client: skip node DB and file manifest — only send config complete
state = STATE_SEND_COMPLETE_ID;
} else
#endif
// Handle special nonce behaviors:
// - SPECIAL_NONCE_ONLY_CONFIG: Skip node info, go directly to file manifest
// - SPECIAL_NONCE_ONLY_NODES: After sending nodes, skip to complete
@@ -544,24 +634,46 @@ size_t PhoneAPI::getFromRadio(uint8_t *buf)
fromRadioScratch.queueStatus = *queueStatusPacketForPhone;
releaseQueueStatusPhonePacket();
} else if (mqttClientProxyMessageForPhone) {
fromRadioScratch.which_payload_variant = meshtastic_FromRadio_mqttClientProxyMessage_tag;
fromRadioScratch.mqttClientProxyMessage = *mqttClientProxyMessageForPhone;
releaseMqttClientProxyPhonePacket();
#ifdef MESHTASTIC_PHONEAPI_ACCESS_CONTROL
if (!isAdminAuthorized && !s_localAdminAuthorized) {
releaseMqttClientProxyPhonePacket(); // Discard — unauthorized client
} else
#endif
{
fromRadioScratch.which_payload_variant = meshtastic_FromRadio_mqttClientProxyMessage_tag;
fromRadioScratch.mqttClientProxyMessage = *mqttClientProxyMessageForPhone;
releaseMqttClientProxyPhonePacket();
}
} else if (xmodemPacketForPhone.control != meshtastic_XModem_Control_NUL) {
fromRadioScratch.which_payload_variant = meshtastic_FromRadio_xmodemPacket_tag;
fromRadioScratch.xmodemPacket = xmodemPacketForPhone;
xmodemPacketForPhone = meshtastic_XModem_init_zero;
#ifdef MESHTASTIC_PHONEAPI_ACCESS_CONTROL
if (!isAdminAuthorized && !s_localAdminAuthorized) {
xmodemPacketForPhone = meshtastic_XModem_init_zero; // Discard — unauthorized client
} else
#endif
{
fromRadioScratch.which_payload_variant = meshtastic_FromRadio_xmodemPacket_tag;
fromRadioScratch.xmodemPacket = xmodemPacketForPhone;
xmodemPacketForPhone = meshtastic_XModem_init_zero;
}
} else if (clientNotification) {
// Always deliver clientNotification — required for TAK_UNLOCKED/TAK_LOCKED to reach client
fromRadioScratch.which_payload_variant = meshtastic_FromRadio_clientNotification_tag;
fromRadioScratch.clientNotification = *clientNotification;
releaseClientNotification();
} else if (packetForPhone) {
printPacket("phone downloaded packet", packetForPhone);
#ifdef MESHTASTIC_PHONEAPI_ACCESS_CONTROL
if (!isAdminAuthorized && !s_localAdminAuthorized) {
releasePhonePacket(); // Discard mesh traffic — unauthorized client
} else
#endif
{
printPacket("phone downloaded packet", packetForPhone);
// Encapsulate as a FromRadio packet
fromRadioScratch.which_payload_variant = meshtastic_FromRadio_packet_tag;
fromRadioScratch.packet = *packetForPhone;
releasePhonePacket();
// Encapsulate as a FromRadio packet
fromRadioScratch.which_payload_variant = meshtastic_FromRadio_packet_tag;
fromRadioScratch.packet = *packetForPhone;
releasePhonePacket();
}
}
break;
@@ -604,6 +716,28 @@ void PhoneAPI::sendConfigComplete()
service->api_state = service->STATE_ETH;
}
#ifdef MESHTASTIC_ENCRYPTED_STORAGE
// Immediately after config_complete_id, notify the client if it hasn't authenticated.
// Gate on client auth state (not storage lock state) — storage may be auto-unlocked via
// boot token, but each new BLE/USB connection must still present the passphrase.
if (!isAdminAuthorized && !s_localAdminAuthorized) {
char msg[48];
if (!EncryptedStorage::isProvisioned()) {
strncpy(msg, "TAK_NEEDS_PROVISION", sizeof(msg));
} else {
// Provisioned: client must authenticate. Use lock reason if storage is hard-locked,
// otherwise signal that passphrase auth is required for this connection.
if (!EncryptedStorage::isUnlocked()) {
snprintf(msg, sizeof(msg), "TAK_LOCKED:%s", EncryptedStorage::getLockReason());
} else {
strncpy(msg, "TAK_LOCKED:needs_auth", sizeof(msg));
}
}
sendNotification(meshtastic_LogRecord_Level_WARNING, 0, msg);
LOG_INFO("PhoneAPI: sent %s to client", msg);
}
#endif
// Allow subclasses to know we've entered steady-state so they can lower power consumption
onConfigComplete();
@@ -842,3 +976,24 @@ int PhoneAPI::onNotify(uint32_t newValue)
return timeout ? -1 : 0; // If we timed out, MeshService should stop iterating through observers as we just removed one
}
#ifdef MESHTASTIC_PHONEAPI_ACCESS_CONTROL
bool PhoneAPI::s_localAdminAuthorized = false;
void PhoneAPI::authorizeLocalAdmin()
{
s_localAdminAuthorized = true;
LOG_INFO("TAK: Local admin authorized via PKC");
}
bool PhoneAPI::isLocalAdminAuthorized()
{
return s_localAdminAuthorized;
}
void PhoneAPI::revokeAllAuth()
{
s_localAdminAuthorized = false;
LOG_INFO("TAK: All connection auth revoked (Lock Now)");
}
#endif