Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/PowerFSM.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -219,7 +219,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);
}
Expand Down
42 changes: 42 additions & 0 deletions src/configuration.h
Original file line number Diff line number Diff line change
Expand Up @@ -561,5 +561,47 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
#define HAS_SCREEN 0
#endif

// -----------------------------------------------------------------------------
// MESHTASTIC_LOCKDOWN — opt-in hardened build flag (nRF52 only)
//
// Add -DMESHTASTIC_LOCKDOWN=1 to any env's build_flags to enable hardening.
//
// nRF52 (CC310 hardware crypto required):
// MESHTASTIC_PHONEAPI_ACCESS_CONTROL — redact keys from unauthenticated clients;
// passphrase delivery via AdminModule
// MESHTASTIC_ENCRYPTED_STORAGE — AES-128-CTR + HMAC-SHA256 at-rest encryption
// MESHTASTIC_ENABLE_APPROTECT — one-way UICR APPROTECT write to lock SWD/JTAG
// DEBUG_MUTE — suppress all serial/USB-CDC log output
//
// Non-nRF52 (degraded — no passphrase path without encrypted storage to gate it):
// DEBUG_MUTE only. Access control is intentionally NOT enabled here because
// the passphrase-delivery code in AdminModule is wrapped in
// MESHTASTIC_ENCRYPTED_STORAGE; turning on access control alone would leave
// non-PKC clients with no way to authorize, redacting them out of admin
// forever. Use PKC admin keys for hardened deployments on these platforms.
//
// Add -DMESHTASTIC_LOCKDOWN_DEBUG=1 alongside MESHTASTIC_LOCKDOWN to keep the
// irreversible bits (APPROTECT, DEBUG_MUTE) disabled while still exercising the
// access-control + encrypted-storage code paths. For development and hardware
// bring-up only — production firmware should not ship with this set.
// -----------------------------------------------------------------------------
#ifdef MESHTASTIC_LOCKDOWN

#ifndef MESHTASTIC_LOCKDOWN_DEBUG
#define DEBUG_MUTE
#endif

#if defined(ARCH_NRF52)
#define MESHTASTIC_PHONEAPI_ACCESS_CONTROL 1
#define MESHTASTIC_ENCRYPTED_STORAGE 1
#ifndef MESHTASTIC_LOCKDOWN_DEBUG
#define MESHTASTIC_ENABLE_APPROTECT 1
#endif
#else
#warning "MESHTASTIC_LOCKDOWN: non-nRF52 target — only DEBUG_MUTE is active. Encrypted storage, APPROTECT, and access control are unavailable on this platform."
#endif

#endif // MESHTASTIC_LOCKDOWN

#include "DebugConfiguration.h"
#include "RF95Configuration.h"
35 changes: 35 additions & 0 deletions src/graphics/Screen.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
#include "draw/NotificationRenderer.h"
#include "draw/UIRenderer.h"
#include "graphics/TFTColorRegions.h"
#include "security/LockdownDisplay.h"
#include "modules/CannedMessageModule.h"

#if !MESHTASTIC_EXCLUDE_GPS
Expand Down Expand Up @@ -118,8 +119,42 @@ static inline void prepareFrameColorRegions()
}
#endif

#ifdef MESHTASTIC_LOCKDOWN
// Static lock screen drawn in place of normal frames when
// meshtastic_security::shouldRedactDisplay() returns true. Renders centered
// "LOCKED" plus battery so the operator can see the device is alive and
// charged without leaking any node/channel/message/position content.
static void drawLockdownLockScreen(OLEDDisplay *display)
{
display->clear();

const int w = display->getWidth();
const int h = display->getHeight();

display->setTextAlignment(TEXT_ALIGN_CENTER);
display->setFont(FONT_LARGE);
display->drawString(w / 2, h / 2 - FONT_HEIGHT_LARGE, "LOCKED");

display->setFont(FONT_SMALL);
char status[32] = "Connect to unlock";
if (powerStatus && powerStatus->getHasBattery()) {
int pct = powerStatus->getBatteryChargePercent();
snprintf(status, sizeof(status), "Battery %d%%", pct);
}
display->drawString(w / 2, h / 2 + 2, status);

display->display();
}
#endif

static inline void updateUiFrame(OLEDDisplayUi *ui)
{
#ifdef MESHTASTIC_LOCKDOWN
if (meshtastic_security::shouldRedactDisplay() && screen != nullptr) {
drawLockdownLockScreen(screen->getDisplayDevice());
return;
}
#endif
#if GRAPHICS_TFT_COLORING_ENABLED
prepareFrameColorRegions();
#endif
Expand Down
9 changes: 9 additions & 0 deletions src/input/InputBroker.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
#include "configuration.h"
#include "graphics/Screen.h"
#include "modules/ExternalNotificationModule.h"
#include "security/LockdownDisplay.h"

#if ARCH_PORTDUINO
#include "input/LinuxInputImpl.h"
Expand Down Expand Up @@ -100,6 +101,14 @@ void InputBroker::processInputEventQueue()

int InputBroker::handleInputEvent(const InputEvent *event)
{
#ifdef MESHTASTIC_LOCKDOWN
// Any input event (button, keyboard, touch, rotary, etc.) counts as
// operator interaction. Resets the lockdown display inactivity timer
// so the next ui->update() will render normal content for a while.
if (event && event->inputEvent != INPUT_BROKER_NONE) {
meshtastic_security::noteUserActivity();
}
#endif
#if HAS_SCREEN
bool screenWasOff = false;
if (screen) {
Expand Down
22 changes: 22 additions & 0 deletions src/main.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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) || defined(USE_CH390D)
#include "mesh/api/WiFiServerAPI.h"
#include "mesh/wifi/WiFiAPClient.h"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -461,6 +472,17 @@ void setup()

fsInit();

#ifdef MESHTASTIC_ENCRYPTED_STORAGE
EncryptedStorage::initLocked();
if (!EncryptedStorage::isUnlocked()) {
if (!EncryptedStorage::isProvisioned()) {
LOG_WARN("Lockdown: Device not provisioned — connect and set a passphrase to unlock storage");
} else {
LOG_WARN("Lockdown: 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);
Expand Down
6 changes: 6 additions & 0 deletions src/mesh/MeshService.h
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,12 @@ class MeshService
/// Release the next ClientNotification packet to pool.
void releaseClientNotificationToPool(meshtastic_ClientNotification *p) { clientNotificationPool.release(p); }

/// Bump fromNum to signal connected clients to poll for new FromRadio data.
/// Used by code paths (e.g. lockdown status queueing) that surface a new
/// FromRadio variant without going through one of the existing pool-backed
/// senders.
void nudgeFromNum() { fromNum++; }

/**
* Given a ToRadio buffer parse it and properly handle it (setup radio, owner or send packet into the mesh)
* Called by PhoneAPI.handleToRadio. Note: p is a scratch buffer, this function is allowed to write to it but it can not keep
Expand Down
134 changes: 134 additions & 0 deletions src/mesh/NodeDB.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,11 @@
#include <power/PowerHAL.h>
#include <vector>

#ifdef MESHTASTIC_ENCRYPTED_STORAGE
#include "security/EncryptedStorage.h"
#include "security/SecureZero.h"
#endif

#ifdef ARCH_ESP32
#if HAS_WIFI
#include "mesh/wifi/WiFiAPClient.h"
Expand Down Expand Up @@ -784,6 +789,7 @@ void NodeDB::installDefaultConfig(bool preserveKey = false)
config.security.private_key.size = 0;
}
config.security.public_key.size = 0;

#ifdef PIN_GPS_EN
config.position.gps_en_gpio = PIN_GPS_EN;
#endif
Expand Down Expand Up @@ -1441,6 +1447,39 @@ 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)) {
// ZeroizingArrayPtr wipes the decrypted plaintext (which contains config
// secrets — channel PSKs, security private_key, etc.) before delete[],
// so it isn't recoverable from the heap after this function returns.
auto decBuf = meshtastic_security::make_zeroizing_array(protoSize);
if (!decBuf) {
LOG_ERROR("OOM decrypting %s", filename);
return LoadFileResult::OTHER_FAILURE;
}
size_t decLen = 0;
if (EncryptedStorage::readAndDecrypt(filename, decBuf.get(), protoSize, decLen)) {
LOG_INFO("Load encrypted %s", filename);
pb_istream_t stream = pb_istream_from_buffer(decBuf.get(), 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;
}
return state;
}
#endif

#ifdef FSCom
concurrency::LockGuard g(spiLock);

Expand Down Expand Up @@ -1518,6 +1557,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) {
Expand Down Expand Up @@ -1731,6 +1785,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("Lockdown: 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);
Expand Down Expand Up @@ -1766,8 +1849,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)
Expand All @@ -1780,6 +1876,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) {
// ZeroizingArrayPtr wipes the unencrypted protobuf encoding (which contains
// config secrets — channel PSKs, security private_key, etc.) before delete[],
// so plaintext copies aren't left in heap memory after encryption completes.
auto pbBuf = meshtastic_security::make_zeroizing_array(protoSize);
if (!pbBuf) {
LOG_ERROR("OOM encoding %s for encryption", filename);
return false;
}

pb_ostream_t stream = pb_ostream_from_buffer(pbBuf.get(), protoSize);
if (!pb_encode(&stream, fields, dest_struct)) {
LOG_ERROR("Error: can't encode protobuf %s", PB_GET_ERROR(&stream));
return false;
}

size_t encodedSize = stream.bytes_written;
bool ok = EncryptedStorage::encryptAndWrite(filename, pbBuf.get(), encodedSize, fullAtomic);

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);
Expand Down Expand Up @@ -1938,6 +2062,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();
Expand Down
6 changes: 6 additions & 0 deletions src/mesh/NodeDB.h
Original file line number Diff line number Diff line change
Expand Up @@ -388,6 +388,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:
mutable concurrency::Lock satelliteMutex;
bool duplicateWarned = false;
Expand Down
Loading
Loading