Compare commits

...
Author SHA1 Message Date
Ben MeadorsandGitHub 38c4001f52 Merge branch 'develop' into feature/rak4631-lockdown 2026-03-02 06:40:58 -06:00
niccellular 067d6834c7 fix audit findings from Opus 4.6 specifically looking for race conditions and logic bugs
Three-agent security audit of the LOCKDOWN build identified race conditions,
  incomplete key zeroing, and missing memory hygiene. All findings except HIGH-1
  are addressed here. HIGH-1 (revokeAllAuth not resetting per-instance
  isAdminAuthorized) was reviewed and closed as wontfix: PKC admin key holders
  have persistent access regardless, so the 7-second pre-reboot window changes
  nothing about the threat model.

  HIGH-2: lockNow() previously only zeroed dek[]. Now also zeroes kek[],
  ephemeralKek[], and their derived flags so no key material survives a lock
  event in a RAM dump.

  HIGH-3: isAdminAuthorized (per-instance) and s_localAdminAuthorized (static)
  changed from plain bool to std::atomic<bool>. Both flags are written from the
  NimBLE FreeRTOS task and read from the main loop — non-atomic reads were a
  data race.

  MED-1: readAndDecrypt() and encryptAndWrite() now snapshot dek[] into a stack-
  local dekSnapshot[] on entry before acquiring any lock. A concurrent lockNow()
  that zeros dek[] mid-operation either zeroes the snapshot (HMAC fails securely)
  or has no effect on an already-captured copy. dekSnapshot is zeroed on all exit
  paths.

  MED-2: close() and handleStartConfig() no longer clear s_localAdminAuthorized.
  Resetting it on every disconnect was an auth DoS — any new client connection
  would silently revoke the passphrase-authenticated state, forcing the operator
  to re-enter the passphrase on every reconnect. Device-level auth now persists
  until explicit Lock Now (revokeAllAuth()).

  MED-3: readAndDecrypt() scoped the spiLock LockGuard to a nested block
  covering only the FSCom.open/read/close sequence. The lock is released before
  any HMAC or AES-CTR work. spiLock is a non-recursive binary semaphore;
  holding it across CC310 calls created a re-entry deadlock risk.

  MED-4: AdminModule zeros the passphrase bytes in toRadioScratch via a volatile
  pointer cast before returning from both the Lock Now and provision/unlock
  paths. Prevents the passphrase lingering in the scratch buffer until the next
  ToRadio message overwrites it.

  MED-5: readAndDecrypt() was missing memset(fileBuf) on the hmacData OOM early-
  exit path, leaving ciphertext in the heap until the allocator reuses it.

  MED-6: readAndDecrypt() now sets outLen=0 on entry so callers never observe a
  stale length value on any early failure return.

  MED-7: readAndDecrypt() calls memset(outBuf, 0, ciphertextLen) on AES-CTR
  failure to clear partial plaintext that may have been written to the caller's
  buffer before the error was detected.

  MED-8: Passphrase length limit corrected from 64 to 32 bytes throughout
  (EncryptedStorage.cpp provisionPassphrase/unlockWithPassphrase, AdminModule.cpp
  ppLen range check, EncryptedStorage.h doc comments). The proto private_key field
  is 32 bytes; accepting 33-64 silently failed inside EncryptedStorage while
  appearing to succeed at the AdminModule gate.

  L-1: readAndDecrypt() now rejects files larger than 65536+54 bytes before
  allocating the heap buffer, preventing OOM or integer overflow on corrupt or
  attacker-supplied oversized files.

  L-2: migrateFile() has a precondition comment that spiLock must not be held
  by the caller; both isEncrypted() and encryptAndWrite() acquire it internally.
2026-02-28 11:57:47 -05:00
niccellularandClaude Sonnet 4.6 3f9d76f054 addressing copilot comments
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-27 16:35:18 -05:00
niccellular 545ae99ff8 Merge branch 'develop' of https://github.com/meshtastic/firmware into develop 2026-02-27 08:00:19 -05:00
niccellular fd7fc40255 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
2026-02-26 22:15:40 -05:00
14 changed files with 2535 additions and 16 deletions
+4
View File
@@ -182,7 +182,11 @@ static void darkEnter()
static void serialEnter()
{
LOG_POWERFSM("State: serialEnter");
#ifndef ARCH_NRF52
// nRF52 runs BLE on SoftDevice independently of USB serial — no need to disable it.
// (Same rationale as nbEnter() which already guards this with #ifdef ARCH_ESP32)
setBluetoothEnable(false);
#endif
if (screen) {
screen->setOn(true);
}
+64
View File
@@ -528,5 +528,69 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
#define HAS_SCREEN 0
#endif
// -----------------------------------------------------------------------------
// MESHTASTIC_LOCKDOWN — combined hardened build flag
//
// All targets:
// DEBUG_MUTE — suppress serial/USB log output
// MESHTASTIC_EXCLUDE_* — strip non-essential modules
// MESHTASTIC_PHONEAPI_ACCESS_CONTROL — redact keys from unauthenticated BLE/USB/TCP clients
// HARDENED_DEFAULTS — set is_managed, RANDOM_PIN, TAK role at first boot
//
// nRF52 (CC310 hardware crypto):
// MESHTASTIC_ENCRYPTED_STORAGE — AES-128-CTR + HMAC-SHA256 at-rest encryption
// MESHTASTIC_ENABLE_APPROTECT — write UICR APPROTECT to block debug probe
//
// ESP32 (stub — mbedTLS crypto TODO):
// MESHTASTIC_ENCRYPTED_STORAGE — same API, plaintext pass-through until ported
// APPROTECT equivalent — burn JTAG_DISABLE + flash encryption via espsecure.py
// -----------------------------------------------------------------------------
#ifdef MESHTASTIC_LOCKDOWN
// Suppress all serial/USB-CDC log output — prevents info leakage via physical access
#define DEBUG_MUTE
// Strip non-essential modules to reduce attack surface
#define MESHTASTIC_EXCLUDE_DETECTIONSENSOR 1
#define MESHTASTIC_EXCLUDE_ENVIRONMENTAL_SENSOR 1
#define MESHTASTIC_EXCLUDE_AIR_QUALITY 1
#define MESHTASTIC_EXCLUDE_HEALTH_TELEMETRY 1
#define MESHTASTIC_EXCLUDE_POWER_TELEMETRY 1
#define MESHTASTIC_EXCLUDE_RANGETEST 1
#define MESHTASTIC_EXCLUDE_REMOTEHARDWARE 1
#define MESHTASTIC_EXCLUDE_STOREFORWARD 1
#define MESHTASTIC_EXCLUDE_CANNEDMESSAGES 1
#define MESHTASTIC_EXCLUDE_SERIAL 1
#define MESHTASTIC_EXCLUDE_POWERSTRESS 1
#define MESHTASTIC_EXCLUDE_DROPZONE 1
#define MESHTASTIC_EXCLUDE_REPLYBOT 1
#define MESHTASTIC_EXCLUDE_PAXCOUNTER 1
#define MESHTASTIC_EXCLUDE_AUDIO 1
#define MESHTASTIC_EXCLUDE_STATUS 1
#define MESHTASTIC_EXCLUDE_GENERIC_THREAD_MODULE 1
#define MESHTASTIC_EXCLUDE_POWERMON 1
#define MESHTASTIC_EXCLUDE_EXTERNALNOTIFICATION 1
// Access control and hardened defaults are pure C++ — no platform-specific deps.
#define MESHTASTIC_PHONEAPI_ACCESS_CONTROL 1
#define HARDENED_DEFAULTS 1
// Platform-specific security features:
#if defined(ARCH_NRF52)
// Full encrypted storage via CC310 hardware AES-128-CTR + HMAC-SHA256.
#define MESHTASTIC_ENCRYPTED_STORAGE 1
// Hardware JTAG/SWD lockout: firmware writes NRF_UICR->APPROTECT = 0x00 once.
#define MESHTASTIC_ENABLE_APPROTECT 1
#elif defined(ARCH_ESP32)
// Encrypted storage stub: plaintext pass-through, same API, crypto TODO (mbedTLS).
// APPROTECT equivalent: burn JTAG_DISABLE eFuse and enable flash encryption
// via espsecure.py at provisioning time — not firmware-controlled.
#define MESHTASTIC_ENCRYPTED_STORAGE 1
#else
#warning "MESHTASTIC_LOCKDOWN: encrypted storage not available on this platform; access control and hardened defaults still active"
#endif
#endif // MESHTASTIC_LOCKDOWN
#include "DebugConfiguration.h"
#include "RF95Configuration.h"
+22
View File
@@ -59,6 +59,13 @@ NimbleBluetooth *nimbleBluetooth = nullptr;
NRF52Bluetooth *nrf52Bluetooth = nullptr;
#endif
#ifdef MESHTASTIC_ENABLE_APPROTECT
#include "security/APProtect.h"
#endif
#ifdef MESHTASTIC_ENCRYPTED_STORAGE
#include "security/EncryptedStorage.h"
#endif
#if HAS_WIFI || defined(USE_WS5500)
#include "mesh/api/WiFiServerAPI.h"
#include "mesh/wifi/WiFiAPClient.h"
@@ -364,6 +371,10 @@ void setup()
consoleInit(); // Set serial baud rate and init our mesh console
#endif
#ifdef MESHTASTIC_ENABLE_APPROTECT
enableAPProtect();
#endif
#ifdef UNPHONE
unphone.printStore();
#endif
@@ -461,6 +472,17 @@ void setup()
fsInit();
#ifdef MESHTASTIC_ENCRYPTED_STORAGE
EncryptedStorage::initLocked();
if (!EncryptedStorage::isUnlocked()) {
if (!EncryptedStorage::isProvisioned()) {
LOG_WARN("TAK: Device not provisioned — connect and set a passphrase to unlock storage");
} else {
LOG_WARN("TAK: Device locked — connect and provide passphrase to unlock storage");
}
}
#endif
#if !MESHTASTIC_EXCLUDE_I2C
#if defined(I2C_SDA1) && defined(ARCH_RP2040)
Wire1.setSDA(I2C_SDA1);
+147 -1
View File
@@ -30,6 +30,10 @@
#include <power/PowerHAL.h>
#include <vector>
#ifdef MESHTASTIC_ENCRYPTED_STORAGE
#include "security/EncryptedStorage.h"
#endif
#ifdef ARCH_ESP32
#if HAS_WIFI
#include "mesh/wifi/WiFiAPClient.h"
@@ -645,6 +649,20 @@ void NodeDB::installDefaultConfig(bool preserveKey = false)
config.security.private_key.size = 0;
}
config.security.public_key.size = 0;
#ifdef HARDENED_DEFAULTS
config.security.serial_enabled = false; // Disable USB serial PhoneAPI — no auth, physical access only
config.security.debug_log_api_enabled = false; // No debug log leaks
config.security.admin_channel_enabled = false; // No legacy admin channel
config.security.is_managed = true; // Block all from=0 admin when unlocked;
// operator must use PKC admin key to configure.
// Passphrase delivery still works when locked
// (AdminModule special-cases !isUnlocked()).
config.device.rebroadcast_mode = meshtastic_Config_DeviceConfig_RebroadcastMode_LOCAL_ONLY;
// FIXED_PIN allows the operator to connect via BLE and provision the passphrase.
config.bluetooth.mode = meshtastic_Config_BluetoothConfig_PairingMode_FIXED_PIN;
#endif
#ifdef PIN_GPS_EN
config.position.gps_en_gpio = PIN_GPS_EN;
#endif
@@ -710,7 +728,9 @@ void NodeDB::installDefaultConfig(bool preserveKey = false)
#ifdef USERPREFS_FIXED_BLUETOOTH
config.bluetooth.fixed_pin = USERPREFS_FIXED_BLUETOOTH;
config.bluetooth.mode = meshtastic_Config_BluetoothConfig_PairingMode_FIXED_PIN;
#else
#elif !defined(HARDENED_DEFAULTS)
// Default: use RANDOM_PIN only if device has a screen (to display the PIN).
// set FIXED_PIN in the HARDENED_DEFAULTS block above.
config.bluetooth.mode = hasScreen ? meshtastic_Config_BluetoothConfig_PairingMode_RANDOM_PIN
: meshtastic_Config_BluetoothConfig_PairingMode_FIXED_PIN;
#endif
@@ -1148,6 +1168,37 @@ LoadFileResult NodeDB::loadProto(const char *filename, size_t protoSize, size_t
void *dest_struct)
{
LoadFileResult state = LoadFileResult::OTHER_FAILURE;
#ifdef MESHTASTIC_ENCRYPTED_STORAGE
// check if the file is encrypted and decrypt before protobuf decode
if (EncryptedStorage::isEncrypted(filename)) {
uint8_t *decBuf = new uint8_t[protoSize];
if (!decBuf) {
LOG_ERROR("OOM decrypting %s", filename);
return LoadFileResult::OTHER_FAILURE;
}
size_t decLen = 0;
if (EncryptedStorage::readAndDecrypt(filename, decBuf, protoSize, decLen)) {
LOG_INFO("Load encrypted %s", filename);
pb_istream_t stream = pb_istream_from_buffer(decBuf, decLen);
if (fields != &meshtastic_NodeDatabase_msg)
memset(dest_struct, 0, objSize);
if (!pb_decode(&stream, fields, dest_struct)) {
LOG_ERROR("Error: can't decode protobuf %s", PB_GET_ERROR(&stream));
state = LoadFileResult::DECODE_FAILED;
} else {
LOG_INFO("Loaded encrypted %s successfully", filename);
state = LoadFileResult::LOAD_SUCCESS;
}
} else {
LOG_ERROR("Decrypt failed for %s, treating as corrupt", filename);
state = LoadFileResult::DECODE_FAILED;
}
delete[] decBuf;
return state;
}
#endif
#ifdef FSCom
concurrency::LockGuard g(spiLock);
@@ -1224,6 +1275,21 @@ void NodeDB::loadFromDisk()
}
#endif
#ifdef MESHTASTIC_ENCRYPTED_STORAGE
if (!EncryptedStorage::isUnlocked()) {
// Encrypted storage is locked — install defaults and wait for passphrase via BLE/serial.
// reloadFromDisk() will be called by AdminModule once the device is unlocked.
LOG_WARN("NodeDB: Encrypted storage locked, using default config until unlocked");
installDefaultNodeDatabase();
installDefaultDeviceState();
installDefaultConfig();
installDefaultModuleConfig();
installDefaultChannels();
return;
}
#endif
auto state = loadProto(nodeDatabaseFileName, getMaxNodesAllocatedSize(), sizeof(meshtastic_NodeDatabase),
&meshtastic_NodeDatabase_msg, &nodeDatabase);
if (nodeDatabase.version < DEVICESTATE_MIN_VER) {
@@ -1385,6 +1451,35 @@ void NodeDB::loadFromDisk()
LOG_INFO("Loaded UIConfig");
}
#ifdef MESHTASTIC_ENCRYPTED_STORAGE
// Ensure all config segments are persisted to encrypted storage.
// installDefaultConfig/installDefaultModuleConfig only set in-memory structs
// without saving to disk, so we force a save here to ensure encrypted files exist.
{
const char *filesToCheck[] = {configFileName, moduleConfigFileName, channelFileName, deviceStateFileName,
nodeDatabaseFileName};
int segments[] = {SEGMENT_CONFIG, SEGMENT_MODULECONFIG, SEGMENT_CHANNELS, SEGMENT_DEVICESTATE, SEGMENT_NODEDATABASE};
int toSave = 0;
for (int i = 0; i < 5; i++) {
if (!EncryptedStorage::isEncrypted(filesToCheck[i])) {
toSave |= segments[i];
}
}
if (toSave) {
LOG_INFO("TAK: Saving unencrypted segments to encrypted storage (mask=0x%x)", toSave);
saveToDisk(toSave);
}
// Migrate any remaining plaintext proto files (from standard firmware upgrade)
for (const char *fn : filesToCheck) {
if (!EncryptedStorage::isEncrypted(fn)) {
LOG_INFO("Migrating %s to encrypted storage", fn);
EncryptedStorage::migrateFile(fn);
}
}
}
#endif
// 2.4.X - configuration migration to update new default intervals
if (moduleConfig.version < 23) {
LOG_DEBUG("ModuleConfig version %d is stale, upgrading to new default intervals", moduleConfig.version);
@@ -1420,8 +1515,21 @@ void NodeDB::loadFromDisk()
}
#endif
}
#ifdef MESHTASTIC_ENCRYPTED_STORAGE
/**
* Re-run loadFromDisk() after encrypted storage is unlocked at runtime.
* Called by AdminModule after a successful provisionPassphrase / unlockWithPassphrase.
*/
void NodeDB::reloadFromDisk()
{
LOG_INFO("NodeDB: Reloading config from encrypted storage after unlock");
loadFromDisk();
}
#endif
/** Save a protobuf from a file, return true for success */
bool NodeDB::saveProto(const char *filename, size_t protoSize, const pb_msgdesc_t *fields, const void *dest_struct,
bool fullAtomic)
@@ -1434,6 +1542,34 @@ bool NodeDB::saveProto(const char *filename, size_t protoSize, const pb_msgdesc_
return false;
}
#ifdef MESHTASTIC_ENCRYPTED_STORAGE
// Encrypt all files except uiconfig (no secrets) and the DEK file (self-encrypted)
if (strcmp(filename, uiconfigFileName) != 0) {
// First encode protobuf to a temporary buffer
uint8_t *pbBuf = new uint8_t[protoSize];
if (!pbBuf) {
LOG_ERROR("OOM encoding %s for encryption", filename);
return false;
}
pb_ostream_t stream = pb_ostream_from_buffer(pbBuf, protoSize);
if (!pb_encode(&stream, fields, dest_struct)) {
LOG_ERROR("Error: can't encode protobuf %s", PB_GET_ERROR(&stream));
delete[] pbBuf;
return false;
}
size_t encodedSize = stream.bytes_written;
bool ok = EncryptedStorage::encryptAndWrite(filename, pbBuf, encodedSize, fullAtomic);
delete[] pbBuf;
if (!ok) {
LOG_ERROR("EncryptedStorage: Failed to encrypt and write %s", filename);
}
return ok;
}
#endif
bool okay = false;
#ifdef FSCom
auto f = SafeFile(filename, fullAtomic);
@@ -1526,6 +1662,16 @@ bool NodeDB::saveToDiskNoRetry(int saveWhat)
return false;
}
#ifdef MESHTASTIC_ENCRYPTED_STORAGE
// When encrypted storage is locked, encryptAndWrite() returns false for every file.
// That would cause saveToDisk()'s nRF52 retry path to call FSCom.format(), wiping all
// encrypted proto files from flash. Return true here — "nothing to save, not an error."
if (!EncryptedStorage::isUnlocked()) {
LOG_WARN("NodeDB: saveToDisk skipped — encrypted storage locked");
return true;
}
#endif
bool success = true;
#ifdef FSCom
spiLock->lock();
+6
View File
@@ -307,6 +307,12 @@ class NodeDB
newStatus.notifyObservers(&status);
}
#ifdef MESHTASTIC_ENCRYPTED_STORAGE
/// Re-run loadFromDisk() after the encrypted storage is unlocked at runtime.
/// Called by AdminModule after a successful provisionPassphrase / unlockWithPassphrase.
void reloadFromDisk();
#endif
private:
bool duplicateWarned = false;
bool localPositionUpdatedSinceBoot = false;
+175 -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,16 @@ void PhoneAPI::handleStartConfig()
observe(&service->fromNumChanged);
#ifdef FSCom
observe(&xModem.packetReady);
#endif
#ifdef MESHTASTIC_PHONEAPI_ACCESS_CONTROL
// New physical connection: reset per-connection PKC auth.
// 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;
// MED-2: s_localAdminAuthorized (device-level passphrase auth) is intentionally
// NOT reset here. It persists until an explicit Lock Now (revokeAllAuth()).
// Resetting it on every new connection would cause an auth DoS.
#endif
}
@@ -127,6 +140,13 @@ void PhoneAPI::close()
config_state = 0;
pauseBluetoothLogging = false;
heartbeatReceived = false;
#ifdef MESHTASTIC_PHONEAPI_ACCESS_CONTROL
isAdminAuthorized = false;
// MED-2: do NOT reset s_localAdminAuthorized on disconnect.
// It represents device-level passphrase auth and must persist across connections
// until an explicit Lock Now (revokeAllAuth()). Resetting it here would revoke
// any passphrase-authenticated session the moment a second client connects.
#endif
}
}
@@ -155,6 +175,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 +201,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 +327,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 +351,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 +394,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 +421,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 +543,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 +639,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 +721,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 +981,25 @@ 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
// HIGH-3: static atomic so BLE-task writes and main-loop reads are race-free.
std::atomic<bool> PhoneAPI::s_localAdminAuthorized{false};
void PhoneAPI::authorizeLocalAdmin()
{
s_localAdminAuthorized.store(true);
LOG_INFO("TAK: Local admin authorized via PKC");
}
bool PhoneAPI::isLocalAdminAuthorized()
{
return s_localAdminAuthorized.load();
}
void PhoneAPI::revokeAllAuth()
{
s_localAdminAuthorized.store(false);
LOG_INFO("TAK: All connection auth revoked (Lock Now)");
}
#endif
+21
View File
@@ -4,6 +4,7 @@
#include "concurrency/Lock.h"
#include "mesh-pb-constants.h"
#include "meshtastic/portnums.pb.h"
#include <atomic>
#include <deque>
#include <iterator>
#include <string>
@@ -138,6 +139,19 @@ class PhoneAPI
bool isConnected() { return state != STATE_SEND_NOTHING; }
bool isSendingPackets() { return state == STATE_SEND_PACKETS; }
#ifdef MESHTASTIC_PHONEAPI_ACCESS_CONTROL
/// Set by AdminModule after successful PKC admin key validation.
/// When true, config dumps include full unredacted secrets.
void setAdminAuthorized(bool authorized) { isAdminAuthorized = authorized; }
bool getAdminAuthorized() const { return isAdminAuthorized; }
/// Static: authorize all local PhoneAPI instances (called by AdminModule)
static void authorizeLocalAdmin();
static bool isLocalAdminAuthorized();
/// Static: immediately revoke all connection-level auth (called on Lock Now)
static void revokeAllAuth();
#endif
protected:
/// Our fromradio packet while it is being assembled
meshtastic_FromRadio fromRadioScratch = {};
@@ -179,6 +193,13 @@ class PhoneAPI
APIType api_type = TYPE_NONE;
#ifdef MESHTASTIC_PHONEAPI_ACCESS_CONTROL
// HIGH-3: both flags are accessed from multiple tasks (main loop + NimBLE BLE callback
// task), so they must be atomic to prevent data races on read-modify-write sequences.
std::atomic<bool> isAdminAuthorized{false};
static std::atomic<bool> s_localAdminAuthorized;
#endif
private:
void releasePhonePacket();
+151 -1
View File
@@ -24,6 +24,12 @@
#include "Default.h"
#include "TypeConversions.h"
#ifdef MESHTASTIC_PHONEAPI_ACCESS_CONTROL
#include "mesh/PhoneAPI.h"
#endif
#ifdef MESHTASTIC_ENCRYPTED_STORAGE
#include "security/EncryptedStorage.h"
#endif
#if !MESHTASTIC_EXCLUDE_MQTT
#include "mqtt/MQTT.h"
@@ -82,11 +88,39 @@ bool AdminModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshta
// and only allowing responses from that remote.
if (messageIsResponse(r)) {
LOG_DEBUG("Allow admin response message");
} else if (mp.from == 0) {
} else if (mp.from == 0 && !mp.pki_encrypted) {
// Plain (non-PKC) local admin from BLE/USB client.
// When locked: always allow through — passphrase delivery and LOCK NOW must work.
// When unlocked and is_managed: block unless this is a TAK security command
// (passphrase re-verify / LOCK NOW) or the connection is already passphrase-authorized.
// from=0 + pki_encrypted is NOT matched here and falls through to the PKC check below.
#ifdef MESHTASTIC_ENCRYPTED_STORAGE
if (config.security.is_managed && EncryptedStorage::isUnlocked()) {
// TAK security commands carry a non-empty private_key — always let them through.
// The passphrase HMAC in EncryptedStorage is the security gate for those packets.
// Only the passphrase delivery command itself is whitelisted — it is its own auth gate.
// Destructive commands (factory reset, nodedb reset) require an already-authorized
// connection (passphrase verified or PKC admin key).
bool isTakSecurityCmd =
(r->which_payload_variant == meshtastic_AdminMessage_set_config_tag &&
r->set_config.which_payload_variant == meshtastic_Config_security_tag &&
r->set_config.payload_variant.security.private_key.size >= 1);
#ifdef MESHTASTIC_PHONEAPI_ACCESS_CONTROL
if (!isTakSecurityCmd && !PhoneAPI::isLocalAdminAuthorized()) {
#else
if (!isTakSecurityCmd) {
#endif
LOG_INFO("Ignore local admin payload because is_managed");
myReply = allocErrorResponse(meshtastic_Routing_Error_NOT_AUTHORIZED, &mp);
return handled;
}
}
#else
if (config.security.is_managed) {
LOG_INFO("Ignore local admin payload because is_managed");
return handled;
}
#endif
} else if (strcasecmp(ch->settings.name, Channels::adminChannel) == 0) {
if (!config.security.admin_channel_enabled) {
LOG_INFO("Ignore admin channel, legacy admin is disabled");
@@ -102,6 +136,15 @@ bool AdminModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshta
memcmp(mp.public_key.bytes, config.security.admin_key[2].bytes, 32) == 0)) {
LOG_INFO("PKC admin payload with authorized sender key");
#ifdef MESHTASTIC_PHONEAPI_ACCESS_CONTROL
// Only authorize local PhoneAPI connections when the PKC admin message
// came from a local client (from=0). A remote PKC admin (from!=0) has no
// business unlocking local BLE/USB config dumps.
if (mp.from == 0) {
PhoneAPI::authorizeLocalAdmin();
}
#endif
// Automatically favorite the node that is using the admin key
auto remoteNode = nodeDB->getMeshNode(mp.from);
if (remoteNode && !remoteNode->is_favorite) {
@@ -853,6 +896,113 @@ void AdminModule::handleSetConfig(const meshtastic_Config &c)
config.bluetooth = c.payload_variant.bluetooth;
break;
case meshtastic_Config_security_tag:
#ifdef MESHTASTIC_ENCRYPTED_STORAGE
// set_config(security) doubles as the TAK passphrase transport (no schema changes).
// private_key field encodes the command:
// private_key.bytes/size — raw passphrase (164 bytes)
// size==1, bytes[0]==0xFF — LOCK NOW sentinel
// admin_key[1].bytes[0] — boots_remaining for new token (0 → default)
// admin_key[2].bytes[0..3] LE u32 — valid_until_epoch (absolute Unix timestamp; 0 → no time limit)
{
const auto &sec = c.payload_variant.security;
const uint8_t *pp = sec.private_key.bytes;
size_t ppLen = sec.private_key.size;
// MED-4: helper to zero the passphrase bytes in the decoded proto scratch buffer
// before returning. Uses volatile to prevent the compiler from eliding the wipe.
// const_cast is safe here: the underlying buffer is the mutable toRadioScratch member.
auto zeroPassphrase = [&]() {
volatile uint8_t *ppVol = const_cast<volatile uint8_t *>(pp);
for (size_t zi = 0; zi < ppLen; zi++)
ppVol[zi] = 0;
};
// LOCK NOW sentinel — always honoured regardless of lock state
if (ppLen == 1 && pp[0] == 0xFF) {
LOG_INFO("AdminModule: TAK LOCK NOW command received");
EncryptedStorage::lockNow();
#ifdef MESHTASTIC_PHONEAPI_ACCESS_CONTROL
// Immediately revoke all connection-level auth so that any config
// re-request in the window before reboot gets redacted data.
PhoneAPI::revokeAllAuth();
#endif
sendWarning("TAK_LOCKED");
zeroPassphrase(); // MED-4
reboot(DEFAULT_REBOOT_SECONDS);
return; // No config changed; skip saveChanges entirely
}
// MED-8: cap to 32 bytes to match the proto private_key field size
if (ppLen >= 1 && ppLen <= 32) {
uint8_t boots = EncryptedStorage::TOKEN_DEFAULT_BOOTS;
if (sec.admin_key[1].size >= 1 && sec.admin_key[1].bytes[0] != 0)
boots = sec.admin_key[1].bytes[0];
uint32_t validUntilEpoch = 0;
if (sec.admin_key[2].size >= 4)
memcpy(&validUntilEpoch, sec.admin_key[2].bytes, 4);
bool ok = false;
if (!EncryptedStorage::isUnlocked()) {
// Locked: provision on first boot, otherwise unlock
if (!EncryptedStorage::isProvisioned()) {
LOG_INFO("AdminModule: TAK first-time provisioning with passphrase");
ok = EncryptedStorage::provisionPassphrase(pp, ppLen, boots, validUntilEpoch);
} else {
LOG_INFO("AdminModule: TAK unlock with passphrase");
ok = EncryptedStorage::unlockWithPassphrase(pp, ppLen, boots, validUntilEpoch);
}
if (ok) {
#ifdef MESHTASTIC_PHONEAPI_ACCESS_CONTROL
PhoneAPI::authorizeLocalAdmin();
#endif
nodeDB->reloadFromDisk();
{
char unlockedMsg[64];
snprintf(unlockedMsg, sizeof(unlockedMsg), "TAK_UNLOCKED:boots=%d:until=%u",
(int)EncryptedStorage::getBootsRemaining(),
(unsigned)EncryptedStorage::getValidUntilEpoch());
sendWarning(unlockedMsg);
}
LOG_INFO("AdminModule: TAK storage unlocked, config reloaded");
}
} else {
// Already unlocked: verify passphrase to authorize this connection for admin.
// unlockWithPassphrase() re-derives KEK, unwraps DEK, refreshes the token.
LOG_INFO("AdminModule: TAK passphrase re-verify for admin authorization");
ok = EncryptedStorage::unlockWithPassphrase(pp, ppLen, boots, validUntilEpoch);
if (ok) {
#ifdef MESHTASTIC_PHONEAPI_ACCESS_CONTROL
PhoneAPI::authorizeLocalAdmin();
#endif
{
char unlockedMsg[64];
snprintf(unlockedMsg, sizeof(unlockedMsg), "TAK_UNLOCKED:boots=%d:until=%u",
(int)EncryptedStorage::getBootsRemaining(),
(unsigned)EncryptedStorage::getValidUntilEpoch());
sendWarning(unlockedMsg);
}
LOG_INFO("AdminModule: TAK passphrase verified, local admin authorized");
}
}
if (!ok) {
uint32_t backoff = EncryptedStorage::getBackoffSecondsRemaining();
char failMsg[64];
if (backoff > 0)
snprintf(failMsg, sizeof(failMsg), "TAK_UNLOCK_FAILED:backoff=%u", backoff);
else
snprintf(failMsg, sizeof(failMsg), "TAK_UNLOCK_FAILED:wrong_passphrase");
sendWarning(failMsg);
LOG_WARN("AdminModule: TAK passphrase verification failed");
}
zeroPassphrase(); // MED-4: wipe passphrase from scratch buffer before returning
return; // Passphrase handled; no config changed, skip saveChanges
}
}
// No passphrase (private_key absent or empty): fall through to normal security config.
// Reachable only when is_managed=false or device is locked (gate allows it in those states).
#endif
LOG_INFO("Set config: Security");
config.security = c.payload_variant.security;
#if !(MESHTASTIC_EXCLUDE_PKI_KEYGEN) && !(MESHTASTIC_EXCLUDE_PKI)
+43
View File
@@ -0,0 +1,43 @@
#include "configuration.h"
#ifdef MESHTASTIC_ENABLE_APPROTECT
#ifdef ARCH_NRF52
#include "APProtect.h"
#include <nrf.h>
void enableAPProtect()
{
// APPROTECT register: 0x00 = enabled (protected), 0xFF = disabled (open)
// On nRF52840, UICR.APPROTECT at address 0x10001208
if (NRF_UICR->APPROTECT != 0x00) {
LOG_WARN("Enabling APPROTECT - debug port will be disabled after reset");
// UICR writes require NVMC to be in write mode
NRF_NVMC->CONFIG = NVMC_CONFIG_WEN_Wen;
while (NRF_NVMC->READY == NVMC_READY_READY_Busy)
;
NRF_UICR->APPROTECT = 0x00;
while (NRF_NVMC->READY == NVMC_READY_READY_Busy)
;
// Return NVMC to read-only mode
NRF_NVMC->CONFIG = NVMC_CONFIG_WEN_Ren;
while (NRF_NVMC->READY == NVMC_READY_READY_Busy)
;
LOG_INFO("APPROTECT set, will take effect after next reset");
} else {
LOG_DEBUG("APPROTECT already enabled");
}
}
#else
// Non-nRF52 builds - no-op
void enableAPProtect()
{
LOG_DEBUG("APPROTECT not supported on this platform");
}
#endif // ARCH_NRF52
#endif // MESHTASTIC_ENABLE_APPROTECT
+16
View File
@@ -0,0 +1,16 @@
#pragma once
#ifdef MESHTASTIC_ENABLE_APPROTECT
/**
* Enable APPROTECT on nRF52840 to block debug probe access.
* Writes NRF_UICR->APPROTECT = 0x00 if not already set.
* Must be called early in setup() before any sensitive data is loaded.
*
* WARNING: This is a one-way operation. Once APPROTECT is enabled,
* the debug port is permanently disabled. The device can still be
* re-flashed via the bootloader (USB/DFU), but SWD/JTAG access is blocked.
*/
void enableAPProtect();
#endif // MESHTASTIC_ENABLE_APPROTECT
File diff suppressed because it is too large Load Diff
+198
View File
@@ -0,0 +1,198 @@
#pragma once
#ifdef MESHTASTIC_ENCRYPTED_STORAGE
#include <cstddef>
#include <cstdint>
/**
* Encrypted storage layer for TAK secure builds.
*
* Key hierarchy:
* FICR eFuse IDs + passphrase -> SHA-256 -> KEK_v2 (16 bytes, never stored)
* KEK_v2 wraps -> DEK (Data Encryption Key, 16 bytes, random, stored in /prefs/.dek)
* DEK encrypts -> proto files via AES-128-CTR + HMAC-SHA256(KEK)
*
* Ephemeral KEK (FICR-only, no passphrase) -> wraps DEK in the unlock token only.
* Unlock token (/prefs/.unlock_token) — valid for N boots and/or M hours after provisioning.
*
* Boot flow:
* 1. initLocked() — derive ephemeral KEK, try unlock token
* 2a. Token valid → UNLOCKED (DEK in RAM, all encrypted files accessible)
* 2b. No token, no DEK file → NOT PROVISIONED (operator must call provisionPassphrase)
* 2c. No token, DEK file exists → LOCKED (operator must call unlockWithPassphrase)
* 3. provisionPassphrase() / unlockWithPassphrase() complete the unlock
* 4. lockNow() immediately invalidates the token and zeroes the DEK from RAM
*
* Encrypted proto file format ("MENC"):
* [4B] Magic 0x4D454E43 ("MENC")
* [1B] Version 0x01
* [13B] Nonce (random per write)
* [4B] Original plaintext length (LE uint32)
* [NB] AES-128-CTR ciphertext
* [32B] HMAC-SHA256(DEK, nonce || ciphertext)
* Total overhead: 54 bytes per file.
*
* DEK file format v2 ("MDEK"):
* [4B] Magic 0x4D44454B ("MDEK")
* [1B] Version 0x02
* [13B] Nonce (random per write)
* [16B] AES-128-CTR(KEK_v2, nonce, DEK)
* [32B] HMAC-SHA256(KEK_v2, "mdek-auth" || nonce || encrypted_DEK)
* Total: 66 bytes.
* Legacy v1 DEK file is 29 bytes (no magic, no HMAC) and is migrated on first passphrase set.
*
* Unlock token format ("UTOK"):
* [4B] Magic 0x55544F4B ("UTOK")
* [1B] Version 0x01
* [13B] Nonce (random per write)
* [16B] AES-128-CTR(ephemeralKEK, nonce, DEK)
* [1B] boots_remaining
* [4B] valid_until_epoch (LE uint32, 0 = no time limit)
* [32B] HMAC-SHA256(ephemeralKEK, all above fields)
* Total: 71 bytes.
*/
namespace EncryptedStorage {
// ---------------------------------------------------------------------------
// File format constants
// ---------------------------------------------------------------------------
static constexpr uint32_t MAGIC = 0x4D454E43; // "MENC" — encrypted proto files
static constexpr uint8_t VERSION = 0x01;
static constexpr size_t NONCE_SIZE = 13;
static constexpr size_t HMAC_SIZE = 32;
static constexpr size_t HEADER_SIZE = 4 + 1 + NONCE_SIZE + 4; // magic+version+nonce+plaintext_len
static constexpr size_t OVERHEAD = HEADER_SIZE + HMAC_SIZE; // 54 bytes
static constexpr size_t AES_KEY_SIZE = 16;
static constexpr size_t AES_BLOCK_SIZE = 16;
static constexpr uint32_t DEK_MAGIC_V2 = 0x4D44454B; // "MDEK"
static constexpr uint8_t DEK_VERSION_V2 = 0x02;
static constexpr size_t DEK_V1_SIZE = NONCE_SIZE + AES_KEY_SIZE; // 29 bytes
static constexpr size_t DEK_V2_SIZE = 4 + 1 + NONCE_SIZE + AES_KEY_SIZE + HMAC_SIZE; // 66 bytes
static constexpr uint32_t TOKEN_MAGIC = 0x55544F4B; // "UTOK"
static constexpr uint8_t TOKEN_VERSION = 0x01;
// Body = magic(4)+ver(1)+nonce(13)+encDEK(16)+boots(1)+epoch(4) = 39 bytes
static constexpr size_t TOKEN_BODY_SIZE = 4 + 1 + NONCE_SIZE + AES_KEY_SIZE + 1 + 4;
static constexpr size_t TOKEN_TOTAL_SIZE = TOKEN_BODY_SIZE + HMAC_SIZE; // 71 bytes
static constexpr uint8_t TOKEN_DEFAULT_BOOTS = 50;
// ---------------------------------------------------------------------------
// Passphrase-gated boot API
// ---------------------------------------------------------------------------
/**
* Boot-time init: derive ephemeral KEK and attempt to unlock via the stored token.
* Sets isUnlocked()=true if the token is present and valid.
* Must be called after fsInit(), before loadFromDisk().
*/
void initLocked();
/**
* First-time provisioning: set the device passphrase, generate a fresh DEK,
* save it wrapped with the passphrase-mixed KEK, and create an unlock token.
*
* If a legacy v1 DEK file already exists, it is migrated rather than replaced,
* preserving all previously encrypted config files.
*
* @param passphrase Raw passphrase bytes (need not be NUL-terminated)
* @param passphraseLen Length in bytes (132; matches the proto private_key field size)
* @param bootsRemaining Token valid for this many boots (default TOKEN_DEFAULT_BOOTS)
* @param validUntilEpoch Absolute Unix timestamp after which token expires (0 = no time limit)
* @return true on success
*/
bool provisionPassphrase(const uint8_t *passphrase, size_t passphraseLen,
uint8_t bootsRemaining = TOKEN_DEFAULT_BOOTS, uint32_t validUntilEpoch = 0);
/**
* Unlock after token expiry (or after lockNow()): derive KEK from passphrase,
* unwrap the stored DEK, and create a fresh unlock token.
*
* @param passphrase Raw passphrase bytes
* @param passphraseLen Length in bytes (132; matches the proto private_key field size)
* @param bootsRemaining New token valid for this many boots
* @param validUntilEpoch Absolute Unix timestamp after which token expires (0 = no time limit)
* @return true if passphrase was correct and DEK is now loaded
*/
bool unlockWithPassphrase(const uint8_t *passphrase, size_t passphraseLen,
uint8_t bootsRemaining = TOKEN_DEFAULT_BOOTS, uint32_t validUntilEpoch = 0);
/**
* Immediately lock: delete the unlock token and zero the DEK from RAM.
* The device will require the passphrase on the next boot (or connection).
*/
void lockNow();
/** Returns true if the DEK file exists (device has been provisioned). */
bool isProvisioned();
/** Returns true if the DEK is loaded in RAM (device is unlocked). */
bool isUnlocked();
/**
* Returns a short string describing why the device is locked (set during initLocked()).
* Useful for client-side diagnostics. Examples:
* "token_missing" — no unlock token file found
* "token_wrong_size" — token file exists but is corrupt
* "token_bad_magic" — wrong magic/version bytes
* "token_hmac_fail" — HMAC mismatch (tampered or wrong device)
* "token_boots_zero" — boot count exhausted
* "token_expired" — TTL expired
* "token_dek_fail" — DEK decrypt failed
* "not_provisioned" — no DEK file; needs first provisioning
* "ok" — unlocked successfully via token
*/
const char *getLockReason();
/** Boots remaining in the current unlock token (0 if not unlocked or last boot consumed). */
uint8_t getBootsRemaining();
/** Unix epoch at which the current unlock token expires (0 = no time limit or not unlocked). */
uint32_t getValidUntilEpoch();
/** Seconds remaining before next passphrase attempt is allowed (0 = can attempt now). */
uint32_t getBackoffSecondsRemaining();
// ---------------------------------------------------------------------------
// Encrypted file I/O (require isUnlocked())
// ---------------------------------------------------------------------------
/** Returns true if the file starts with the MENC magic bytes. */
bool isEncrypted(const char *filename);
/**
* Read and decrypt a file into outBuf.
* Returns true on success; sets outLen to the plaintext byte count.
*/
bool readAndDecrypt(const char *filename, uint8_t *outBuf, size_t outBufSize, size_t &outLen);
/**
* Encrypt plaintext and write to filename.
* Returns true on success.
*/
bool encryptAndWrite(const char *filename, const uint8_t *plaintext, size_t plaintextLen,
bool fullAtomic = false);
/**
* Migrate a plaintext proto file to encrypted format in-place.
* Returns true on success or if already encrypted.
*/
bool migrateFile(const char *filename);
// ---------------------------------------------------------------------------
// Legacy init (FICR-only KEK, no passphrase, no token)
// ---------------------------------------------------------------------------
/**
* Original init(): derives KEK from FICR only, loads or generates DEK.
* Retained for non-TAK builds and unit tests. Prefer initLocked() for TAK builds.
*/
bool init();
} // namespace EncryptedStorage
#endif // MESHTASTIC_ENCRYPTED_STORAGE
@@ -0,0 +1,40 @@
; RAK4631 LOCKDOWN Build - Hardened variant with encrypted storage and access control
; Enable with -DMESHTASTIC_LOCKDOWN=1 (all sub-features derived from that single flag)
[env:rak4631_lockdown]
custom_meshtastic_hw_model = 9
custom_meshtastic_hw_model_slug = RAK4631
custom_meshtastic_architecture = nrf52840
custom_meshtastic_actively_supported = false
custom_meshtastic_support_level = 2
custom_meshtastic_display_name = RAK WisBlock 4631 LOCKDOWN
custom_meshtastic_tags = RAK, LOCKDOWN
extends = nrf52840_base
board = wiscore_rak4631
board_level = extra
board_check = true
build_type = release
build_flags = ${nrf52840_base.build_flags}
-I variants/nrf52840/rak4631_lockdown
-I variants/nrf52840/rak4631
-D RAK_4631
-DMESHTASTIC_USE_EINK_UI=0
-DRADIOLIB_EXCLUDE_SX128X=1
-DRADIOLIB_EXCLUDE_SX127X=1
-DRADIOLIB_EXCLUDE_LR11X0=1
; Hardened build — DEBUG_MUTE and module exclusions are derived automatically
; from this single flag via configuration.h
-DMESHTASTIC_LOCKDOWN=1
build_src_filter = ${nrf52_base.build_src_filter}
+<../variants/nrf52840/rak4631>
+<../variants/nrf52840/rak4631_lockdown>
+<security/>
-<graphics/EInkDisplay2.cpp>
-<graphics/EInkDynamicDisplay.cpp>
-<graphics/fonts/EinkDisplayFonts.cpp>
lib_deps =
${nrf52840_base.lib_deps}
melopero/Melopero RV3028@1.2.0
rakwireless/RAKwireless NCP5623 RGB LED library@1.0.3
@@ -0,0 +1,18 @@
/*
* RAK4631 LOCKDOWN Build variant header
* Includes the parent RAK4631 variant and adds LOCKDOWN-specific overrides.
*/
#ifndef _VARIANT_RAK4631_LOCKDOWN_
#define _VARIANT_RAK4631_LOCKDOWN_
// Include the parent RAK4631 variant - all pin definitions, radio config, etc.
#include "../rak4631/variant.h"
// Disable ethernet — reduces attack surface and avoids W5100S dependency
#ifdef HAS_ETHERNET
#undef HAS_ETHERNET
#endif
#define HAS_ETHERNET 0
#endif // _VARIANT_RAK4631_LOCKDOWN_