diff --git a/src/PowerFSM.cpp b/src/PowerFSM.cpp
index a1610109c..0a1229f16 100644
--- a/src/PowerFSM.cpp
+++ b/src/PowerFSM.cpp
@@ -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);
}
diff --git a/src/configuration.h b/src/configuration.h
index f9c7721b7..bc138282b 100644
--- a/src/configuration.h
+++ b/src/configuration.h
@@ -573,5 +573,78 @@ along with this program. If not, see .
#define USE_ETHERNET_DEFAULT 0
#endif
+// -----------------------------------------------------------------------------
+// MESHTASTIC_LOCKDOWN — runtime, client-toggleable hardening (nRF52 only)
+//
+// There is NO build flag to turn lockdown on or off. On nRF52 (CC310 hardware
+// crypto) the lockdown machinery is ALWAYS compiled in; whether it is ACTIVE
+// is decided entirely at runtime by EncryptedStorage::isLockdownActive()
+// (== a passphrase has been provisioned, i.e. /prefs/.dek exists). A device
+// that has never been provisioned — or that the operator disabled from the
+// client app — behaves exactly like stock firmware: plaintext storage, no
+// redaction, normal logging, normal display.
+//
+// The operator toggles lockdown from the client app:
+// off -> on : provision a passphrase (AdminMessage.lockdown_auth). The
+// firmware generates a DEK, encrypts the stored config, and
+// authorizes the connection.
+// on -> off : AdminMessage.lockdown_auth { disable=true } with the
+// passphrase — decrypts storage back to plaintext and removes
+// the DEK / token / monotonic-counter / backoff files, then
+// reboots into normal mode. APPROTECT is the one thing that
+// does NOT revert (see below).
+//
+// MESHTASTIC_LOCKDOWN here is an INTERNAL capability marker, auto-defined for
+// nRF52. It gates the UI bits (lock screen, pairing-PIN handling). It is NOT
+// something a variant sets. Flash-constrained nRF52 variants that genuinely
+// cannot afford the ~tens-of-KB of crypto + access-control code may opt OUT
+// with -DMESHTASTIC_EXCLUDE_LOCKDOWN=1.
+//
+// MESHTASTIC_PHONEAPI_ACCESS_CONTROL — per-connection auth + redaction,
+// gated at runtime on isLockdownActive()
+// MESHTASTIC_ENCRYPTED_STORAGE — AES-128-CTR + HMAC-SHA256 at-rest
+// MESHTASTIC_ENABLE_APPROTECT — UICR APPROTECT capability. The actual
+// one-way burn happens at runtime, only
+// once provisioned, only on non-vulnerable
+// silicon, and is STICKY: disabling
+// lockdown does NOT (cannot) reverse it.
+//
+// DEBUG_MUTE is intentionally NOT coupled to lockdown — a capable-but-off
+// device must log normally. Define DEBUG_MUTE separately for a silent build.
+//
+// -DMESHTASTIC_LOCKDOWN_DEBUG=1 keeps the irreversible APPROTECT burn disabled
+// even when provisioned — for development so dev boards never lose SWD.
+// -----------------------------------------------------------------------------
+#if defined(ARCH_NRF52) && !defined(MESHTASTIC_EXCLUDE_LOCKDOWN)
+#define MESHTASTIC_LOCKDOWN 1
+#define MESHTASTIC_PHONEAPI_ACCESS_CONTROL 1
+#define MESHTASTIC_ENCRYPTED_STORAGE 1
+#ifndef MESHTASTIC_LOCKDOWN_DEBUG
+#define MESHTASTIC_ENABLE_APPROTECT 1
+#endif
+#endif
+
+#ifdef MESHTASTIC_LOCKDOWN
+
+// Per-boot uptime cap on unlocked sessions. 0 = unlimited (token-only
+// enforcement, the existing behavior). When non-zero, every passphrase
+// unlock (and every token-auto-unlock that inherits the value) arms a
+// timer; on expiry the device lockNow()s and reboots into locked state.
+// Bounds the total exposure window to bootsRemaining * this value if an
+// attacker has physical possession but not the passphrase.
+//
+// Override at build time. Suggested:
+// carry device: 3600 (1h sessions, periodic re-auth from phone)
+// tower / infra node: 0 (default — relies on token TTLs only)
+//
+// A future LockdownAuth.max_session_seconds proto field will let the
+// client set this per-token; until that lands the build-time value is
+// the only source.
+#ifndef MESHTASTIC_LOCKDOWN_SESSION_DEFAULT_SECONDS
+#define MESHTASTIC_LOCKDOWN_SESSION_DEFAULT_SECONDS 0
+#endif
+
+#endif // MESHTASTIC_LOCKDOWN
+
#include "DebugConfiguration.h"
#include "RF95Configuration.h"
diff --git a/src/graphics/Screen.cpp b/src/graphics/Screen.cpp
index 81befe329..1f7e8c079 100644
--- a/src/graphics/Screen.cpp
+++ b/src/graphics/Screen.cpp
@@ -41,6 +41,7 @@ along with this program. If not, see .
#include "draw/UIRenderer.h"
#include "graphics/TFTColorRegions.h"
#include "modules/CannedMessageModule.h"
+#include "security/LockdownDisplay.h"
#if !MESHTASTIC_EXCLUDE_GPS
#include "GPS.h"
@@ -119,8 +120,76 @@ 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.
+// Draw the LOCKED frame into the host-side framebuffer. Does NOT commit
+// to the panel — the caller is responsible for calling display->display()
+// once it has composited any overlays on top. Committing here would cause
+// visible flicker between "just LOCKED" and "LOCKED + banner overlay" when
+// the pairing-PIN special-case in updateUiFrame paints the overlay after
+// this returns.
+static void drawLockdownLockScreenIntoBuffer(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);
+}
+
+// Convenience wrapper for callers that want the LOCKED frame committed
+// to the panel immediately and have no overlay to compose on top.
+static void drawLockdownLockScreen(OLEDDisplay *display)
+{
+ drawLockdownLockScreenIntoBuffer(display);
+ display->display();
+}
+#endif
+
static inline void updateUiFrame(OLEDDisplayUi *ui)
{
+#ifdef MESHTASTIC_LOCKDOWN
+ if (meshtastic_security::shouldRedactDisplay() && screen != nullptr) {
+ OLEDDisplay *display = screen->getDisplayDevice();
+ // Paint LOCKED into the framebuffer WITHOUT committing. We commit
+ // exactly once at the bottom — after any overlay has been composed
+ // on top — so the panel never visibly transitions from "just LOCKED"
+ // to "LOCKED + overlay" mid-frame. Committing twice per cycle was
+ // the source of the H13 flicker.
+ drawLockdownLockScreenIntoBuffer(display);
+ // Special-case the BLE pairing PIN banner. The PIN is needed to
+ // complete first-pair against a locked device, but the lockdown
+ // short-circuit would otherwise hide the PIN entirely. The PIN is
+ // a per-attempt ephemeral pair-handshake artifact, not operator
+ // content, so compositing it over the LOCKED frame is safe.
+ //
+ // Calling ui->update() here would be wrong: it redraws the current
+ // carousel frame (the dashboard) into the framebuffer before the
+ // overlay paints, leaving operator content visible underneath the
+ // banner. Instead we invoke the banner overlay callback directly,
+ // which paints only the banner box on top of the LOCKED pixels we
+ // already have in the framebuffer.
+ if (NotificationRenderer::current_notification_type == notificationTypeEnum::pairing_pin) {
+ NotificationRenderer::drawBannercallback(display, ui->getUiState());
+ }
+ display->display();
+ return;
+ }
+#endif
#if GRAPHICS_TFT_COLORING_ENABLED
prepareFrameColorRegions();
#endif
@@ -583,6 +652,21 @@ void Screen::handleSetOn(bool on, FrameCallback einkScreensaver)
setScreensaverFrames(einkScreensaver);
#endif
+#ifdef MESHTASTIC_LOCKDOWN
+ // M19: before turning the panel off, paint a safe frame into the
+ // OLED's GDDRAM. The panel retains whatever was last written even
+ // while powered down, so when displayOn() is called later the
+ // screen would otherwise flash the previous frame's content for
+ // 16-50 ms before the next ui->update() lands. Painting the
+ // LOCKED frame now ensures the only thing the operator (or
+ // someone over their shoulder) can see on wake is the redacted
+ // view. Gated on lockdown — non-lockdown builds keep the
+ // previous frame as a UX cue that the display is just dimmed.
+ // dispdev is dereferenced unguarded throughout this file (incl.
+ // displayOff() just below), so no null check here.
+ drawLockdownLockScreen(dispdev);
+#endif
+
#ifdef PIN_EINK_EN
digitalWrite(PIN_EINK_EN, LOW);
#elif defined(PCA_PIN_EINK_EN)
@@ -702,6 +786,27 @@ void Screen::setup()
#endif
LOG_INFO("Applied screen brightness: %d", brightness);
+#if defined(MESHTASTIC_LOCKDOWN) && defined(USE_EINK)
+ // M20: e-ink panels physically retain the last-rendered image without
+ // power, so a power-cycled lockdown handheld would keep showing
+ // operator-identifying content (position, messages, node info) until
+ // the firmware's first natural refresh — which on e-ink can be seconds
+ // into boot. Force a full refresh to the LOCKED frame here, immediately
+ // after the display is initialised and before any other rendering, so
+ // the persistent pixels are wiped to the redacted view before an
+ // observer can see them.
+ if (meshtastic_security::shouldRedactDisplay()) {
+ drawLockdownLockScreen(dispdev);
+#if defined(USE_EINK_PARALLELDISPLAY)
+ // Parallel-display variants drive refresh through a different path;
+ // a bare drawLockdownLockScreen above lands the frame into the
+ // panel buffer and the next ui->update() commits it as normal.
+#else
+ static_cast(dispdev)->forceDisplay();
+#endif
+ }
+#endif
+
// Set custom overlay callbacks
static OverlayCallback overlays[] = {
graphics::UIRenderer::drawNavigationBar // Custom indicator icons for each frame
@@ -809,10 +914,16 @@ void Screen::setOn(bool on, FrameCallback einkScreensaver)
if (cardKbI2cImpl)
cardKbI2cImpl->toggleBacklight(on);
#endif
- if (!on)
+ if (!on) {
+#ifdef MESHTASTIC_LOCKDOWN
+ // Screen powering off (idle timeout, shutdown, deep sleep) latches
+ // the screen-lock. Next time the display wakes it shows the LOCKED
+ // frame until a client authenticates with the passphrase.
+ meshtastic_security::lockScreen();
+#endif
// We handle off commands immediately, because they might be called because the CPU is shutting down
handleSetOn(false, einkScreensaver);
- else
+ } else
enqueueCmd(ScreenCmd{.cmd = Cmd::SET_ON});
}
@@ -919,7 +1030,17 @@ int32_t Screen::runOnce()
#endif
#ifndef DISABLE_WELCOME_UNSET
- if (!NotificationRenderer::isOverlayBannerShowing() && config.lora.region == meshtastic_Config_LoRaConfig_RegionCode_UNSET) {
+ bool suppressRegionOnboard = false;
+#ifdef MESHTASTIC_LOCKDOWN
+ // While lockdown is active and storage is still locked, config.lora.region
+ // is a deliberate UNSET placeholder — the real region lives in encrypted
+ // storage and is restored on unlock (see NodeDB's locked-boot path). Don't
+ // pop the region picker over the lock screen: it would trap input, and the
+ // operator can't set a region until they unlock anyway.
+ suppressRegionOnboard = meshtastic_security::shouldRedactDisplay();
+#endif
+ if (!suppressRegionOnboard && !NotificationRenderer::isOverlayBannerShowing() &&
+ config.lora.region == meshtastic_Config_LoRaConfig_RegionCode_UNSET) {
#if defined(OLED_TINY)
menuHandler::LoraRegionPicker();
#else
@@ -1635,6 +1756,15 @@ void Screen::handleStartFirmwareUpdateScreen()
void Screen::blink()
{
+#ifdef MESHTASTIC_LOCKDOWN
+ // L4: defensive guard. blink() paints arbitrary geometry, not node
+ // data, so it doesn't actually leak today. But it bypasses the normal
+ // ui->update() path that the lockdown short-circuit gates, so any
+ // future change that puts content into blink would silently leak past
+ // redaction. Refuse to draw when the redaction latch is set.
+ if (meshtastic_security::shouldRedactDisplay())
+ return;
+#endif
setFastFramerate();
uint8_t count = 10;
dispdev->setBrightness(254);
diff --git a/src/graphics/Screen.h b/src/graphics/Screen.h
index 09361949a..37acfbc96 100644
--- a/src/graphics/Screen.h
+++ b/src/graphics/Screen.h
@@ -12,7 +12,21 @@
#define getStringCenteredX(s) ((SCREEN_WIDTH - display->getStringWidth(s)) / 2)
namespace graphics
{
-enum notificationTypeEnum { none, text_banner, selection_picker, node_picker, number_picker, hex_picker, text_input };
+enum notificationTypeEnum {
+ none,
+ text_banner,
+ selection_picker,
+ node_picker,
+ number_picker,
+ hex_picker,
+ text_input,
+ // BLE pairing PIN banner. Treated specially by the lockdown short-circuit
+ // in Screen.cpp: the PIN is ephemeral (regenerated per pair attempt) and
+ // not a real secret, so we allow ui->update() to composite it over the
+ // LOCKED frame. Without this, a first-pair on a locked device cannot
+ // complete because the PIN never renders.
+ pairing_pin,
+};
struct BannerOverlayOptions {
const char *message;
diff --git a/src/graphics/draw/NotificationRenderer.cpp b/src/graphics/draw/NotificationRenderer.cpp
index 4c50a374d..6880c6bd7 100644
--- a/src/graphics/draw/NotificationRenderer.cpp
+++ b/src/graphics/draw/NotificationRenderer.cpp
@@ -260,6 +260,12 @@ void NotificationRenderer::drawBannercallback(OLEDDisplay *display, OLEDDisplayU
break;
case notificationTypeEnum::text_banner:
case notificationTypeEnum::selection_picker:
+ case notificationTypeEnum::pairing_pin:
+ // pairing_pin is rendered the same as text_banner — it's just a
+ // text banner. The split type exists only so the lockdown UI
+ // short-circuit in Screen.cpp can recognise the BLE pair-PIN
+ // banner as the one safe banner to composite over the LOCKED
+ // frame.
drawAlertBannerOverlay(display, state);
break;
case notificationTypeEnum::node_picker:
diff --git a/src/input/InputBroker.cpp b/src/input/InputBroker.cpp
index 42ab7f70d..3d18f69cf 100644
--- a/src/input/InputBroker.cpp
+++ b/src/input/InputBroker.cpp
@@ -3,6 +3,9 @@
#include "configuration.h"
#include "graphics/Screen.h"
#include "modules/ExternalNotificationModule.h"
+#ifdef MESHTASTIC_LOCKDOWN
+#include "security/LockdownDisplay.h"
+#endif
#if ARCH_PORTDUINO
#include "input/LinuxInputImpl.h"
@@ -122,6 +125,22 @@ int InputBroker::handleInputEvent(const InputEvent *event)
}
#endif
+#ifdef MESHTASTIC_LOCKDOWN
+ // Lockdown: when the display is redacted (storage locked, or screen-lock
+ // latch set after idle) the screen content is hidden, but local input
+ // would otherwise still flow into UI handlers — letting an operator
+ // drive menus, fire canned messages, change settings etc. blind. Eat
+ // the event here so input is no-op until the redaction clears.
+ // The latch is cleared only by unlockScreen() on a successful
+ // passphrase auth (see PhoneAPI::handleLockdownAuthInline) — local
+ // input does not clear it, even if storage happens to be unlocked.
+ // PowerFSM was already triggered above, so the backlight still wakes
+ // to show the LOCKED frame — the input just doesn't act on anything.
+ if (meshtastic_security::shouldRedactDisplay()) {
+ return 0;
+ }
+#endif
+
this->notifyObservers(event);
return 0;
}
diff --git a/src/main.cpp b/src/main.cpp
index 2541c7c20..d1703c82d 100644
--- a/src/main.cpp
+++ b/src/main.cpp
@@ -68,6 +68,19 @@ void nrf54l15Loop();
NRF54L15Bluetooth *nrf54l15Bluetooth = nullptr;
#endif
+#ifdef MESHTASTIC_ENABLE_APPROTECT
+#include "security/APProtect.h"
+#endif
+#ifdef MESHTASTIC_ENCRYPTED_STORAGE
+#include "security/EncryptedStorage.h"
+#endif
+#ifdef MESHTASTIC_PHONEAPI_ACCESS_CONTROL
+#include "mesh/PhoneAPI.h"
+#endif
+#ifdef MESHTASTIC_LOCKDOWN
+#include "security/LockdownDisplay.h"
+#endif
+
#if HAS_WIFI || defined(USE_WS5500) || defined(USE_CH390D)
#include "mesh/api/WiFiServerAPI.h"
#include "mesh/wifi/WiFiAPClient.h"
@@ -379,6 +392,14 @@ void setup()
consoleInit(); // Set serial baud rate and init our mesh console
#endif
+ // M23 (audit): APPROTECT engagement moved below fsInit() so we can gate
+ // on EncryptedStorage::isProvisioned(). Engaging on an unprovisioned dev
+ // board permanently locks SWD before the operator has even set a
+ // passphrase — a misconfigured CI build flashed to a developer device
+ // would brick its debug port on first boot. Now we only engage when the
+ // device has a DEK file on flash, i.e. the operator has explicitly
+ // committed to lockdown via passphrase provisioning.
+
#ifdef UNPHONE
unphone.printStore();
#endif
@@ -409,7 +430,12 @@ void setup()
#endif
#endif
-#if defined(DEBUG_MUTE) && defined(DEBUG_PORT)
+ // The DEBUG_MUTE "we are muted, FYI" banner spills APP_VERSION / APP_ENV /
+ // APP_REPO out the USB CDC even with logging otherwise suppressed — a free
+ // firmware-fingerprinting primitive for an attacker holding the cable.
+ // Under MESHTASTIC_LOCKDOWN we want the device to look uniformly silent
+ // until the operator authenticates, so skip the banner entirely there.
+#if defined(DEBUG_MUTE) && defined(DEBUG_PORT) && !defined(MESHTASTIC_LOCKDOWN)
DEBUG_PORT.printf("\r\n\r\n//\\ E S H T /\\ S T / C\r\n");
DEBUG_PORT.printf("Version %s for %s from %s\r\n", optstr(APP_VERSION), optstr(APP_ENV), optstr(APP_REPO));
DEBUG_PORT.printf("Debug mute is enabled, there will be no serial output.\r\n");
@@ -481,6 +507,38 @@ 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 defined(MESHTASTIC_ENABLE_APPROTECT) && defined(MESHTASTIC_ENCRYPTED_STORAGE)
+ // M23 (audit): only engage the irreversible UICR APPROTECT lockout once
+ // the device has been provisioned with a passphrase. A misconfigured
+ // CI build of a lockdown variant flashed to a developer board would
+ // otherwise burn SWD on first boot before the operator has even set a
+ // passphrase, taking the board out of the dev/recovery workflow with
+ // no real security benefit (there's no DEK to protect yet). Once a
+ // DEK file exists, the operator has committed to lockdown — engaging
+ // APPROTECT then is the protection they asked for.
+ if (EncryptedStorage::isProvisioned()) {
+ enableAPProtect();
+ } else {
+ LOG_INFO("APPROTECT deferred: device not yet provisioned");
+ }
+#elif defined(MESHTASTIC_ENABLE_APPROTECT)
+ // Lockdown without encrypted storage shouldn't be reachable per
+ // configuration.h, but if it ever is, fall back to the unconditional
+ // engagement.
+ enableAPProtect();
+#endif
+
#if !MESHTASTIC_EXCLUDE_I2C
#if defined(I2C_SDA1) && defined(ARCH_RP2040)
Wire1.setSDA(I2C_SDA1);
@@ -1077,6 +1135,11 @@ uint32_t rebootAtMsec; // If not zero we will reboot at this time (used to r
uint32_t shutdownAtMsec; // If not zero we will shutdown at this time (used to shutdown from python or mobile client)
bool suppressRebootBanner; // If true, suppress "Rebooting..." overlay (used for OTA handoff)
+#if defined(MESHTASTIC_ENCRYPTED_STORAGE) && defined(MESHTASTIC_PHONEAPI_ACCESS_CONTROL)
+volatile bool lockdownReloadPending; // see main.h — deferred NodeDB reload after lockdown unlock
+volatile bool lockdownDisablePending; // see main.h — deferred decrypt-revert after lockdown disable
+#endif
+
// If a thread does something that might need for it to be rescheduled ASAP it can set this flag
// This will suppress the current delay and instead try to run ASAP.
bool runASAP;
@@ -1161,6 +1224,85 @@ void loop()
{
runASAP = false;
+#if defined(MESHTASTIC_ENCRYPTED_STORAGE) && defined(MESHTASTIC_PHONEAPI_ACCESS_CONTROL)
+ if (lockdownDisablePending) {
+ lockdownDisablePending = false;
+ LOG_INFO("Lockdown: disabling — reverting encrypted storage to plaintext");
+ if (nodeDB->disableLockdownToPlaintext()) {
+ LOG_INFO("Lockdown: disabled, rebooting into normal mode");
+ PhoneAPI::broadcastLockdownStatus(meshtastic_LockdownStatus_State_DISABLED, "", 0, 0, 0);
+ rebootAtMsec = millis() + DEFAULT_REBOOT_SECONDS * 1000;
+ } else {
+ // Revert failed mid-way (a file couldn't be decrypted/rewritten).
+ // The DEK file is still present (it's deleted last), so the device
+ // stays in lockdown and the operator can retry disable. Surface
+ // the failure rather than leaving the client hanging.
+ LOG_ERROR("Lockdown: disable revert failed — device remains in lockdown");
+ PhoneAPI::broadcastLockdownStatus(meshtastic_LockdownStatus_State_LOCKED, "disable_failed", 0, 0, 0);
+ }
+ }
+
+ if (lockdownReloadPending) {
+ lockdownReloadPending = false;
+ LOG_INFO("Lockdown: reloading config from disk after unlock");
+ bool reloadOk = nodeDB->reloadFromDisk();
+ if (!reloadOk) {
+ // Storage decrypt/decode failed during reload. Treat as
+ // unrecoverable for this boot: lock storage, revoke any
+ // auth that managed to slip through (defense in depth — the
+ // cold-unlock path doesn't authorize until completion, but
+ // a concurrent re-verify-path call from another connection
+ // might have), and notify clients. Storage will be locked
+ // on next boot anyway; deferring to the user-visible
+ // notification path is sufficient for now.
+ LOG_ERROR("Lockdown: reload failed — locking and notifying clients");
+ EncryptedStorage::lockNow();
+ PhoneAPI::revokeAllAuth();
+ }
+ PhoneAPI::completePendingUnlocks(reloadOk);
+ }
+
+ // Periodic session-expiry check. Cheap — millis() comparison. Don't
+ // hammer it every loop tick; once a second is plenty.
+ static uint32_t lastSessionCheckMs = 0;
+ if (millis() - lastSessionCheckMs > 1000) {
+ lastSessionCheckMs = millis();
+ if (rebootAtMsec == 0 && EncryptedStorage::isUnlocked() && EncryptedStorage::isSessionExpired()) {
+ // The session expired. Two paths:
+ // 1. Budget remains (bootsRemaining > 0): decrement the
+ // on-flash boot count in place, revoke per-connection
+ // auth, re-engage screen redaction, re-arm the uptime
+ // timer — all WITHOUT rebooting. Storage stays unlocked
+ // so the mesh keeps routing. Clients must re-authenticate
+ // to see content again. The decrement is what enforces
+ // the rollback ceiling — bootsRemaining ticks down
+ // monotonically whether the device reboots or not.
+ // 2. Budget exhausted (bootsRemaining == 0): no more
+ // sessions to grant. Hard lock (token deleted, DEK
+ // zeroed) and reboot. Operator must re-enter passphrase.
+ if (EncryptedStorage::getBootsRemaining() == 0) {
+ LOG_WARN("Lockdown: session limit reached and boot budget exhausted, locking and rebooting");
+ EncryptedStorage::lockNow();
+ PhoneAPI::revokeAllAuth();
+ PhoneAPI::broadcastLockdownStatus(meshtastic_LockdownStatus_State_LOCKED, "session_budget_exhausted", 0, 0, 0);
+ rebootAtMsec = millis() + DEFAULT_REBOOT_SECONDS * 1000;
+ } else {
+ uint8_t newBoots = EncryptedStorage::consumeSessionBoot();
+ LOG_WARN("Lockdown: session expired, rolled to next budget slot (boots=%u remaining)", newBoots);
+ PhoneAPI::revokeAllAuth();
+ meshtastic_security::lockScreen();
+ // Signal clients that they need to re-auth on this
+ // connection. Storage is still unlocked (DEK in RAM,
+ // mesh keeps routing) but per-connection auth is gone.
+ // Reusing the LOCKED(needs_auth) post-config emission
+ // pattern so existing clients don't need a new state.
+ PhoneAPI::broadcastLockdownStatus(meshtastic_LockdownStatus_State_LOCKED, "needs_auth", newBoots,
+ EncryptedStorage::getValidUntilEpoch(), 0);
+ }
+ }
+ }
+#endif
+
#ifdef ARCH_ESP32
esp32Loop();
#endif
diff --git a/src/main.h b/src/main.h
index d6439f1d1..661fdd9fb 100644
--- a/src/main.h
+++ b/src/main.h
@@ -92,6 +92,19 @@ extern uint32_t rebootAtMsec;
extern uint32_t shutdownAtMsec;
extern bool suppressRebootBanner;
+#if defined(MESHTASTIC_ENCRYPTED_STORAGE) && defined(MESHTASTIC_PHONEAPI_ACCESS_CONTROL)
+// Set by PhoneAPI::handleLockdownAuthInline after a successful unlock.
+// Serviced on the main loop thread because NodeDB::reloadFromDisk() is
+// too heavy for the BLE/serial transport callback stack.
+extern volatile bool lockdownReloadPending;
+
+// Set by PhoneAPI::handleLockdownAuthInline on a disable request (after the
+// passphrase is verified). Serviced on the main loop thread: decrypt every
+// pref back to plaintext, remove the lockdown artifacts, reboot. Heavy file
+// IO, same reason as lockdownReloadPending.
+extern volatile bool lockdownDisablePending;
+#endif
+
extern uint32_t serialSinceMsec;
// If a thread does something that might need for it to be rescheduled ASAP it can set this flag
diff --git a/src/mesh/MeshService.h b/src/mesh/MeshService.h
index 71fb544a0..eee39fba2 100644
--- a/src/mesh/MeshService.h
+++ b/src/mesh/MeshService.h
@@ -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
diff --git a/src/mesh/NodeDB.cpp b/src/mesh/NodeDB.cpp
index ccc9a00ef..32d1c75ab 100644
--- a/src/mesh/NodeDB.cpp
+++ b/src/mesh/NodeDB.cpp
@@ -36,6 +36,11 @@
#include
#include
+#ifdef MESHTASTIC_ENCRYPTED_STORAGE
+#include "security/EncryptedStorage.h"
+#include "security/SecureZero.h"
+#endif
+
#ifdef ARCH_ESP32
#if HAS_WIFI
#include "mesh/wifi/WiFiAPClient.h"
@@ -908,6 +913,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
@@ -1578,6 +1584,41 @@ 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;
+ storageCorruptThisLoad = true;
+ } 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;
+ storageCorruptThisLoad = true;
+ }
+ return state;
+ }
+#endif
+
#ifdef FSCom
concurrency::LockGuard g(spiLock);
@@ -1612,6 +1653,13 @@ void NodeDB::loadFromDisk()
// Mark the current device state as completely unusable, so that if we fail reading the entire file from
// disk we will still factoryReset to restore things.
devicestate.version = 0;
+#ifdef MESHTASTIC_ENCRYPTED_STORAGE
+ // Reset the per-load decrypt-failure tracker. Set by loadProto on any
+ // encrypted file that fails to decrypt or proto-decode; consumed by
+ // reloadFromDisk to surface storage corruption to the operator instead
+ // of silently falling back to defaults.
+ storageCorruptThisLoad = false;
+#endif
meshtastic_Config_SecurityConfig backupSecurity = meshtastic_Config_SecurityConfig_init_zero;
@@ -1655,6 +1703,39 @@ void NodeDB::loadFromDisk()
}
#endif
+#ifdef MESHTASTIC_ENCRYPTED_STORAGE
+ // Only take the locked-boot defaults path when lockdown is ACTIVE (the
+ // device is provisioned) AND storage is still locked. A lockdown-capable
+ // build that has never been provisioned — or that was disabled — falls
+ // through to the normal plaintext load below and behaves like stock.
+ if (EncryptedStorage::isLockdownActive() && !EncryptedStorage::isUnlocked()) {
+ // Encrypted storage is locked. Install defaults and wait for the
+ // passphrase over BLE/serial; PhoneAPI::handleLockdownAuthInline
+ // calls reloadFromDisk() once the storage is unlocked.
+ LOG_WARN("NodeDB: Encrypted storage locked, using default config until unlocked");
+ installDefaultNodeDatabase();
+ installDefaultDeviceState();
+ installDefaultConfig();
+ installDefaultModuleConfig();
+ installDefaultChannels();
+
+ // Hold the radio silent until the operator unlocks. installDefaultConfig
+ // would otherwise honour USERPREFS_CONFIG_LORA_REGION (the common shape
+ // for managed deployments) and the LongFast default channel synthesised
+ // by installDefaultChannels, so the device would beacon nodeinfo /
+ // telemetry on the public default PSK before any unlock — and process
+ // incoming default-channel packets the same way. Forcing region=UNSET
+ // gates both TX and RX in RadioLibInterface (see the region==UNSET
+ // checks in startSend and readData); tx_enabled=false is belt-and-
+ // suspenders for any code path that does not consult region directly.
+ // reloadFromDisk() restores the persisted lora config when the
+ // operator unlocks.
+ config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_UNSET;
+ config.lora.tx_enabled = false;
+ return;
+ }
+#endif
+
// Arm the direct-into-map decode so satellite entries skip the temp vectors.
{
concurrency::LockGuard guard(&satelliteMutex);
@@ -1891,6 +1972,40 @@ 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.
+ //
+ // Only when lockdown is ACTIVE. A capable-but-off device must leave its
+ // files as plaintext — encryptAndWrite would fail anyway (no DEK), but
+ // skipping the whole block avoids the wasted attempts and error logs.
+ if (EncryptedStorage::isLockdownActive()) {
+ const char *filesToCheck[] = {configFileName, moduleConfigFileName, channelFileName, deviceStateFileName,
+ nodeDatabaseFileName};
+ const 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);
@@ -1928,6 +2043,87 @@ void NodeDB::loadFromDisk()
#endif
}
+#ifdef MESHTASTIC_ENCRYPTED_STORAGE
+// Serializes reloadFromDisk against itself. Other readers of config /
+// channelFile / nodeDatabase don't take this lock today, so this only
+// prevents reload-vs-reload races (e.g. fast successive unlocks). It is
+// not a full data-race fix for those structs — that would require
+// thread-shared locking discipline across the whole codebase, beyond
+// the audit's M7 scope. The radio standby+reconfigure below keeps the
+// radio out of the window where SX12xx registers are mid-swap.
+static concurrency::Lock g_reloadFromDiskMutex;
+
+/**
+ * Re-run loadFromDisk() after encrypted storage is unlocked at runtime.
+ * Holds the radio in standby across the file IO + proto decode so the
+ * SX12xx is not mid-RX/TX when config.lora is overwritten, then calls
+ * reconfigure() to push the now-real settings to the chip.
+ *
+ * Returns true iff every encrypted file decrypted and decoded cleanly.
+ * On false the caller MUST treat storage as corrupt — see header.
+ */
+bool NodeDB::reloadFromDisk()
+{
+ concurrency::LockGuard guard(&g_reloadFromDiskMutex);
+ LOG_INFO("NodeDB: Reloading config from encrypted storage after unlock");
+
+ RadioInterface *rIface = router ? router->getRadioIface() : nullptr;
+
+ // Park the radio while config.lora / channelFile swap. Without this,
+ // a concurrent send or receive can read half-old / half-new state
+ // (channel keys, region, modem preset) and the SX12xx ends up in
+ // an inconsistent register set that only a reboot recovers from.
+ if (rIface)
+ rIface->sleep();
+
+ loadFromDisk();
+
+ if (storageCorruptThisLoad) {
+ LOG_ERROR("NodeDB: storage decrypt/decode failed during reload — surfacing as corrupt");
+ // Leave the radio sleeping. Caller will lock storage and emit
+ // a LOCKED(storage_corrupt) status; we must not reconfigure
+ // the chip with the locked-default placeholder values still
+ // sitting in config.lora.
+ return false;
+ }
+
+ // Push the now-real config to the radio.
+ if (rIface) {
+ channels.onConfigChanged();
+ rIface->reconfigure();
+ }
+ return true;
+}
+
+bool NodeDB::disableLockdownToPlaintext()
+{
+ concurrency::LockGuard guard(&g_reloadFromDiskMutex);
+ if (!EncryptedStorage::isUnlocked()) {
+ LOG_ERROR("NodeDB: disable requested but storage not unlocked");
+ return false;
+ }
+ LOG_INFO("NodeDB: reverting encrypted prefs to plaintext for lockdown disable");
+
+ // Decrypt each encrypted pref back to plaintext IN PLACE. Mirror of the
+ // plaintext->encrypted migrate loop above. Order does not matter here;
+ // EncryptedStorage::removeLockdownArtifacts() (which deletes the DEK,
+ // the commit point) only runs after every file is confirmed plaintext.
+ const char *filesToCheck[] = {configFileName, moduleConfigFileName, channelFileName, deviceStateFileName,
+ nodeDatabaseFileName};
+ for (const char *fn : filesToCheck) {
+ if (!EncryptedStorage::migrateFileToPlaintext(fn)) {
+ LOG_ERROR("NodeDB: failed to revert %s to plaintext; aborting disable (device stays in lockdown)", fn);
+ return false;
+ }
+ }
+
+ // All files are plaintext now — remove the lockdown artifacts. Deleting
+ // /prefs/.dek is the atomic commit: after it, isLockdownActive() is false.
+ EncryptedStorage::removeLockdownArtifacts();
+ return true;
+}
+#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)
@@ -1940,6 +2136,38 @@ 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).
+ // Only when lockdown is ACTIVE (provisioned). A lockdown-capable but DISABLED
+ // device has no DEK, so encryptAndWrite would fail and config would never
+ // persist — it must save plaintext exactly like stock firmware. Once enabled,
+ // the reloadFromDisk migrate pass re-saves these plaintext files encrypted.
+ if (EncryptedStorage::isLockdownActive() && 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);
@@ -2115,6 +2343,22 @@ bool NodeDB::saveToDiskNoRetry(int saveWhat)
return false;
}
+#ifdef MESHTASTIC_ENCRYPTED_STORAGE
+ // When lockdown is ACTIVE but storage is still 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."
+ //
+ // Gate on isLockdownActive(): a lockdown-capable but DISABLED device (never
+ // provisioned) also has isUnlocked()==false, but it must persist plaintext
+ // normally — skipping here would silently drop every config write (e.g. the
+ // LoRa region) until the device is provisioned.
+ if (EncryptedStorage::isLockdownActive() && !EncryptedStorage::isUnlocked()) {
+ LOG_WARN("NodeDB: saveToDisk skipped — encrypted storage locked");
+ return true;
+ }
+#endif
+
bool success = true;
#ifdef FSCom
spiLock->lock();
diff --git a/src/mesh/NodeDB.h b/src/mesh/NodeDB.h
index 8b250f09b..cbd2cfa8c 100644
--- a/src/mesh/NodeDB.h
+++ b/src/mesh/NodeDB.h
@@ -388,6 +388,38 @@ class NodeDB
newStatus.notifyObservers(&status);
}
+#ifdef MESHTASTIC_ENCRYPTED_STORAGE
+ /// Re-run loadFromDisk() after the encrypted storage is unlocked at runtime.
+ /// Trigger: PhoneAPI::handleLockdownAuthInline sets lockdownReloadPending
+ /// on a successful provisionPassphrase / unlockWithPassphrase; the main
+ /// loop in main.cpp services the flag and calls this method on the main
+ /// thread. The transport callback stack (BLE/USB) is too small for the
+ /// file IO + MAX_NUM_NODES vector reserve + proto decode this triggers.
+ ///
+ /// Returns true iff every encrypted file decrypted and decoded cleanly.
+ /// On false the caller MUST treat the storage as corrupt: leave the
+ /// connection unauthenticated, emit a LOCKED(storage_corrupt) status,
+ /// and refuse to call setAdminAuthorized — otherwise a subsequent
+ /// set_config would re-encrypt a wrong baseline (the locked-default
+ /// values still resident in `config` / `channelFile` / `nodeDatabase`)
+ /// and overwrite the operator's persisted state.
+ bool reloadFromDisk();
+
+ /// Disable lockdown: decrypt every encrypted pref file back to plaintext,
+ /// then remove the DEK / token / counter / backoff artifacts. Requires
+ /// EncryptedStorage to be unlocked (DEK in RAM). Returns false if any
+ /// file failed to revert — in which case the DEK is still present and the
+ /// device remains in lockdown so the operator can retry. APPROTECT is not
+ /// reversed. Called from the main loop via lockdownDisablePending.
+ bool disableLockdownToPlaintext();
+
+ /// Set by loadProto when any encrypted file fails to decrypt or decode.
+ /// Tracked across an entire loadFromDisk pass so reloadFromDisk can
+ /// surface the condition without callers re-walking each loadProto
+ /// result. Cleared at the top of every loadFromDisk run.
+ bool storageCorruptThisLoad = false;
+#endif
+
private:
mutable concurrency::Lock satelliteMutex;
bool duplicateWarned = false;
diff --git a/src/mesh/PhoneAPI.cpp b/src/mesh/PhoneAPI.cpp
index 349069de2..d37a3e837 100644
--- a/src/mesh/PhoneAPI.cpp
+++ b/src/mesh/PhoneAPI.cpp
@@ -3,6 +3,12 @@
#include "GPS.h"
#endif
+#ifdef MESHTASTIC_ENCRYPTED_STORAGE
+#include "security/EncryptedStorage.h"
+#endif
+#ifdef MESHTASTIC_LOCKDOWN
+#include "security/LockdownDisplay.h"
+#endif
#include "Channels.h"
#include "Default.h"
#include "FSCommon.h"
@@ -36,6 +42,194 @@
// Flag to indicate a heartbeat was received and we should send queue status
bool heartbeatReceived = false;
+#ifdef MESHTASTIC_PHONEAPI_ACCESS_CONTROL
+// Auth-slot table and status-slot table are both sized to the typical
+// SerialConsole + BluetoothPhoneAPI footprint plus room for WiFi/TCP
+// transports. Sized together so both tables are keyed identically.
+static constexpr size_t MAX_AUTH_SLOTS = 6;
+
+// Per-PhoneAPI pending LockdownStatus. One slot per connection so a
+// status produced for connection A (e.g. UNLOCKED with the active TTL,
+// or UNLOCK_FAILED with a backoff) cannot be drained by connection B,
+// which would otherwise learn that A just authenticated or just failed
+// — a real information leak across local clients.
+//
+// File-scope rather than a per-PhoneAPI member because adding any
+// non-trivial state directly to PhoneAPI broke USB-CDC enumeration on
+// the current nRF52 framework; the auth-slot table next door uses the
+// same workaround. Lifecycle is tied to the auth slot table — both are
+// keyed by PhoneAPI*, both are cleared together in clearAuthSlot_LH,
+// and both share g_authSlotsMutex.
+struct PendingStatusSlot {
+ PhoneAPI *who = nullptr;
+ meshtastic_LockdownStatus status = {};
+ bool hasPending = false;
+ // True between a successful passphrase verify and the main-loop
+ // reloadFromDisk that follows. While set, the connection is NOT
+ // yet authorized and no UNLOCKED status has been emitted — the
+ // client still sees LOCKED, and any admin op it tries is dropped
+ // by the existing unauth gates. Cleared either way by
+ // completePendingUnlocks once reload finishes.
+ bool pendingUnlockAfterReload = false;
+};
+static PendingStatusSlot g_statusSlots[MAX_AUTH_SLOTS];
+
+// Lock-held helpers ---------------------------------------------------------
+
+static PendingStatusSlot *findOrAllocStatusSlot_LH(PhoneAPI *p)
+{
+ if (!p)
+ return nullptr;
+ for (auto &s : g_statusSlots)
+ if (s.who == p)
+ return &s;
+ for (auto &s : g_statusSlots) {
+ if (s.who == nullptr) {
+ s.who = p;
+ s.hasPending = false;
+ s.pendingUnlockAfterReload = false;
+ memset(&s.status, 0, sizeof(s.status));
+ return &s;
+ }
+ }
+ // Mirror the auth-slot eviction policy: stale slots can be reused.
+ // A connection that lost its auth slot has nothing meaningful to be
+ // told via a pending status anyway. Never evict a slot mid-unlock
+ // (pendingUnlockAfterReload set) — completing that flow on the
+ // wrong PhoneAPI would authorize the wrong connection.
+ for (auto &s : g_statusSlots) {
+ if (!s.hasPending && !s.pendingUnlockAfterReload) {
+ s.who = p;
+ memset(&s.status, 0, sizeof(s.status));
+ return &s;
+ }
+ }
+ return nullptr;
+}
+
+static void clearStatusSlot_LH(const PhoneAPI *p)
+{
+ if (!p)
+ return;
+ for (auto &s : g_statusSlots) {
+ if (s.who == p) {
+ s.who = nullptr;
+ s.hasPending = false;
+ s.pendingUnlockAfterReload = false;
+ memset(&s.status, 0, sizeof(s.status));
+ return;
+ }
+ }
+}
+
+// Build a LockdownStatus message under lock from the supplied fields,
+// applying the audit's M13 redaction so token_* tamper-detection
+// strings are not leaked to unauth clients over the wire.
+static void buildStatus_LH(meshtastic_LockdownStatus &out, meshtastic_LockdownStatus_State state, const char *lock_reason,
+ uint8_t boots_remaining, uint32_t valid_until_epoch, uint32_t backoff_seconds)
+{
+ memset(&out, 0, sizeof(out));
+ out.state = state;
+ // Collapse the specific token_* reasons to a generic "locked" over
+ // the wire — full detail still goes to local logs. An unauth client
+ // does not need to know whether HMAC failed vs the boot count
+ // hit zero vs the file was the wrong size; all of those mean the
+ // same thing to the client ("locked, ask for passphrase") but
+ // telling them apart over the network lets an attacker confirm
+ // that their tampering or rollback attempt was noticed.
+ const char *wireReason = lock_reason;
+ if (state == meshtastic_LockdownStatus_State_LOCKED && wireReason && wireReason[0] != '\0') {
+ if (strncmp(wireReason, "token_", 6) == 0)
+ wireReason = "locked";
+ }
+ if (wireReason && wireReason[0] != '\0')
+ strncpy(out.lock_reason, wireReason, sizeof(out.lock_reason) - 1);
+ out.boots_remaining = boots_remaining;
+ out.valid_until_epoch = valid_until_epoch;
+ out.backoff_seconds = backoff_seconds;
+}
+
+// Per-connection auth state table keyed by PhoneAPI*. Searched linearly;
+// cost is negligible compared to the redaction gates that call it.
+struct PhoneAuthSlot {
+ PhoneAPI *who = nullptr;
+ bool authorized = false;
+ uint32_t epoch = 0;
+};
+static PhoneAuthSlot g_authSlots[MAX_AUTH_SLOTS];
+
+// Global auth epoch. Lock Now bumps it; per-slot `epoch` compared against
+// this. Wraps at 2^32 revocations — practically unreachable; on wrap the
+// only behavioral effect is that any slot whose epoch happens to match the
+// new low value would be treated as authorized again, which requires a
+// pre-existing authorized slot to survive 2^32 lockNow events on the same
+// boot.
+static uint32_t g_authEpoch = 1;
+
+// Single mutex guarding g_authSlots and g_authEpoch. All readers and
+// writers — including const getters like getAdminAuthorized — must take
+// it. Granularity is fine because the critical sections are short (a
+// fixed-size linear scan over 6 entries) and contention is dominated by
+// getFromRadio's per-call redaction checks, which tolerate brief
+// blocking.
+static concurrency::Lock g_authSlotsMutex;
+
+// Find or allocate the auth slot for `p`. Caller must hold g_authSlotsMutex.
+// When the table is full of *unauthorized* slots from prior dead PhoneAPIs,
+// evicts the first unauthorized slot found. Refuses to evict an authorized
+// slot (those represent a live operator session and must outlive the table
+// pressure of reconnect churn). Returns nullptr only if every slot is
+// occupied by a different live, authorized PhoneAPI — practically only
+// reachable as a DoS via 7+ simultaneous authed connections, in which
+// case fail-closed and log.
+static PhoneAuthSlot *findOrAllocSlot_LH(PhoneAPI *p)
+{
+ if (!p)
+ return nullptr;
+ for (auto &s : g_authSlots)
+ if (s.who == p)
+ return &s;
+ // First pass: free (who==nullptr) slot.
+ for (auto &s : g_authSlots) {
+ if (s.who == nullptr) {
+ s.who = p;
+ s.authorized = false;
+ s.epoch = 0;
+ return &s;
+ }
+ }
+ // Second pass: evict an unauthorized stale slot. Don't touch authorized
+ // ones — those still represent an operator-authenticated session.
+ for (auto &s : g_authSlots) {
+ if (!s.authorized) {
+ s.who = p;
+ s.epoch = 0;
+ LOG_WARN("Lockdown: auth slot table full, evicted stale unauthorized slot for new PhoneAPI %p", p);
+ return &s;
+ }
+ }
+ LOG_WARN("Lockdown: auth slot table full of authorized sessions, refusing new PhoneAPI %p (fail-closed)", p);
+ return nullptr;
+}
+
+// Drop p's slot from both the auth table and the status-queue table.
+// Lock-held variant.
+static void clearAuthSlot_LH(const PhoneAPI *p)
+{
+ if (!p)
+ return;
+ for (auto &s : g_authSlots) {
+ if (s.who == p) {
+ s.authorized = false;
+ s.epoch = 0;
+ s.who = nullptr;
+ break;
+ }
+ }
+ clearStatusSlot_LH(p);
+}
+#endif
+
PhoneAPI::PhoneAPI()
{
lastContactMsec = millis();
@@ -45,6 +239,17 @@ PhoneAPI::PhoneAPI()
PhoneAPI::~PhoneAPI()
{
close();
+#ifdef MESHTASTIC_PHONEAPI_ACCESS_CONTROL
+ // Free the auth slot unconditionally, regardless of whether close()'s
+ // slot-clear branch ran (it skips when state == STATE_SEND_NOTHING).
+ // Leaving a stale slot.who pointing at freed memory lets a future
+ // PhoneAPI heap-allocated at the same address inherit the prior
+ // session's authorization through findOrAllocSlot.
+ {
+ concurrency::LockGuard g(&g_authSlotsMutex);
+ clearAuthSlot_LH(this);
+ }
+#endif
}
void PhoneAPI::handleStartConfig()
@@ -55,6 +260,28 @@ void PhoneAPI::handleStartConfig()
observe(&service->fromNumChanged);
#ifdef FSCom
observe(&xModem.packetReady);
+#endif
+#ifdef MESHTASTIC_PHONEAPI_ACCESS_CONTROL
+ // New physical connection: clear this PhoneAPI's auth slot so the new
+ // client must present a passphrase or PKC admin signature before
+ // seeing full config. Do NOT reset on a subsequent want_config_id
+ // within the same connection: after a successful unlock the client
+ // re-requests config to pull the now-unredacted values, and re-locking
+ // that same-link re-fetch would strip the auth it just earned (config
+ // comes back redacted and set_config writes get dropped).
+ //
+ // The security boundary is therefore the physical connection, not the
+ // want_config handshake. For BLE that boundary is enforced in
+ // onConnect() (which fires once per link and also resets the slot), so
+ // a reconnect re-locks even if this !isConnected() transition was
+ // missed because the prior link's close() raced the new config burst.
+ {
+ concurrency::LockGuard g(&g_authSlotsMutex);
+ if (auto *slot = findOrAllocSlot_LH(this)) {
+ slot->authorized = false;
+ slot->epoch = 0;
+ }
+ }
#endif
}
@@ -157,6 +384,12 @@ void PhoneAPI::close()
config_state = 0;
pauseBluetoothLogging = false;
heartbeatReceived = false;
+#ifdef MESHTASTIC_PHONEAPI_ACCESS_CONTROL
+ {
+ concurrency::LockGuard g(&g_authSlotsMutex);
+ clearAuthSlot_LH(this);
+ }
+#endif
}
}
@@ -185,6 +418,26 @@ 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 (!getAdminAuthorized()) {
+ // 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.
+ // Refuse to match when our own node number is still 0 (NodeDB
+ // not yet loaded — happens during the locked-default boot path
+ // before reloadFromDisk). Otherwise a packet with to==0 would
+ // satisfy the equality and bypass the gate.
+ NodeNum ourNum = nodeDB->getNodeNum();
+ bool isLocalAdmin =
+ ourNum != 0 && toRadioScratch.packet.which_payload_variant == meshtastic_MeshPacket_decoded_tag &&
+ toRadioScratch.packet.decoded.portnum == meshtastic_PortNum_ADMIN_APP && toRadioScratch.packet.to == ourNum;
+ if (!isLocalAdmin) {
+ LOG_INFO("Lockdown: 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;
@@ -196,6 +449,12 @@ bool PhoneAPI::handleToRadio(const uint8_t *buf, size_t bufLength)
close();
break;
case meshtastic_ToRadio_xmodemPacket_tag:
+#ifdef MESHTASTIC_PHONEAPI_ACCESS_CONTROL
+ if (!getAdminAuthorized()) {
+ LOG_INFO("Lockdown: Dropping xmodem packet from unauthorized client");
+ break;
+ }
+#endif
LOG_INFO("Got xmodem packet");
#ifdef FSCom
xModem.handlePacket(toRadioScratch.xmodemPacket);
@@ -298,6 +557,21 @@ size_t PhoneAPI::getFromRadio(uint8_t *buf)
strncpy(myNodeInfo.pio_env, optstr(APP_ENV), sizeof(myNodeInfo.pio_env));
myNodeInfo.nodedb_count = static_cast(nodeDB->getNumMeshNodes());
fromRadioScratch.my_info = myNodeInfo;
+#ifdef MESHTASTIC_PHONEAPI_ACCESS_CONTROL
+ if (!getAdminAuthorized()) {
+ // device_id is a stable hardware identifier — useful for an attacker
+ // to fingerprint / correlate the device across observations. Strip it
+ // for unauthenticated clients. my_node_num is kept (it's broadcast
+ // on the mesh anyway). pio_env / min_app_version reveal the exact
+ // build flavour, useful only for picking which known-CVE to try.
+ // nodedb_count stays — clients need it to decide whether to pull
+ // the node DB after unlocking.
+ fromRadioScratch.my_info.device_id.size = 0;
+ memset(fromRadioScratch.my_info.device_id.bytes, 0, sizeof(fromRadioScratch.my_info.device_id.bytes));
+ memset(fromRadioScratch.my_info.pio_env, 0, sizeof(fromRadioScratch.my_info.pio_env));
+ fromRadioScratch.my_info.min_app_version = 0;
+ }
+#endif
state = STATE_SEND_UIDATA;
service->refreshLocalMeshNode(); // Update my NodeInfo because the client will be asking for it soon.
@@ -331,8 +605,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 (!getAdminAuthorized()) {
+ state = STATE_SEND_COMPLETE_ID; // Unauthorized: skip node DB
+ } else
+#endif
+ {
+ state = STATE_SEND_OTHER_NODEINFOS;
+ onNowHasData(0);
+ }
} else {
state = STATE_SEND_METADATA;
}
@@ -343,12 +624,35 @@ size_t PhoneAPI::getFromRadio(uint8_t *buf)
LOG_DEBUG("Send device metadata");
fromRadioScratch.which_payload_variant = meshtastic_FromRadio_metadata_tag;
fromRadioScratch.metadata = getDeviceMetadata();
+#ifdef MESHTASTIC_PHONEAPI_ACCESS_CONTROL
+ if (!getAdminAuthorized()) {
+ // DeviceMetadata is one large fingerprint vector for an unauth
+ // client: firmware_version, device_state_version, hw_model,
+ // hw_model_string, has_bluetooth/has_wifi/has_ethernet, role,
+ // position_flags, excluded_modules, optionsCount. None of it
+ // is needed to drive lockdown_auth, and most of it tells an
+ // attacker which CVE / behavior quirks to probe. Wipe the
+ // whole struct — clients re-fetch once authenticated.
+ memset(&fromRadioScratch.metadata, 0, sizeof(fromRadioScratch.metadata));
+ }
+#endif
state = STATE_SEND_CHANNELS;
break;
case STATE_SEND_CHANNELS:
fromRadioScratch.which_payload_variant = meshtastic_FromRadio_channel_tag;
- fromRadioScratch.channel = channels.getByIndex(config_state);
+#ifdef MESHTASTIC_PHONEAPI_ACCESS_CONTROL
+ if (!getAdminAuthorized()) {
+ // Unauthenticated: emit a zero-initialized Channel. fromRadioScratch
+ // was memset(0) at the top of getFromRadio(), so leaving .channel
+ // untouched gives the client an empty entry — no name, no PSK, no
+ // role. Advances the state machine normally so config_complete_id
+ // still fires.
+ } 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) {
@@ -380,7 +684,15 @@ size_t PhoneAPI::getFromRadio(uint8_t *buf)
case meshtastic_Config_network_tag:
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 (!getAdminAuthorized()) {
+ // Unauthenticated: emit an empty NetworkConfig (zero-init from the
+ // top-of-loop memset). No wifi_psk, no SSID, no static IP info.
+ } else
+#endif
+ {
+ fromRadioScratch.config.payload_variant.network = config.network;
+ }
break;
case meshtastic_Config_display_tag:
LOG_DEBUG("Send config: display");
@@ -390,7 +702,28 @@ size_t PhoneAPI::getFromRadio(uint8_t *buf)
case meshtastic_Config_lora_tag:
LOG_DEBUG("Send config: lora");
fromRadioScratch.config.which_payload_variant = meshtastic_Config_lora_tag;
- fromRadioScratch.config.payload_variant.lora = config.lora;
+#ifdef MESHTASTIC_PHONEAPI_ACCESS_CONTROL
+ if (!getAdminAuthorized()) {
+ // Whitelist only the spec-mandated radio identity fields that
+ // are intrinsically observable on the air anyway: region,
+ // modem_preset, use_preset, channel_num, hop_limit. Operator-
+ // private knobs (ignore_incoming list, override_duty_cycle,
+ // override_frequency, sx126x_rx_boosted_gain, tx_power,
+ // ignore_mqtt, fem_lna_mode, config_ok_to_mqtt, ...) stay
+ // hidden — they tell an attacker how the operator has tuned
+ // the device but are not needed by an unauth client.
+ meshtastic_Config_LoRaConfig whitelist = {};
+ whitelist.use_preset = config.lora.use_preset;
+ whitelist.modem_preset = config.lora.modem_preset;
+ whitelist.region = config.lora.region;
+ whitelist.channel_num = config.lora.channel_num;
+ whitelist.hop_limit = config.lora.hop_limit;
+ fromRadioScratch.config.payload_variant.lora = whitelist;
+ } else
+#endif
+ {
+ fromRadioScratch.config.payload_variant.lora = config.lora;
+ }
break;
case meshtastic_Config_bluetooth_tag:
LOG_DEBUG("Send config: bluetooth");
@@ -400,7 +733,21 @@ size_t PhoneAPI::getFromRadio(uint8_t *buf)
case meshtastic_Config_security_tag:
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 (!getAdminAuthorized()) {
+ // Unauthenticated: emit an empty SecurityConfig (zero-init from
+ // the top-of-loop memset). No private_key, no admin_keys, no
+ // public_key — nothing for an attacker to inspect.
+ //
+ // Provisioning state (NEEDS_PROVISION vs LOCKED) is conveyed via
+ // the FromRadio.lockdown_status proto sent post-config; clients
+ // should consume that rather than inferring from this empty
+ // security config.
+ } else
+#endif
+ {
+ fromRadioScratch.config.payload_variant.security = config.security;
+ }
break;
case meshtastic_Config_sessionkey_tag:
LOG_DEBUG("Send config: sessionkey");
@@ -430,7 +777,17 @@ size_t PhoneAPI::getFromRadio(uint8_t *buf)
case meshtastic_ModuleConfig_mqtt_tag:
LOG_DEBUG("Send module config: mqtt");
fromRadioScratch.moduleConfig.which_payload_variant = meshtastic_ModuleConfig_mqtt_tag;
- fromRadioScratch.moduleConfig.payload_variant.mqtt = moduleConfig.mqtt;
+#ifdef MESHTASTIC_PHONEAPI_ACCESS_CONTROL
+ if (!getAdminAuthorized()) {
+ // Unauthenticated: emit an empty MQTTConfig (zero-init from
+ // the top-of-loop memset). MQTT broker username/password, the
+ // server address, and root_topic are credentials/config that
+ // shouldn't be visible to an unauth client.
+ } else
+#endif
+ {
+ fromRadioScratch.moduleConfig.payload_variant.mqtt = moduleConfig.mqtt;
+ }
break;
case meshtastic_ModuleConfig_serial_tag:
LOG_DEBUG("Send module config: serial");
@@ -509,15 +866,21 @@ 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)) {
- // 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
- if (config_nonce == SPECIAL_NONCE_ONLY_CONFIG) {
- state = STATE_SEND_FILEMANIFEST;
- } else {
- state = STATE_SEND_OTHER_NODEINFOS;
- onNowHasData(0);
- }
+#ifdef MESHTASTIC_PHONEAPI_ACCESS_CONTROL
+ if (!getAdminAuthorized()) {
+ // 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
+ if (config_nonce == SPECIAL_NONCE_ONLY_CONFIG) {
+ state = STATE_SEND_FILEMANIFEST;
+ } else {
+ state = STATE_SEND_OTHER_NODEINFOS;
+ onNowHasData(0);
+ }
config_state = 0;
}
break;
@@ -590,24 +953,58 @@ 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 (!getAdminAuthorized()) {
+ 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 (!getAdminAuthorized()) {
+ 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;
+ }
+#ifdef MESHTASTIC_PHONEAPI_ACCESS_CONTROL
+ } else if (hasPendingLockdownStatus()) {
+ concurrency::LockGuard guard(&g_authSlotsMutex);
+ // Look up our own slot only — never another connection's. Re-check
+ // hasPending under the lock since a concurrent drain on the same
+ // connection (unlikely but possible if multiple transport
+ // callbacks race against one PhoneAPI) may have grabbed it.
+ if (auto *slot = findOrAllocStatusSlot_LH(this); slot && slot->hasPending) {
+ fromRadioScratch.which_payload_variant = meshtastic_FromRadio_lockdown_status_tag;
+ fromRadioScratch.lockdown_status = slot->status;
+ memset(&slot->status, 0, sizeof(slot->status));
+ slot->hasPending = false;
+ }
+#endif
} else if (clientNotification) {
fromRadioScratch.which_payload_variant = meshtastic_FromRadio_clientNotification_tag;
fromRadioScratch.clientNotification = *clientNotification;
releaseClientNotification();
} else if (packetForPhone) {
- printPacket("phone downloaded packet", packetForPhone);
-
- // Encapsulate as a FromRadio packet
- fromRadioScratch.which_payload_variant = meshtastic_FromRadio_packet_tag;
- fromRadioScratch.packet = *packetForPhone;
- releasePhonePacket();
+#ifdef MESHTASTIC_PHONEAPI_ACCESS_CONTROL
+ if (!getAdminAuthorized()) {
+ 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();
+ }
} else if (replayPending()) {
// No live packet pending — feed the phone one cached satellite-DB packet.
// popReplayPacket advances through positions->telemetry->environment->status,
@@ -669,6 +1066,29 @@ void PhoneAPI::sendConfigComplete()
service->api_state = service->STATE_ETH;
}
+#if defined(MESHTASTIC_ENCRYPTED_STORAGE) && defined(MESHTASTIC_PHONEAPI_ACCESS_CONTROL)
+ if (!EncryptedStorage::isLockdownActive()) {
+ // Lockdown-capable firmware, but lockdown is not active on this
+ // device (never provisioned, or disabled). Tell the client so its
+ // "lockdown mode" toggle renders OFF. Note getAdminAuthorized()
+ // returns true in this state, so the redaction gates are no-ops and
+ // the client just received the full, unredacted config above.
+ queueLockdownStatus(meshtastic_LockdownStatus_State_DISABLED, "", 0, 0, 0);
+ LOG_INFO("PhoneAPI: DISABLED (lockdown not active) sent to client");
+ } else if (!getAdminAuthorized()) {
+ if (!EncryptedStorage::isProvisioned()) {
+ queueLockdownStatus(meshtastic_LockdownStatus_State_NEEDS_PROVISION, "", 0, 0, 0);
+ LOG_INFO("PhoneAPI: NEEDS_PROVISION sent to client");
+ } else if (!EncryptedStorage::isUnlocked()) {
+ queueLockdownStatus(meshtastic_LockdownStatus_State_LOCKED, EncryptedStorage::getLockReason(), 0, 0, 0);
+ LOG_INFO("PhoneAPI: LOCKED (%s) sent to client", EncryptedStorage::getLockReason());
+ } else {
+ queueLockdownStatus(meshtastic_LockdownStatus_State_LOCKED, "needs_auth", 0, 0, 0);
+ LOG_INFO("PhoneAPI: LOCKED (needs_auth) sent to client");
+ }
+ }
+#endif
+
// Allow subclasses to know we've entered steady-state so they can lower power consumption
onConfigComplete();
@@ -1121,6 +1541,10 @@ bool PhoneAPI::available()
if (!clientNotification)
clientNotification = service->getClientNotificationForPhone();
bool hasPacket = !!queueStatusPacketForPhone || !!mqttClientProxyMessageForPhone || !!clientNotification;
+#ifdef MESHTASTIC_PHONEAPI_ACCESS_CONTROL
+ if (hasPendingLockdownStatus())
+ hasPacket = true;
+#endif
if (hasPacket)
return true;
@@ -1192,6 +1616,46 @@ bool PhoneAPI::handleToRadioPacket(meshtastic_MeshPacket &p)
{
printPacket("PACKET FROM PHONE", &p);
+#if defined(MESHTASTIC_ENCRYPTED_STORAGE) && defined(MESHTASTIC_PHONEAPI_ACCESS_CONTROL)
+ // Local admin gating happens here, synchronously on the dispatching
+ // task. Two distinct cases:
+ //
+ // (a) lockdown_auth: handled inline. Passphrase never enters the
+ // routed MeshPacket queue, and authorize-this-connection
+ // runs while `this` is still on the call stack.
+ //
+ // (b) Any other admin payload from an unauthorized connection:
+ // dropped here. The previous design relied on AdminModule
+ // to apply isLocalAdminAuthorized() during dispatch, but
+ // AdminModule runs on the Router task — by then the
+ // PhoneAPI dispatching task has already exited and the
+ // per-connection auth context is unrecoverable. Putting
+ // the gate here closes that race and covers H6/H7 from the
+ // audit: get_config_request and set_config from unauthed
+ // clients no longer reach AdminModule at all.
+ if (p.from == 0 && p.which_payload_variant == meshtastic_MeshPacket_decoded_tag &&
+ p.decoded.portnum == meshtastic_PortNum_ADMIN_APP) {
+ meshtastic_AdminMessage admin = meshtastic_AdminMessage_init_zero;
+ if (pb_decode_from_bytes(p.decoded.payload.bytes, p.decoded.payload.size, &meshtastic_AdminMessage_msg, &admin)) {
+ if (admin.which_payload_variant == meshtastic_AdminMessage_lockdown_auth_tag) {
+ handleLockdownAuthInline(admin.lockdown_auth);
+ // Wipe the decoded passphrase scratch — the byte array in
+ // p.decoded.payload.bytes is wiped by handleLockdownAuthInline.
+ volatile uint8_t *adminVol = const_cast(admin.lockdown_auth.passphrase.bytes);
+ for (size_t i = 0; i < sizeof(admin.lockdown_auth.passphrase.bytes); i++)
+ adminVol[i] = 0;
+ return true;
+ }
+ if (!getAdminAuthorized()) {
+ LOG_WARN("Lockdown: dropping admin payload variant=%d from unauthorized connection", admin.which_payload_variant);
+ return false;
+ }
+ }
+ // pb_decode failure: fall through to normal handling so the
+ // regular Router/AdminModule reject path can respond.
+ }
+#endif
+
#if defined(ARCH_PORTDUINO)
// For use with the simulator, we should not ignore duplicate packets from the phone
if (SimRadio::instance == nullptr)
@@ -1260,3 +1724,314 @@ 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::getAdminAuthorized() const
+{
+ // Runtime-toggle model: when lockdown is NOT active (a lockdown-capable
+ // build that hasn't been provisioned, or that was disabled), there is
+ // nothing to protect — every connection is implicitly authorized, so
+ // all the `if (!getAdminAuthorized())` redaction gates throughout
+ // getFromRadio() / handleToRadio() become no-ops and the device serves
+ // config exactly like stock firmware. Only once provisioned (lockdown
+ // active) do we consult the per-connection auth slot table.
+#ifdef MESHTASTIC_ENCRYPTED_STORAGE
+ if (!EncryptedStorage::isLockdownActive())
+ return true;
+#endif
+ concurrency::LockGuard g(&g_authSlotsMutex);
+ // const_cast is safe — findOrAllocSlot_LH only mutates the slot table,
+ // not the PhoneAPI itself, and the table key is just the pointer.
+ const auto *slot = findOrAllocSlot_LH(const_cast(this));
+ return slot && slot->authorized && slot->epoch == g_authEpoch;
+}
+
+void PhoneAPI::setAdminAuthorized(bool authorized)
+{
+ concurrency::LockGuard g(&g_authSlotsMutex);
+ auto *slot = findOrAllocSlot_LH(this);
+ if (!slot)
+ return; // slot table full — fail-closed
+ if (authorized) {
+ slot->epoch = g_authEpoch;
+ slot->authorized = true;
+ } else {
+ slot->authorized = false;
+ slot->epoch = 0;
+ }
+}
+
+void PhoneAPI::revokeAllAuth()
+{
+ {
+ concurrency::LockGuard g(&g_authSlotsMutex);
+ g_authEpoch++;
+ }
+ LOG_INFO("Lockdown: All connection auth revoked (Lock Now)");
+}
+
+void PhoneAPI::completePendingUnlocks(bool reloadOk)
+{
+ // Snapshot fields that we'll need outside the lock (we cannot call
+ // EncryptedStorage / setAdminAuthorized / unlockScreen while holding
+ // g_authSlotsMutex without risking re-entry — setAdminAuthorized
+ // itself takes the same lock).
+ constexpr size_t kMaxSnapshots = MAX_AUTH_SLOTS;
+ PhoneAPI *targets[kMaxSnapshots] = {};
+ size_t targetCount = 0;
+ {
+ concurrency::LockGuard guard(&g_authSlotsMutex);
+ for (auto &s : g_statusSlots) {
+ if (!s.pendingUnlockAfterReload || !s.who)
+ continue;
+ if (targetCount < kMaxSnapshots)
+ targets[targetCount++] = s.who;
+ // Clear the pending flag either way — failure path must not
+ // leave it set so a subsequent successful reload retries
+ // against the wrong PhoneAPI.
+ s.pendingUnlockAfterReload = false;
+ }
+ }
+
+ if (reloadOk) {
+ uint8_t boots = EncryptedStorage::getBootsRemaining();
+ uint32_t until = EncryptedStorage::getValidUntilEpoch();
+ for (size_t i = 0; i < targetCount; i++) {
+ PhoneAPI *p = targets[i];
+ p->setAdminAuthorized(true);
+ p->queueLockdownStatus(meshtastic_LockdownStatus_State_UNLOCKED, "", boots, until, 0);
+ }
+ // Screen-lock latch is cleared once any client successfully
+ // unlocks — the operator has proven the passphrase. Matches the
+ // re-verify path's behavior.
+ if (targetCount > 0)
+ meshtastic_security::unlockScreen();
+ LOG_INFO("Lockdown: post-reload completion: authorized %u connection(s)", (unsigned)targetCount);
+ } else {
+ // Storage corrupt — emit LOCKED(storage_corrupt) to every slot
+ // that was awaiting the unlock. setAdminAuthorized is NOT called
+ // so the connection stays redacted and any set_config it sends
+ // is dropped at the existing unauth gates. Caller (main.cpp) has
+ // already lockNow'd storage and broadcast-revoked.
+ for (size_t i = 0; i < targetCount; i++) {
+ targets[i]->queueLockdownStatus(meshtastic_LockdownStatus_State_LOCKED, "storage_corrupt", 0, 0, 0);
+ }
+ LOG_ERROR("Lockdown: post-reload completion: storage corrupt, notified %u connection(s)", (unsigned)targetCount);
+ }
+}
+
+void PhoneAPI::queueLockdownStatus(meshtastic_LockdownStatus_State state, const char *lock_reason, uint8_t boots_remaining,
+ uint32_t valid_until_epoch, uint32_t backoff_seconds)
+{
+ {
+ concurrency::LockGuard guard(&g_authSlotsMutex);
+ auto *slot = findOrAllocStatusSlot_LH(this);
+ if (!slot)
+ return; // slot table exhausted — fail-closed, no status delivered
+ buildStatus_LH(slot->status, state, lock_reason, boots_remaining, valid_until_epoch, backoff_seconds);
+ slot->hasPending = true;
+ }
+ if (service)
+ service->nudgeFromNum();
+}
+
+void PhoneAPI::broadcastLockdownStatus(meshtastic_LockdownStatus_State state, const char *lock_reason, uint8_t boots_remaining,
+ uint32_t valid_until_epoch, uint32_t backoff_seconds)
+{
+ bool anyOverwritten = false;
+ {
+ concurrency::LockGuard guard(&g_authSlotsMutex);
+ for (auto &s : g_statusSlots) {
+ if (s.who) {
+ buildStatus_LH(s.status, state, lock_reason, boots_remaining, valid_until_epoch, backoff_seconds);
+ s.hasPending = true;
+ anyOverwritten = true;
+ }
+ }
+ }
+ // Service nudge is shared across connections; one nudge wakes every
+ // drainer. Skip if no connection currently has a slot.
+ if (anyOverwritten && service)
+ service->nudgeFromNum();
+}
+
+bool PhoneAPI::hasPendingLockdownStatus() const
+{
+ concurrency::LockGuard guard(&g_authSlotsMutex);
+ for (const auto &s : g_statusSlots) {
+ if (s.who == this && s.hasPending)
+ return true;
+ }
+ return false;
+}
+
+#ifdef MESHTASTIC_ENCRYPTED_STORAGE
+bool PhoneAPI::handleLockdownAuthInline(const meshtastic_LockdownAuth &la)
+{
+ // Wipe passphrase bytes in the caller's decoded scratch on every exit.
+ auto zeroPassphrase = [&]() {
+ volatile uint8_t *ppVol = const_cast(la.passphrase.bytes);
+ for (pb_size_t zi = 0; zi < la.passphrase.size; zi++)
+ ppVol[zi] = 0;
+ };
+
+ // Lock Now — only honored from a connection that has already proven
+ // the passphrase. Unauthenticated clients used to be able to trigger
+ // a reboot, which was a trivial local-presence DoS (any BLE/USB
+ // attacker could brick-loop the device). Now lock_now requires
+ // prior auth on this connection.
+ if (la.lock_now) {
+ if (!getAdminAuthorized()) {
+ LOG_WARN("Lockdown: LOCK NOW from unauthorized connection — denied");
+ queueLockdownStatus(meshtastic_LockdownStatus_State_UNLOCK_FAILED, "", 0, 0, 0);
+ zeroPassphrase();
+ return true;
+ }
+ LOG_INFO("Lockdown: LOCK NOW command received from authorized connection");
+ EncryptedStorage::lockNow();
+ revokeAllAuth();
+ queueLockdownStatus(meshtastic_LockdownStatus_State_LOCKED, "", 0, 0, 0);
+ zeroPassphrase();
+ rebootAtMsec = millis() + DEFAULT_REBOOT_SECONDS * 1000;
+ return true;
+ }
+
+ // Disable lockdown entirely. Requires the passphrase (must prove
+ // ownership before reverting at-rest encryption). We verify it here to
+ // load the DEK, then hand the heavy decrypt-revert work to the main
+ // loop via lockdownDisablePending — exactly like the unlock reload
+ // path, because decrypting + rewriting nodes.proto is too heavy for
+ // this transport-callback stack. APPROTECT is NOT reversed.
+ if (la.disable) {
+ if (la.passphrase.size < 1) {
+ LOG_WARN("Lockdown: disable with empty passphrase — rejecting");
+ queueLockdownStatus(meshtastic_LockdownStatus_State_UNLOCK_FAILED, "", 0, 0, 0);
+ zeroPassphrase();
+ return true;
+ }
+ if (!EncryptedStorage::isLockdownActive()) {
+ // Already off — nothing to do; report DISABLED so the client UI settles.
+ LOG_INFO("Lockdown: disable requested but lockdown is not active");
+ queueLockdownStatus(meshtastic_LockdownStatus_State_DISABLED, "", 0, 0, 0);
+ zeroPassphrase();
+ return true;
+ }
+ // Re-verify the passphrase (loads the DEK needed to decrypt files).
+ bool ok = EncryptedStorage::unlockWithPassphrase(la.passphrase.bytes, la.passphrase.size,
+ EncryptedStorage::TOKEN_DEFAULT_BOOTS, 0, 0);
+ if (!ok) {
+ uint32_t backoff = EncryptedStorage::getBackoffSecondsRemaining();
+ queueLockdownStatus(meshtastic_LockdownStatus_State_UNLOCK_FAILED, "", 0, 0, backoff);
+ LOG_WARN("Lockdown: disable passphrase verification failed");
+ zeroPassphrase();
+ return true;
+ }
+ setAdminAuthorized(true);
+ lockdownDisablePending = true; // main loop runs nodeDB->disableLockdownToPlaintext() then reboots
+ LOG_INFO("Lockdown: disable authorized, deferring decrypt-revert to main loop");
+ zeroPassphrase();
+ return true;
+ }
+
+ // Empty-passphrase auth was previously a silent success — clients
+ // got no feedback and the device looked the same as it would after
+ // an actual no-op. Emit UNLOCK_FAILED with no backoff so honest
+ // clients can detect their own bug and an attacker still learns
+ // nothing they wouldn't from any other failed attempt.
+ if (la.passphrase.size < 1) {
+ LOG_WARN("Lockdown: lockdown_auth with empty passphrase and lock_now=false — rejecting");
+ queueLockdownStatus(meshtastic_LockdownStatus_State_UNLOCK_FAILED, "", 0, 0, 0);
+ zeroPassphrase();
+ return true;
+ }
+
+ // boots_remaining is uint32 on the wire but the token field is uint8.
+ // Silently truncating (256 -> 0 -> default 50) hides a real client
+ // bug. Reject explicitly so the client can correct its request.
+ if (la.boots_remaining > 255) {
+ LOG_WARN("Lockdown: boots_remaining=%u exceeds uint8 cap, rejecting", la.boots_remaining);
+ queueLockdownStatus(meshtastic_LockdownStatus_State_UNLOCK_FAILED, "", 0, 0, 0);
+ zeroPassphrase();
+ return true;
+ }
+
+ uint8_t boots = la.boots_remaining != 0 ? (uint8_t)la.boots_remaining : EncryptedStorage::TOKEN_DEFAULT_BOOTS;
+ uint32_t validUntilEpoch = la.valid_until_epoch;
+ // Client-supplied session cap when present; otherwise the
+ // firmware-side default. 0 from the client means "use firmware
+ // default", consistent with the boots_remaining sentinel.
+ uint32_t sessionMaxSeconds =
+ la.max_session_seconds != 0 ? la.max_session_seconds : MESHTASTIC_LOCKDOWN_SESSION_DEFAULT_SECONDS;
+
+ bool ok = false;
+ bool needsReload = false;
+ if (!EncryptedStorage::isUnlocked()) {
+ if (!EncryptedStorage::isProvisioned()) {
+ LOG_INFO("Lockdown: first-time provisioning with passphrase");
+ ok = EncryptedStorage::provisionPassphrase(la.passphrase.bytes, la.passphrase.size, boots, validUntilEpoch,
+ sessionMaxSeconds);
+ } else {
+ LOG_INFO("Lockdown: unlock with passphrase");
+ ok = EncryptedStorage::unlockWithPassphrase(la.passphrase.bytes, la.passphrase.size, boots, validUntilEpoch,
+ sessionMaxSeconds);
+ }
+ if (ok) {
+ needsReload = true;
+ // Mark this slot for the main-loop completion handler. Don't
+ // authorize or emit UNLOCKED yet — `config` / `channelFile`
+ // / `nodeDatabase` still hold the locked-default placeholders
+ // installed by loadFromDisk()'s !isUnlocked() branch. If we
+ // flipped the connection to authorized here, the client could
+ // read those placeholders as if they were the operator's real
+ // settings, or set_config write a corrupted baseline that
+ // overwrites the real config when reloadFromDisk swaps them
+ // in. completePendingUnlocks() runs on the main thread after
+ // reloadFromDisk has populated the real values and the radio
+ // has been reconfigured.
+ {
+ concurrency::LockGuard guard(&g_authSlotsMutex);
+ if (auto *slot = findOrAllocStatusSlot_LH(this))
+ slot->pendingUnlockAfterReload = true;
+ }
+ lockdownReloadPending = true;
+ LOG_INFO("Lockdown: storage unlocked, awaiting reload before client visibility");
+ }
+ } else {
+ LOG_INFO("Lockdown: passphrase re-verify for admin authorization");
+ ok = EncryptedStorage::unlockWithPassphrase(la.passphrase.bytes, la.passphrase.size, boots, validUntilEpoch,
+ sessionMaxSeconds);
+ if (ok) {
+ // Storage was already unlocked — no reload needed. Authorize
+ // and surface UNLOCKED to the client immediately.
+ setAdminAuthorized(true);
+ LOG_INFO("Lockdown: passphrase verified, this connection authorized");
+ }
+ }
+
+ if (ok && !needsReload) {
+ // Re-verify path: storage was already unlocked. Clear the screen
+ // latch and emit UNLOCKED now. The cold-unlock path defers both
+ // of these to completePendingUnlocks() once reloadFromDisk finishes.
+ meshtastic_security::unlockScreen();
+ queueLockdownStatus(meshtastic_LockdownStatus_State_UNLOCKED, "", EncryptedStorage::getBootsRemaining(),
+ EncryptedStorage::getValidUntilEpoch(), 0);
+ } else if (ok && needsReload) {
+ // Cold-unlock path: deliberately no status emission yet — the
+ // client keeps seeing LOCKED until completePendingUnlocks()
+ // runs after a successful reload.
+ } else {
+ uint32_t backoff = EncryptedStorage::getBackoffSecondsRemaining();
+ queueLockdownStatus(meshtastic_LockdownStatus_State_UNLOCK_FAILED, "", 0, 0, backoff);
+ // Don't log backoff seconds — the client receives it in the
+ // UNLOCK_FAILED status anyway, and in non-DEBUG_MUTE builds the
+ // numeric value would otherwise spill onto a USB-attached
+ // attacker's serial terminal alongside other diagnostic noise.
+ LOG_WARN("Lockdown: passphrase verification failed");
+ (void)backoff;
+ }
+
+ zeroPassphrase();
+ return true;
+}
+#endif // MESHTASTIC_ENCRYPTED_STORAGE
+#endif // MESHTASTIC_PHONEAPI_ACCESS_CONTROL
diff --git a/src/mesh/PhoneAPI.h b/src/mesh/PhoneAPI.h
index b1bd1fd23..cd5939b0c 100644
--- a/src/mesh/PhoneAPI.h
+++ b/src/mesh/PhoneAPI.h
@@ -4,6 +4,7 @@
#include "concurrency/Lock.h"
#include "mesh-pb-constants.h"
#include "meshtastic/portnums.pb.h"
+#include
#include
#include
#include
@@ -170,6 +171,49 @@ class PhoneAPI
bool isConnected() { return state != STATE_SEND_NOTHING; }
bool isSendingPackets() { return state == STATE_SEND_PACKETS; }
+#ifdef MESHTASTIC_PHONEAPI_ACCESS_CONTROL
+ /// Per-connection auth: tracked in a small file-scope slot table keyed
+ /// by PhoneAPI*. Adding state members directly to PhoneAPI broke
+ /// USB-CDC enumeration on current nRF52 framework — even one extra
+ /// per-instance uint32_t was enough. Keeping all state out-of-line
+ /// avoids the issue.
+ void setAdminAuthorized(bool authorized);
+ bool getAdminAuthorized() const;
+
+ /// Lock Now: O(1) invalidation of every connection's auth by advancing
+ /// the global epoch. Subsequent gate checks see slot.myEpoch != epoch
+ /// and treat the connection as unauthenticated.
+ static void revokeAllAuth();
+
+ /// Called from the main loop after NodeDB::reloadFromDisk() finishes.
+ /// On reloadOk=true: any connection marked pending-unlock-after-reload
+ /// is promoted to authorized and receives an UNLOCKED status; the
+ /// screen-lock latch clears. On reloadOk=false: those connections
+ /// receive a LOCKED(storage_corrupt) status and remain unauthorized
+ /// so they cannot drive set_config against the corrupt baseline.
+ static void completePendingUnlocks(bool reloadOk);
+
+ /// Queue a LockdownStatus FromRadio for THIS connection only. Each
+ /// PhoneAPI owns its own pending-status slot in a file-scope table
+ /// (file-scope because adding fields directly to PhoneAPI broke
+ /// USB-CDC enumeration on nRF52); a status produced here will not
+ /// be delivered to any other connection. `lock_reason` may be
+ /// nullptr / empty for non-LOCKED states.
+ void queueLockdownStatus(meshtastic_LockdownStatus_State state, const char *lock_reason, uint8_t boots_remaining,
+ uint32_t valid_until_epoch, uint32_t backoff_seconds);
+
+ /// Queue the same LockdownStatus on every active connection's slot.
+ /// Use for events with no specific originating connection (session
+ /// expiry tick in main.cpp, broadcast revocations, etc.). Per-
+ /// connection callers should prefer the instance method above to
+ /// avoid leaking one client's auth state to another.
+ static void broadcastLockdownStatus(meshtastic_LockdownStatus_State state, const char *lock_reason, uint8_t boots_remaining,
+ uint32_t valid_until_epoch, uint32_t backoff_seconds);
+
+ /// True iff this connection has a pending lockdown_status drain.
+ bool hasPendingLockdownStatus() const;
+#endif
+
protected:
/// Our fromradio packet while it is being assembled
meshtastic_FromRadio fromRadioScratch = {};
@@ -211,6 +255,20 @@ class PhoneAPI
APIType api_type = TYPE_NONE;
+#ifdef MESHTASTIC_PHONEAPI_ACCESS_CONTROL
+ // No per-instance auth members — see method-level note. All state lives
+ // in a file-scope slot table in PhoneAPI.cpp keyed by `this` pointer.
+
+ // Pending LockdownStatus storage is NOT a class member — having a
+ // meshtastic_LockdownStatus (~50 bytes with the char[33] lock_reason)
+ // as a PhoneAPI member broke USB-CDC enumeration on the nRF52 Adafruit
+ // framework. The exact mechanism wasn't pinned down, but moving the
+ // storage to a file-scope static in PhoneAPI.cpp side-steps it cleanly.
+ // Trade-off: all PhoneAPI instances share one pending slot. Acceptable
+ // because only one transport delivers a lockdown command at a time in
+ // any realistic scenario.
+#endif
+
private:
void releasePhonePacket();
@@ -248,6 +306,16 @@ class PhoneAPI
*/
bool handleToRadioPacket(meshtastic_MeshPacket &p);
+#if defined(MESHTASTIC_ENCRYPTED_STORAGE) && defined(MESHTASTIC_PHONEAPI_ACCESS_CONTROL)
+ /// Synchronously handle a lockdown_auth AdminMessage from the local
+ /// client. Runs inside handleToRadioPacket so the originating
+ /// connection is reachable via `this` — avoids the async context
+ /// loss that broke the previous AdminModule path. Always consumes the
+ /// packet (returns true): lockdown_auth is local-only and must not be
+ /// forwarded to the mesh router.
+ bool handleLockdownAuthInline(const meshtastic_LockdownAuth &la);
+#endif
+
/// If the mesh service tells us fromNum has changed, tell the phone
virtual int onNotify(uint32_t newValue) override;
};
diff --git a/src/mesh/Router.h b/src/mesh/Router.h
index bd4188693..d8d8ff7a5 100644
--- a/src/mesh/Router.h
+++ b/src/mesh/Router.h
@@ -35,6 +35,14 @@ class Router : protected concurrency::OSThread, protected PacketHistory
*/
void addInterface(std::unique_ptr _iface) { iface = std::move(_iface); }
+ /**
+ * Borrowed (non-owning) access to the radio interface — used by NodeDB
+ * after a lockdown unlock so it can push the freshly-loaded config to
+ * the SX12xx via reconfigure(). Returns nullptr when no radio has been
+ * attached (e.g. ARCH_PORTDUINO simulator before SimRadio bind).
+ */
+ RadioInterface *getRadioIface() { return iface.get(); }
+
/**
* do idle processing
* Mostly looking in our incoming rxPacket queue and calling handleReceived.
diff --git a/src/modules/AdminModule.cpp b/src/modules/AdminModule.cpp
index 7e256291c..d5ccbaf12 100644
--- a/src/modules/AdminModule.cpp
+++ b/src/modules/AdminModule.cpp
@@ -27,6 +27,12 @@
#include "RadioInterface.h"
#include "TypeConversions.h"
#include "mesh/RadioLibInterface.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"
@@ -76,6 +82,26 @@ bool AdminModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshta
// if handled == false, then let others look at this message also if they want
bool handled = false;
assert(r);
+
+#ifdef MESHTASTIC_ENCRYPTED_STORAGE
+ // While storage is locked, drop every admin payload — both local and
+ // remote (PKC, mesh-relayed). Lockdown unlock is the prerequisite for
+ // any admin operation: operators must authenticate via lockdown_auth
+ // first. The lockdown_auth path itself is handled synchronously in
+ // PhoneAPI::handleToRadioPacket before reaching here, so the real
+ // unlock flow is not affected by this gate. Without this, a remote
+ // PKC-authorized peer (or a USERPREFS-baked admin_key) could drive
+ // factory_reset / set_config against a locked device before the
+ // operator has even unlocked it.
+ // Only gate when lockdown is ACTIVE. A lockdown-capable build that hasn't
+ // been provisioned (or was disabled) is not unlocked either, but must
+ // still serve admin normally — so check isLockdownActive() first.
+ if (EncryptedStorage::isLockdownActive() && !EncryptedStorage::isUnlocked()) {
+ LOG_WARN("AdminModule: dropping admin payload — storage locked");
+ return handled;
+ }
+#endif
+
bool fromOthers = !isFromUs(&mp);
if (mp.which_payload_variant != meshtastic_MeshPacket_decoded_tag) {
return handled;
@@ -85,11 +111,24 @@ 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.
+ //
+ // Under MESHTASTIC_PHONEAPI_ACCESS_CONTROL, the per-connection auth
+ // gate lives in PhoneAPI::handleToRadioPacket — any local admin
+ // payload other than lockdown_auth is dropped there if the
+ // originating connection is unauthorized. By the time we reach
+ // this branch the connection has already proven the passphrase,
+ // so is_managed needs no additional gate here.
+ //
+ // Without that build flag the legacy is_managed semantics still
+ // apply: refuse all plain local admin and require PKC instead.
+#ifndef MESHTASTIC_PHONEAPI_ACCESS_CONTROL
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");
@@ -105,6 +144,17 @@ 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");
+ // Note: PKC admin does NOT automatically authorize the
+ // originating local PhoneAPI connection for content
+ // redaction purposes. PKC and the per-connection lockdown
+ // auth slot are independent gates — operators using PKC
+ // admin from a local app should still send lockdown_auth
+ // separately to unlock the redacted FromRadio stream.
+ // (The previous auto-authorize path read a shared
+ // g_currentContext set during synchronous PhoneAPI
+ // dispatch; by the time this Router-thread handler runs
+ // that pointer is unrelated, so the path was unsafe.)
+
// Automatically favorite the node that is using the admin key
auto remoteNode = nodeDB->getMeshNode(mp.from);
if (remoteNode && !nodeInfoLiteIsFavorite(remoteNode)) {
@@ -141,6 +191,17 @@ bool AdminModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshta
}
switch (r->which_payload_variant) {
+#ifdef MESHTASTIC_ENCRYPTED_STORAGE
+ // lockdown_auth is handled synchronously in
+ // PhoneAPI::handleToRadioPacket — see handleLockdownAuthInline. A
+ // packet should not normally reach AdminModule under that flag set,
+ // but if it ever does (e.g. injected via a non-PhoneAPI path), drop
+ // it silently rather than leaking a partial response.
+ case meshtastic_AdminMessage_lockdown_auth_tag:
+ LOG_WARN("AdminModule: lockdown_auth reached Router/AdminModule path; ignoring (should be handled in PhoneAPI)");
+ return handled;
+#endif // MESHTASTIC_ENCRYPTED_STORAGE
+
/**
* Getters
*/
diff --git a/src/platform/nrf52/NRF52Bluetooth.cpp b/src/platform/nrf52/NRF52Bluetooth.cpp
index c877829db..b0f033c94 100644
--- a/src/platform/nrf52/NRF52Bluetooth.cpp
+++ b/src/platform/nrf52/NRF52Bluetooth.cpp
@@ -67,6 +67,14 @@ void onConnect(uint16_t conn_handle)
connection->getPeerName(central_name, sizeof(central_name));
LOG_INFO("BLE Connected to %s", central_name);
+ // A new physical link must start unauthenticated. The auth slot is keyed by
+ // the (single, reused) bluetoothPhoneAPI instance, so a prior session's
+ // authorization can otherwise survive a quick reconnect. handleStartConfig()
+ // re-locks on every want_config too; this closes the window before that.
+ if (bluetoothPhoneAPI) {
+ bluetoothPhoneAPI->setAdminAuthorized(false);
+ }
+
// Notify UI (or any other interested firmware components)
meshtastic::BluetoothStatus newStatus(meshtastic::BluetoothStatus::ConnectionState::CONNECTED);
bluetoothStatus->updateStatus(&newStatus);
@@ -401,7 +409,15 @@ bool NRF52Bluetooth::onPairingPasskey(uint16_t conn_handle, uint8_t const passke
std::string configuredPasskeyText = std::to_string(configuredPasskey);
std::string ble_message =
"Bluetooth\nPIN\n[M]" + configuredPasskeyText.substr(0, 3) + " " + configuredPasskeyText.substr(3, 6);
- screen->showSimpleBanner(ble_message.c_str(), 30000);
+ // Use the pairing_pin notification type so the lockdown UI short-
+ // circuit (Screen.cpp updateUiFrame) allows the overlay through
+ // even on a locked device — see H13 audit fix. The banner content
+ // is the per-attempt ephemeral pair PIN, not operator content.
+ graphics::BannerOverlayOptions opts;
+ opts.message = ble_message.c_str();
+ opts.durationMs = 30000;
+ opts.notificationType = graphics::notificationTypeEnum::pairing_pin;
+ screen->showOverlayBanner(opts);
}
#endif
passkeyShowing = true;
diff --git a/src/platform/nrf52/hardfault.cpp b/src/platform/nrf52/hardfault.cpp
index 7b7a718b8..ee3a4c12f 100644
--- a/src/platform/nrf52/hardfault.cpp
+++ b/src/platform/nrf52/hardfault.cpp
@@ -1,6 +1,10 @@
#include "configuration.h"
#include
+#ifdef MESHTASTIC_ENCRYPTED_STORAGE
+#include "security/EncryptedStorage.h"
+#endif
+
// Based on reading/modifying https://blog.feabhas.com/2013/02/developing-a-generic-hard-fault-handler-for-arm-cortex-m3cortex-m4/
enum { r0, r1, r2, r3, r12, lr, pc, psr };
@@ -50,6 +54,16 @@ static void printMemErrorMsg(uint32_t cfsr)
extern "C" void HardFault_Impl(uint32_t stack[])
{
+ // M11 (audit): before any diagnostic / coredump path that could capture
+ // RAM contents, zero the DEK / KEK / ephemeralKEK so they aren't sitting
+ // in BSS for a fault dump to pick up. This is called from the asm naked
+ // HardFault_Handler entry above, so we're effectively in the chip's
+ // exception context — keep this strictly to in-RAM scrubbing, no flash
+ // I/O, no logging.
+#ifdef MESHTASTIC_ENCRYPTED_STORAGE
+ EncryptedStorage::secureWipeKeys();
+#endif
+
FAULT_MSG("Hard Fault occurred! SCB->HFSR = 0x%08lx\n", SCB->HFSR);
if ((SCB->HFSR & SCB_HFSR_FORCED_Msk) != 0) {
diff --git a/src/security/APProtect.cpp b/src/security/APProtect.cpp
new file mode 100644
index 000000000..9ce978752
--- /dev/null
+++ b/src/security/APProtect.cpp
@@ -0,0 +1,103 @@
+#include "configuration.h"
+
+#ifdef MESHTASTIC_ENABLE_APPROTECT
+#ifdef ARCH_NRF52
+
+#include "APProtect.h"
+#include
+
+// M22 (audit): refuse to engage APPROTECT on silicon revisions where the
+// debug-port lockout is publicly known to be bypassable. nRF52840 build
+// codes AAB0..AAF0 are all affected by the SWD glitching attack documented
+// in LimitedResults' nRF52-series research — i.e. every nRF52840 currently
+// in shipping Meshtastic hardware. Engaging APPROTECT on these revisions
+// gives the operator a false sense of security AND irreversibly blocks
+// legitimate SWD-based dev/recovery: the worst of both. Detect-and-skip
+// is the policy; log loudly so the operator knows.
+//
+// FICR.INFO.VARIANT is a 32-bit register storing 4 ASCII characters as a
+// big-endian word ('AAB0' = 0x41414230). Compare whole-word.
+static bool isApProtectVulnerableSilicon(uint32_t variant)
+{
+ // Known-affected nRF52840 build codes. Only remove entries with
+ // positive evidence the variant is fixed.
+ static const uint32_t kVulnerable[] = {
+ 0x41414230, // AAB0
+ 0x41414330, // AAC0
+ 0x41414430, // AAD0
+ 0x41414530, // AAE0
+ 0x41414630, // AAF0
+ };
+ for (uint32_t v : kVulnerable) {
+ if (variant == v)
+ return true;
+ }
+ return false;
+}
+
+static void logApProtectVariant(const char *prefix, uint32_t variant)
+{
+ // Render the 4-byte ASCII variant for the log line. FICR encodes it
+ // big-endian, so the high byte is the first ASCII character.
+ char buf[5] = {(char)((variant >> 24) & 0xFF), (char)((variant >> 16) & 0xFF), (char)((variant >> 8) & 0xFF),
+ (char)(variant & 0xFF), '\0'};
+ LOG_WARN("%s (FICR.INFO.VARIANT='%s', 0x%08x)", prefix, buf, variant);
+}
+
+void enableAPProtect()
+{
+ const uint32_t variant = NRF_FICR->INFO.VARIANT;
+
+ if (isApProtectVulnerableSilicon(variant)) {
+ logApProtectVariant("APPROTECT NOT engaged: silicon revision is publicly known "
+ "bypassable via SWD glitching. Skipping irreversible UICR write so "
+ "the operator is not misled into thinking SWD is locked when it is "
+ "not. To override (e.g. for testing on a known-vulnerable board), "
+ "rebuild with -DMESHTASTIC_APPROTECT_OVERRIDE_VULNERABLE_SILICON=1",
+ variant);
+#ifndef MESHTASTIC_APPROTECT_OVERRIDE_VULNERABLE_SILICON
+ return;
+#else
+ LOG_WARN("APPROTECT vulnerable-silicon override flag set; engaging anyway");
+#endif
+ }
+
+ // 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)
+ ;
+
+ // UICR APPROTECT is latched at chip reset, so the lock is NOT in effect
+ // until we reset. Force a reset now to close the window where SWD remains
+ // attachable on this same boot. We're called early in setup() before any
+ // sensitive data is in RAM, so the reboot is safe.
+ LOG_INFO("APPROTECT written; resetting to engage debug port lockout");
+ NVIC_SystemReset();
+ // unreachable
+ } 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
diff --git a/src/security/APProtect.h b/src/security/APProtect.h
new file mode 100644
index 000000000..59b27ecfd
--- /dev/null
+++ b/src/security/APProtect.h
@@ -0,0 +1,32 @@
+#pragma once
+
+#ifdef MESHTASTIC_ENABLE_APPROTECT
+
+/**
+ * Enable APPROTECT on nRF52840 to disable the SWD/JTAG debug port.
+ *
+ * Writes NRF_UICR->APPROTECT = 0x00 (and ERASEPROTECT/DEBUG variants where
+ * applicable) if not already set, then triggers a reset so the change takes
+ * effect. Must be called early in setup(), before any sensitive data is
+ * loaded into RAM, so an attacker who powered the device cannot halt it via
+ * SWD before the lock is in place.
+ *
+ * Once APPROTECT is written:
+ * - SWD/JTAG halt, memory read, and register access are blocked.
+ * - The lock survives reboot, power cycle, and ordinary USB/DFU firmware
+ * reflash. The DFU bootloader path keeps working for routine app
+ * updates because the bootloader doesn't need SWD.
+ * - The only way to clear APPROTECT is an SWD-side `nrfjprog --recover`
+ * (CTRL-AP ERASEALL), which wipes the entire chip — bootloader,
+ * application, LittleFS, and the encrypted DEK — destroying all
+ * on-device state in the process. That destructive coupling is the
+ * point: an attacker cannot clear APPROTECT to extract user data
+ * without also wiping the data they were trying to read.
+ *
+ * Practical implication: do not enable this on a device you might want to
+ * SWD-debug later. Recovery is possible but always destroys all user
+ * data; routine USB reflashing alone will NOT clear it.
+ */
+void enableAPProtect();
+
+#endif // MESHTASTIC_ENABLE_APPROTECT
diff --git a/src/security/EncryptedStorage.cpp b/src/security/EncryptedStorage.cpp
new file mode 100644
index 000000000..274afef08
--- /dev/null
+++ b/src/security/EncryptedStorage.cpp
@@ -0,0 +1,1819 @@
+#include "configuration.h"
+
+#ifdef MESHTASTIC_ENCRYPTED_STORAGE
+
+// Common includes — available for all platform implementations
+#include "EncryptedStorage.h"
+#include "FSCommon.h"
+#include "RTC.h"
+#include "SPILock.h"
+#include "SafeFile.h"
+#include "SecureZero.h"
+#include
+
+#ifdef ARCH_NRF52
+
+// nRF52 CC310 hardware crypto
+#include
+#include
+
+extern "C" {
+#include "nrf_cc310/include/crys_hmac.h"
+#include "nrf_cc310/include/ssi_aes.h"
+#include "nrf_cc310/include/ssi_aes_defs.h"
+}
+
+namespace EncryptedStorage
+{
+
+// ---------------------------------------------------------------------------
+// File paths and domain-separation strings
+// ---------------------------------------------------------------------------
+
+static const char *DEK_FILENAME = "/prefs/.dek";
+static const char *TOKEN_FILENAME = "/prefs/.unlock_token";
+static const char *BACKOFF_FILENAME = "/prefs/.backoff";
+
+// Passphrase-mixed KEK
+static const char *KEK_DOMAIN = "meshtastic-tak-kek-v2";
+// Ephemeral: FICR-only, used only to wrap DEK inside the unlock token
+static const char *EPHEMERAL_KEK_DOMAIN = "meshtastic-tak-ephemeral-v1";
+// HMAC auth label for DEK file
+static const char *DEK_AUTH_LABEL = "mdek-auth";
+
+// ---------------------------------------------------------------------------
+// Module-level key state
+// ---------------------------------------------------------------------------
+
+// Passphrase-mixed KEK (v2), derived on provision/unlock
+static uint8_t kek[AES_KEY_SIZE];
+static bool kekDerived = false;
+
+// FICR-only ephemeral KEK for token wrapping/unwrapping
+static uint8_t ephemeralKek[AES_KEY_SIZE];
+static bool ephemeralKekDerived = false;
+
+// Data Encryption Key — loaded from /prefs/.dek, never stored in plaintext
+static uint8_t dek[AES_KEY_SIZE];
+static bool dekLoaded = false;
+
+// Reason the device is currently locked (set in initLocked / readAndConsumeToken)
+static const char *lockReason = "not_provisioned";
+
+// Token state after successful unlock — exposed via getBootsRemaining() / getValidUntilEpoch()
+static uint8_t s_bootsRemaining = 0;
+static uint32_t s_validUntilEpoch = 0;
+
+// Uptime-based session limit. Set by setSession() at successful unlock.
+// s_sessionMaxMs = 0 means no limit (token-only enforcement). RAM-only:
+// reboot clears these, but readAndConsumeToken() persists the
+// sessionMaxSeconds in the token file and re-calls setSession() from
+// the token-load path so token-auto-unlocked sessions inherit the
+// same cap. consumeSessionBoot() re-arms in place between sessions.
+static uint32_t s_sessionMaxMs = 0;
+static uint32_t s_sessionStartedMs = 0;
+
+// Backoff state — seconds remaining before next passphrase attempt is allowed
+static uint32_t s_backoffSecondsRemaining = 0;
+
+// ---------------------------------------------------------------------------
+// Passphrase attempt backoff helpers
+// ---------------------------------------------------------------------------
+
+// Returns the delay in seconds for a given number of failed attempts.
+// Schedule: 5, 10, 20, 40, 80, 160, 320, 900 (capped)
+static uint32_t backoffDelay(uint8_t attempts)
+{
+ if (attempts == 0)
+ return 0;
+ uint32_t delay = 5u;
+ for (uint8_t i = 1; i < attempts; i++) {
+ delay *= 2;
+ if (delay >= 900)
+ return 900;
+ }
+ return delay;
+}
+
+// RAM-only: millis() at the most recent failed attempt this boot. Lets us
+// enforce within-boot backoff without relying on RTC. Reset to 0 each boot.
+static uint32_t s_lastFailMillis = 0;
+
+// Forward declarations for the crypto helpers defined further down. The
+// backoff section (next) needs them and we want the backoff state next to
+// the rest of the boot/state machinery rather than buried after the crypto.
+static bool computeHMAC(const uint8_t *key, size_t keyLen, const uint8_t *data, size_t dataLen, uint8_t *hmacOut);
+static bool constTimeEq(const uint8_t *a, const uint8_t *b, size_t len);
+
+// HMAC domain label for the backoff file. Distinct from DEK_AUTH_LABEL so a
+// cross-file replay (use a DEK-MAC as a backoff-MAC or vice versa) fails.
+static const char *BACKOFF_AUTH_LABEL = "backoff-auth";
+
+// Backoff state file format (38 bytes): attempts(1) + bootsSinceFail(1) +
+// lastFailEpoch(4) + HMAC-SHA256(ephemeralKEK, BACKOFF_AUTH_LABEL || body)(32)
+//
+// H4 (audit): MAC the file with the FICR-derived ephemeralKek so an attacker
+// who can write LittleFS (DFU file inject, compromised firmware) cannot
+// forge a low-attempts file to bypass backoff. Atomic write via SafeFile
+// closes the power-glitch-during-write window. Missing/short/MAC-fail are
+// all treated as max-attempts (kBackoffMaxAttempts) so a tamper-delete can
+// only INCREASE the wait, never decrease it.
+//
+// bootsSinceFail is incremented once per boot in initLocked() (saturating at
+// 255). It provides a reliable monotonic across-reboot counter for backoff
+// enforcement even when the RTC is unset, closing the reboot-bypass: an
+// attacker who reboots between failed attempts cannot fast-forward through
+// the backoff window because each reboot costs ~3-5 s of nRF52 boot time
+// and only advances bootsSinceFail by 1.
+static constexpr size_t BACKOFF_BODY_SIZE = 6;
+static constexpr size_t BACKOFF_SIZE = BACKOFF_BODY_SIZE + HMAC_SIZE; // 38 bytes
+static constexpr uint8_t kBackoffMaxAttempts = 255;
+
+// Compute HMAC-SHA256(ephemeralKek, "backoff-auth" || body) into `out`.
+// Caller must already hold CC310 (nRFCrypto.begin/end).
+static bool computeBackoffHmac(const uint8_t body[BACKOFF_BODY_SIZE], uint8_t out[HMAC_SIZE])
+{
+ if (!ephemeralKekDerived)
+ return false;
+ size_t labelLen = strlen(BACKOFF_AUTH_LABEL);
+ meshtastic_security::ZeroizingBuffer<32 + BACKOFF_BODY_SIZE> input; // labelLen <= 32
+ memcpy(input.data(), BACKOFF_AUTH_LABEL, labelLen);
+ memcpy(input.data() + labelLen, body, BACKOFF_BODY_SIZE);
+ return computeHMAC(ephemeralKek, AES_KEY_SIZE, input.data(), labelLen + BACKOFF_BODY_SIZE, out);
+}
+
+static void readBackoff(uint8_t &attempts, uint8_t &bootsSinceFail, uint32_t &lastFailEpoch)
+{
+ // Default outputs: zero-attempts. Reassigned to "max" below if the file
+ // is missing OR present-but-tampered. The fresh-device (pre-provision)
+ // case is handled by bumpBootsSinceFailOnBoot's early-return; once
+ // provision has run, the file is always present and a missing file
+ // means something hostile deleted it.
+ attempts = 0;
+ bootsSinceFail = 0;
+ lastFailEpoch = 0;
+#ifdef FSCom
+ meshtastic_security::ZeroizingBuffer buf;
+ {
+ concurrency::LockGuard g(spiLock);
+ auto f = FSCom.open(BACKOFF_FILENAME, FILE_O_READ);
+ if (!f) {
+ // Fresh device (no provision yet) OR an attacker deleted the
+ // file. Caller resolves the ambiguity via isProvisioned() —
+ // see bumpBootsSinceFailOnBoot and the unlock backoff gate.
+ return;
+ }
+ size_t sz = f.size();
+ if (sz != BACKOFF_SIZE) {
+ f.close();
+ attempts = kBackoffMaxAttempts;
+ return;
+ }
+ size_t n = f.read(buf.data(), BACKOFF_SIZE);
+ f.close();
+ if (n != BACKOFF_SIZE) {
+ attempts = kBackoffMaxAttempts;
+ return;
+ }
+ }
+ // Verify HMAC under lock-free CC310 access (we hold no spiLock here).
+ uint8_t expected[HMAC_SIZE];
+ nRFCrypto.begin();
+ bool ok = computeBackoffHmac(buf.data(), expected);
+ nRFCrypto.end();
+ if (!ok || !constTimeEq(expected, buf.data() + BACKOFF_BODY_SIZE, HMAC_SIZE)) {
+ // Tampered or attacker-rewritten file. Fail closed.
+ attempts = kBackoffMaxAttempts;
+ return;
+ }
+ attempts = buf.data()[0];
+ bootsSinceFail = buf.data()[1];
+ memcpy(&lastFailEpoch, buf.data() + 2, 4);
+#endif
+}
+
+static void writeBackoff(uint8_t attempts, uint8_t bootsSinceFail, uint32_t lastFailEpoch)
+{
+#ifdef FSCom
+ meshtastic_security::ZeroizingBuffer buf;
+ buf.data()[0] = attempts;
+ buf.data()[1] = bootsSinceFail;
+ memcpy(buf.data() + 2, &lastFailEpoch, 4);
+ uint8_t mac[HMAC_SIZE];
+ nRFCrypto.begin();
+ bool ok = computeBackoffHmac(buf.data(), mac);
+ nRFCrypto.end();
+ if (!ok) {
+ LOG_ERROR("EncryptedStorage: backoff HMAC compute failed");
+ return;
+ }
+ memcpy(buf.data() + BACKOFF_BODY_SIZE, mac, HMAC_SIZE);
+ SafeFile sf(BACKOFF_FILENAME, /*fullAtomic=*/true);
+ sf.write(buf.data(), BACKOFF_SIZE);
+ if (!sf.close()) {
+ LOG_ERROR("EncryptedStorage: backoff atomic write failed");
+ }
+#endif
+}
+
+// Called once per boot from initLocked(). Skip the bump on a fresh
+// (un-provisioned) device — there's no backoff file to MAC against yet and
+// readBackoff would return kBackoffMaxAttempts which would be wrong here.
+static void bumpBootsSinceFailOnBoot()
+{
+ if (!isProvisioned())
+ return;
+ uint8_t attempts;
+ uint8_t bootsSinceFail;
+ uint32_t lastFailEpoch;
+ readBackoff(attempts, bootsSinceFail, lastFailEpoch);
+ if (attempts == 0 || attempts == kBackoffMaxAttempts)
+ return;
+ if (bootsSinceFail < 255)
+ bootsSinceFail++;
+ writeBackoff(attempts, bootsSinceFail, lastFailEpoch);
+}
+
+// On successful unlock, write a freshly-MAC'd attempts=0 sentinel so the
+// file always exists post-provision. Missing == hostile delete from there
+// on. (Removing the file instead would make "missing == fresh-cleared" and
+// re-open the delete-to-reset bypass that H4 exists to close.)
+static void clearBackoff()
+{
+ writeBackoff(0, 0, 0);
+ s_backoffSecondsRemaining = 0;
+ s_lastFailMillis = 0;
+}
+
+// ---------------------------------------------------------------------------
+// Internal helpers: FICR data extraction
+// ---------------------------------------------------------------------------
+
+static void readFICR(uint8_t efuseData[16])
+{
+ // Copy FICR registers to local vars before memcpy (registers are volatile)
+ uint32_t tmp;
+ tmp = NRF_FICR->DEVICEID[0];
+ memcpy(efuseData, &tmp, 4);
+ tmp = NRF_FICR->DEVICEID[1];
+ memcpy(efuseData + 4, &tmp, 4);
+ tmp = NRF_FICR->DEVICEADDR[0];
+ memcpy(efuseData + 8, &tmp, 4);
+ tmp = NRF_FICR->DEVICEADDR[1];
+ memcpy(efuseData + 12, &tmp, 4);
+}
+
+// ---------------------------------------------------------------------------
+// Internal helpers: CC310 crypto primitives
+// ---------------------------------------------------------------------------
+
+/// AES-128-CTR encrypt/decrypt (symmetric). Caller holds CC310.
+static bool aesCtr128(const uint8_t *key, const uint8_t *nonce, size_t nonceLen, const uint8_t *input, size_t inputLen,
+ uint8_t *output)
+{
+ if (inputLen == 0)
+ return true;
+
+ SaSiAesUserContext_t ctx;
+ SaSiAesUserKeyData_t keyData;
+ SaSiAesIv_t iv;
+
+ memset(iv, 0, sizeof(iv));
+ size_t copyLen = (nonceLen < sizeof(iv)) ? nonceLen : sizeof(iv);
+ memcpy(iv, nonce, copyLen);
+
+ SaSiError_t err = SaSi_AesInit(&ctx, SASI_AES_ENCRYPT, SASI_AES_MODE_CTR, SASI_AES_PADDING_NONE);
+ if (err != 0) {
+ LOG_ERROR("EncryptedStorage: AES init failed: 0x%x", err);
+ return false;
+ }
+
+ keyData.pKey = (uint8_t *)key;
+ keyData.keySize = AES_KEY_SIZE;
+ err = SaSi_AesSetKey(&ctx, SASI_AES_USER_KEY, &keyData, sizeof(keyData));
+ if (err != 0) {
+ LOG_ERROR("EncryptedStorage: AES setkey failed: 0x%x", err);
+ SaSi_AesFree(&ctx);
+ return false;
+ }
+
+ err = SaSi_AesSetIv(&ctx, iv);
+ if (err != 0) {
+ LOG_ERROR("EncryptedStorage: AES setiv failed: 0x%x", err);
+ SaSi_AesFree(&ctx);
+ return false;
+ }
+
+ size_t processed = 0;
+ size_t fullBlocks = (inputLen / AES_BLOCK_SIZE) * AES_BLOCK_SIZE;
+ if (fullBlocks > 0) {
+ err = SaSi_AesBlock(&ctx, (uint8_t *)input, fullBlocks, output);
+ if (err != 0) {
+ LOG_ERROR("EncryptedStorage: AES block failed: 0x%x", err);
+ SaSi_AesFree(&ctx);
+ return false;
+ }
+ processed = fullBlocks;
+ }
+
+ size_t remaining = inputLen - processed;
+ size_t finishOutSize = remaining;
+ err = SaSi_AesFinish(&ctx, remaining, (uint8_t *)input + processed, remaining, output + processed, &finishOutSize);
+ if (err != 0) {
+ LOG_ERROR("EncryptedStorage: AES finish failed: 0x%x", err);
+ SaSi_AesFree(&ctx);
+ return false;
+ }
+
+ SaSi_AesFree(&ctx);
+ return true;
+}
+
+/// Compute HMAC-SHA256(key, data). Caller holds CC310.
+static bool computeHMAC(const uint8_t *key, size_t keyLen, const uint8_t *data, size_t dataLen, uint8_t *hmacOut)
+{
+ CRYS_HASH_Result_t hmacResult;
+ CRYSError_t err = CRYS_HMAC(CRYS_HASH_SHA256_mode, (uint8_t *)key, (uint16_t)keyLen, (uint8_t *)data, dataLen, hmacResult);
+ if (err != 0) {
+ LOG_ERROR("EncryptedStorage: CRYS_HMAC failed: 0x%x", err);
+ return false;
+ }
+ memcpy(hmacOut, hmacResult, HMAC_SIZE);
+ return true;
+}
+
+/// Constant-time memory comparison (avoids timing side-channels on HMAC compare).
+static bool constTimeEq(const uint8_t *a, const uint8_t *b, size_t len)
+{
+ uint8_t diff = 0;
+ for (size_t i = 0; i < len; i++)
+ diff |= a[i] ^ b[i];
+ return diff == 0;
+}
+
+// ---------------------------------------------------------------------------
+// Internal helpers: KEK derivation
+// ---------------------------------------------------------------------------
+
+/**
+ * Derive the passphrase-mixed KEK and store in module-level kek[].
+ * SHA-256("device-efuse-data" || FICR_16 || passphrase || KEK_DOMAIN) → first 16 bytes.
+ * Caller must hold CC310 (nRFCrypto.begin()).
+ */
+static bool deriveKEK(const uint8_t *passphrase, size_t passphraseLen)
+{
+ uint8_t efuseData[16];
+ readFICR(efuseData);
+
+ static const char *prefix = "device-efuse-data";
+ uint8_t sha256Result[32];
+
+ nRFCrypto_Hash hash;
+ if (!hash.begin(CRYS_HASH_SHA256_mode)) {
+ LOG_ERROR("EncryptedStorage: SHA-256 init failed (KEK)");
+ meshtastic_security::secure_zero(efuseData, sizeof(efuseData));
+ return false;
+ }
+ hash.update((uint8_t *)prefix, strlen(prefix));
+ hash.update(efuseData, sizeof(efuseData));
+ hash.update((uint8_t *)passphrase, passphraseLen);
+ hash.update((uint8_t *)KEK_DOMAIN, strlen(KEK_DOMAIN));
+ hash.end(sha256Result);
+
+ memcpy(kek, sha256Result, AES_KEY_SIZE);
+ meshtastic_security::secure_zero(sha256Result, sizeof(sha256Result));
+ meshtastic_security::secure_zero(efuseData, sizeof(efuseData));
+
+ kekDerived = true;
+ return true;
+}
+
+/**
+ * Derive the ephemeral KEK (FICR-only, separate domain) into ephemeralKek[].
+ * Used only for wrapping/unwrapping the unlock token.
+ * Caller must hold CC310.
+ */
+static bool deriveEphemeralKEK()
+{
+ if (ephemeralKekDerived)
+ return true;
+
+ uint8_t efuseData[16];
+ readFICR(efuseData);
+
+ static const char *prefix = "device-efuse-data";
+ uint8_t sha256Result[32];
+
+ nRFCrypto_Hash hash;
+ if (!hash.begin(CRYS_HASH_SHA256_mode)) {
+ LOG_ERROR("EncryptedStorage: SHA-256 init failed (ephemeral KEK)");
+ meshtastic_security::secure_zero(efuseData, sizeof(efuseData));
+ return false;
+ }
+ hash.update((uint8_t *)prefix, strlen(prefix));
+ hash.update(efuseData, sizeof(efuseData));
+ hash.update((uint8_t *)EPHEMERAL_KEK_DOMAIN, strlen(EPHEMERAL_KEK_DOMAIN));
+ hash.end(sha256Result);
+
+ memcpy(ephemeralKek, sha256Result, AES_KEY_SIZE);
+ meshtastic_security::secure_zero(sha256Result, sizeof(sha256Result));
+ meshtastic_security::secure_zero(efuseData, sizeof(efuseData));
+
+ ephemeralKekDerived = true;
+ return true;
+}
+
+// ---------------------------------------------------------------------------
+// Internal helpers: DEK file I/O
+// ---------------------------------------------------------------------------
+
+/**
+ * Load DEK from the DEK file using the current kek[].
+ * Verifies HMAC before returning the DEK.
+ */
+static bool loadDEK()
+{
+#ifdef FSCom
+ concurrency::LockGuard g(spiLock);
+ auto f = FSCom.open(DEK_FILENAME, FILE_O_READ);
+ if (!f)
+ return false;
+
+ size_t fileSize = f.size();
+ if (fileSize != DEK_SIZE) {
+ f.close();
+ return false;
+ }
+
+ uint8_t buf[DEK_SIZE];
+ size_t bytesRead = f.read(buf, sizeof(buf));
+ f.close();
+
+ if (bytesRead != DEK_SIZE) {
+ LOG_ERROR("EncryptedStorage: DEK short read");
+ return false;
+ }
+
+ // Check magic
+ uint32_t magic;
+ memcpy(&magic, buf, 4);
+ if (magic != DEK_MAGIC) {
+ LOG_ERROR("EncryptedStorage: DEK bad magic");
+ return false;
+ }
+
+ uint8_t *nonce = buf + 4;
+ uint8_t *encDek = buf + 4 + NONCE_SIZE;
+
+ // Verify HMAC-SHA256(KEK, DEK_AUTH_LABEL || nonce || encDEK)
+ size_t authLabelLen = strlen(DEK_AUTH_LABEL);
+ size_t hmacInputLen = authLabelLen + NONCE_SIZE + AES_KEY_SIZE;
+ auto hmacInput = meshtastic_security::make_zeroizing_array(hmacInputLen);
+ if (!hmacInput) {
+ LOG_ERROR("EncryptedStorage: OOM for DEK HMAC verify");
+ return false;
+ }
+ memcpy(hmacInput.get(), DEK_AUTH_LABEL, authLabelLen);
+ memcpy(hmacInput.get() + authLabelLen, nonce, NONCE_SIZE);
+ memcpy(hmacInput.get() + authLabelLen + NONCE_SIZE, encDek, AES_KEY_SIZE);
+
+ meshtastic_security::ZeroizingBuffer expectedHmac;
+ nRFCrypto.begin();
+ bool hmacOk = computeHMAC(kek, AES_KEY_SIZE, hmacInput.get(), hmacInputLen, expectedHmac.data());
+ nRFCrypto.end();
+ hmacInput.reset();
+
+ if (!hmacOk) {
+ return false;
+ }
+
+ const uint8_t *storedHmac = buf + DEK_SIZE - HMAC_SIZE;
+ if (!constTimeEq(expectedHmac.data(), storedHmac, HMAC_SIZE)) {
+ LOG_ERROR("EncryptedStorage: DEK HMAC mismatch — wrong passphrase or tampered file");
+ return false;
+ }
+
+ // Decrypt DEK into a local candidate — only write to global dek[] on success so that
+ // a wrong passphrase attempt does not destroy the live DEK in RAM.
+ meshtastic_security::ZeroizingBuffer dekCandidate;
+ nRFCrypto.begin();
+ bool decOk = aesCtr128(kek, nonce, NONCE_SIZE, encDek, AES_KEY_SIZE, dekCandidate.data());
+ nRFCrypto.end();
+
+ if (!decOk) {
+ LOG_ERROR("EncryptedStorage: DEK decrypt failed");
+ return false;
+ }
+
+ memcpy(dek, dekCandidate.data(), AES_KEY_SIZE);
+
+ LOG_INFO("EncryptedStorage: DEK loaded and verified");
+ return true;
+#else
+ return false;
+#endif
+}
+
+/**
+ * Save the current in-RAM dek[] to disk as a DEK file, wrapped with kek[].
+ * Overwrites any existing DEK file.
+ */
+static bool saveDEK()
+{
+#ifdef FSCom
+ // Generate random nonce
+ uint8_t nonce[NONCE_SIZE];
+ nRFCrypto.begin();
+ if (!nRFCrypto.Random.generate(nonce, NONCE_SIZE)) {
+ LOG_ERROR("EncryptedStorage: TRNG failed for DEK nonce");
+ nRFCrypto.end();
+ return false;
+ }
+
+ // Encrypt DEK with KEK
+ uint8_t encDek[AES_KEY_SIZE];
+ bool encOk = aesCtr128(kek, nonce, NONCE_SIZE, dek, AES_KEY_SIZE, encDek);
+ if (!encOk) {
+ LOG_ERROR("EncryptedStorage: DEK encrypt failed");
+ nRFCrypto.end();
+ meshtastic_security::secure_zero(encDek, sizeof(encDek));
+ return false;
+ }
+
+ // Compute HMAC-SHA256(KEK, DEK_AUTH_LABEL || nonce || encDEK)
+ size_t authLabelLen = strlen(DEK_AUTH_LABEL);
+ size_t hmacInputLen = authLabelLen + NONCE_SIZE + AES_KEY_SIZE;
+ auto hmacInput = meshtastic_security::make_zeroizing_array(hmacInputLen);
+ if (!hmacInput) {
+ LOG_ERROR("EncryptedStorage: OOM for DEK HMAC");
+ nRFCrypto.end();
+ return false;
+ }
+ memcpy(hmacInput.get(), DEK_AUTH_LABEL, authLabelLen);
+ memcpy(hmacInput.get() + authLabelLen, nonce, NONCE_SIZE);
+ memcpy(hmacInput.get() + authLabelLen + NONCE_SIZE, encDek, AES_KEY_SIZE);
+
+ uint8_t hmac[HMAC_SIZE];
+ bool hmacOk = computeHMAC(kek, AES_KEY_SIZE, hmacInput.get(), hmacInputLen, hmac);
+ nRFCrypto.end();
+ hmacInput.reset();
+
+ if (!hmacOk) {
+ meshtastic_security::secure_zero(encDek, sizeof(encDek));
+ return false;
+ }
+
+ // Write file: magic(4)+nonce(13)+encDEK(16)+hmac(32) = 65 bytes
+ // H12 (audit): atomic write via SafeFile. Power-loss between remove()
+ // and write() previously left a missing or partial DEK file, which
+ // bricked the device — the encrypted protos can't be decrypted with
+ // no DEK on flash. SafeFile writes a tmp file, reads it back to verify
+ // a content hash, then atomically renames over the target. Crash before
+ // rename → old DEK stays in place; crash after rename → new DEK is on
+ // disk and verified.
+ uint32_t magic = DEK_MAGIC;
+ SafeFile sf(DEK_FILENAME, /*fullAtomic=*/true);
+ sf.write((uint8_t *)&magic, 4);
+ sf.write(nonce, NONCE_SIZE);
+ sf.write(encDek, AES_KEY_SIZE);
+ sf.write(hmac, HMAC_SIZE);
+ bool ok = sf.close();
+
+ meshtastic_security::secure_zero(nonce, sizeof(nonce));
+ meshtastic_security::secure_zero(encDek, sizeof(encDek));
+ meshtastic_security::secure_zero(hmac, sizeof(hmac));
+
+ if (!ok) {
+ LOG_ERROR("EncryptedStorage: DEK write/verify failed");
+ return false;
+ }
+ LOG_INFO("EncryptedStorage: DEK saved");
+ return true;
+#else
+ return false;
+#endif
+}
+
+// ---------------------------------------------------------------------------
+// Internal helpers: unlock token I/O
+// ---------------------------------------------------------------------------
+
+// ---------------------------------------------------------------------------
+// M4 (audit): monotonic counter for token rollback protection
+// ---------------------------------------------------------------------------
+//
+// Each new unlock token carries a uint32 counter inside its MAC'd body that
+// strictly increases over the device's lifetime. The highest counter we've
+// ever seen is persisted to /prefs/.tokmono and MAC'd with the FICR-only
+// ephemeralKek under a distinct domain label, so a casual flash-write
+// attacker (no flash extraction, no FICR access) cannot forge it.
+//
+// Rollback attempt: attacker captures token T1 at time T, operator unlocks
+// later (token T2, counter > T1), attacker writes T1 back. readAndConsume
+// sees T1.counter < max_seen and rejects as rollback.
+
+static const char *MONO_FILENAME = "/prefs/.tokmono";
+static const char *MONO_AUTH_LABEL = "tokmono-auth";
+static constexpr size_t MONO_BODY_SIZE = 4;
+static constexpr size_t MONO_TOTAL_SIZE = MONO_BODY_SIZE + HMAC_SIZE; // 36 bytes
+
+// Compute HMAC-SHA256(ephemeralKek, MONO_AUTH_LABEL || body). Caller holds
+// CC310 (nRFCrypto.begin/end).
+static bool computeMonoHmac(const uint8_t body[MONO_BODY_SIZE], uint8_t out[HMAC_SIZE])
+{
+ if (!ephemeralKekDerived)
+ return false;
+ size_t labelLen = strlen(MONO_AUTH_LABEL);
+ meshtastic_security::ZeroizingBuffer<32 + MONO_BODY_SIZE> input;
+ memcpy(input.data(), MONO_AUTH_LABEL, labelLen);
+ memcpy(input.data() + labelLen, body, MONO_BODY_SIZE);
+ return computeHMAC(ephemeralKek, AES_KEY_SIZE, input.data(), labelLen + MONO_BODY_SIZE, out);
+}
+
+// Read the persisted max-counter-seen value. Missing/short/MAC-fail
+// returns 0 — the safe default that lets the next token write succeed and
+// re-seed the file. Unlike the backoff file, missing-here is not a tamper
+// signal: a fresh device or a device whose .tokmono got wiped (e.g. via
+// factory-erase) legitimately has no counter file.
+static uint32_t readMonoCounter()
+{
+#ifdef FSCom
+ meshtastic_security::ZeroizingBuffer buf;
+ {
+ concurrency::LockGuard g(spiLock);
+ auto f = FSCom.open(MONO_FILENAME, FILE_O_READ);
+ if (!f)
+ return 0;
+ size_t sz = f.size();
+ if (sz != MONO_TOTAL_SIZE) {
+ f.close();
+ return 0;
+ }
+ size_t n = f.read(buf.data(), MONO_TOTAL_SIZE);
+ f.close();
+ if (n != MONO_TOTAL_SIZE)
+ return 0;
+ }
+ uint8_t expected[HMAC_SIZE];
+ nRFCrypto.begin();
+ bool ok = computeMonoHmac(buf.data(), expected);
+ nRFCrypto.end();
+ if (!ok || !constTimeEq(expected, buf.data() + MONO_BODY_SIZE, HMAC_SIZE))
+ return 0;
+ uint32_t counter;
+ memcpy(&counter, buf.data(), 4);
+ return counter;
+#else
+ return 0;
+#endif
+}
+
+// Persist a new max-counter-seen value. Best-effort: log on failure but do
+// not abort the caller (the token write that incremented the counter has
+// already committed; a missing/stale .tokmono on the next read will be
+// quietly promoted by readAndConsumeToken when it sees a token whose
+// counter exceeds the persisted value).
+static bool writeMonoCounter(uint32_t counter)
+{
+#ifdef FSCom
+ meshtastic_security::ZeroizingBuffer buf;
+ memcpy(buf.data(), &counter, 4);
+ uint8_t mac[HMAC_SIZE];
+ nRFCrypto.begin();
+ bool ok = computeMonoHmac(buf.data(), mac);
+ nRFCrypto.end();
+ if (!ok) {
+ LOG_ERROR("EncryptedStorage: mono-counter HMAC failed");
+ return false;
+ }
+ memcpy(buf.data() + MONO_BODY_SIZE, mac, HMAC_SIZE);
+ SafeFile sf(MONO_FILENAME, /*fullAtomic=*/true);
+ sf.write(buf.data(), MONO_TOTAL_SIZE);
+ if (!sf.close()) {
+ LOG_ERROR("EncryptedStorage: mono-counter atomic write failed");
+ return false;
+ }
+ return true;
+#else
+ return false;
+#endif
+}
+
+/**
+ * Write a new unlock token to TOKEN_FILENAME.
+ * Wraps the current in-RAM dek[] with ephemeralKek[].
+ * @param bootsRemaining Number of boots this token grants
+ * @param validUntilEpoch Unix timestamp after which token is invalid (0 = no limit)
+ * @param sessionMaxSeconds Uptime-based session cap per boot (0 = no cap).
+ * Persisted in the token so token-auto-unlock at
+ * cold boot inherits the same limit. Reboot
+ * starts a fresh session window — combined with
+ * bootsRemaining, gives a hard exposure ceiling
+ * bootsRemaining * sessionMaxSeconds.
+ */
+static bool writeUnlockToken(uint8_t bootsRemaining, uint32_t validUntilEpoch, uint32_t sessionMaxSeconds)
+{
+#ifdef FSCom
+ uint8_t nonce[NONCE_SIZE];
+ nRFCrypto.begin();
+ if (!nRFCrypto.Random.generate(nonce, NONCE_SIZE)) {
+ LOG_ERROR("EncryptedStorage: TRNG failed for token nonce");
+ nRFCrypto.end();
+ return false;
+ }
+
+ uint8_t encDek[AES_KEY_SIZE];
+ bool encOk = aesCtr128(ephemeralKek, nonce, NONCE_SIZE, dek, AES_KEY_SIZE, encDek);
+ if (!encOk) {
+ LOG_ERROR("EncryptedStorage: Token DEK encrypt failed");
+ nRFCrypto.end();
+ meshtastic_security::secure_zero(encDek, sizeof(encDek));
+ return false;
+ }
+
+ // M4 (audit): claim a fresh monotonic-counter slot ABOVE the highest
+ // value previously persisted. The new counter is MAC'd into the token
+ // body below; after the token write succeeds we persist this value to
+ // /prefs/.tokmono so the next readAndConsumeToken can reject any older
+ // token that gets restored to disk later.
+ uint32_t newMonoCounter = readMonoCounter() + 1;
+
+ // Build body for HMAC (everything before the trailing HMAC)
+ uint8_t body[TOKEN_BODY_SIZE];
+ size_t pos = 0;
+ uint32_t magic = TOKEN_MAGIC;
+ memcpy(body + pos, &magic, 4);
+ pos += 4;
+ memcpy(body + pos, nonce, NONCE_SIZE);
+ pos += NONCE_SIZE;
+ memcpy(body + pos, encDek, AES_KEY_SIZE);
+ pos += AES_KEY_SIZE;
+ body[pos++] = bootsRemaining;
+ memcpy(body + pos, &validUntilEpoch, 4);
+ pos += 4;
+ memcpy(body + pos, &sessionMaxSeconds, 4);
+ pos += 4;
+ memcpy(body + pos, &newMonoCounter, 4);
+ pos += 4;
+
+ uint8_t hmac[HMAC_SIZE];
+ bool hmacOk = computeHMAC(ephemeralKek, AES_KEY_SIZE, body, TOKEN_BODY_SIZE, hmac);
+ nRFCrypto.end();
+
+ meshtastic_security::secure_zero(encDek, sizeof(encDek));
+
+ if (!hmacOk) {
+ meshtastic_security::secure_zero(body, sizeof(body));
+ return false;
+ }
+
+ // H12 (audit): atomic token write via SafeFile (see saveDEK note for
+ // the same rationale). Power-loss between remove and write previously
+ // left the token in an unreadable state, forcing the operator to re-
+ // enter the passphrase from a client. SafeFile rolls back to the
+ // previous token if the new write fails verification.
+ SafeFile sf(TOKEN_FILENAME, /*fullAtomic=*/true);
+ sf.write(body, TOKEN_BODY_SIZE);
+ sf.write(hmac, HMAC_SIZE);
+ bool tokOk = sf.close();
+ if (!tokOk) {
+ LOG_ERROR("EncryptedStorage: token write/verify failed");
+ meshtastic_security::secure_zero(body, sizeof(body));
+ meshtastic_security::secure_zero(hmac, sizeof(hmac));
+ return false;
+ }
+
+ meshtastic_security::secure_zero(body, sizeof(body));
+ meshtastic_security::secure_zero(hmac, sizeof(hmac));
+
+ // M4: persist new max-counter-seen AFTER the token write committed.
+ // If this write fails the token is still valid (its counter is
+ // greater than the persisted value); readAndConsumeToken will
+ // promote .tokmono on the next read.
+ if (!writeMonoCounter(newMonoCounter)) {
+ LOG_WARN("EncryptedStorage: mono-counter persist failed (will self-heal on next read)");
+ }
+
+ LOG_INFO("EncryptedStorage: Unlock token written (boots=%d, epoch=%u, mono=%u)", bootsRemaining, validUntilEpoch,
+ (unsigned)newMonoCounter);
+ return true;
+#else
+ return false;
+#endif
+}
+
+/**
+ * Read, validate, and consume the unlock token.
+ * If valid: decrypts DEK into dek[], decrements boot count, rewrites token (or deletes if boots==0).
+ * Returns true if the token was valid and DEK was loaded.
+ */
+static bool readAndConsumeToken()
+{
+#ifdef FSCom
+ // Read the token file. M10 (audit): the 74-byte buffer holds the entire
+ // wrapped DEK + HMAC; using ZeroizingBuffer ensures the destructor
+ // wipes it on every return path (success and all the error cases
+ // below) without needing one secure_zero per goto-label.
+ meshtastic_security::ZeroizingBuffer buf;
+ {
+ concurrency::LockGuard g(spiLock);
+ auto f = FSCom.open(TOKEN_FILENAME, FILE_O_READ);
+ if (!f)
+ return false;
+
+ size_t fileSize = f.size();
+ if (fileSize != TOKEN_TOTAL_SIZE) {
+ f.close();
+ LOG_WARN("EncryptedStorage: Token file wrong size (%d), deleting", fileSize);
+ FSCom.remove(TOKEN_FILENAME);
+ lockReason = "token_wrong_size";
+ return false;
+ }
+
+ size_t bytesRead = f.read(buf.data(), TOKEN_TOTAL_SIZE);
+ f.close();
+
+ if (bytesRead != TOKEN_TOTAL_SIZE) {
+ LOG_ERROR("EncryptedStorage: Token short read");
+ FSCom.remove(TOKEN_FILENAME);
+ return false;
+ }
+ }
+
+ // Verify magic
+ uint32_t magic;
+ memcpy(&magic, buf.data(), 4);
+ if (magic != TOKEN_MAGIC) {
+ LOG_ERROR("EncryptedStorage: Token bad magic, deleting");
+ concurrency::LockGuard g(spiLock);
+ FSCom.remove(TOKEN_FILENAME);
+ lockReason = "token_bad_magic";
+ return false;
+ }
+
+ // Verify HMAC-SHA256(ephemeralKek, body). M10: ZeroizingBuffer wipes on scope exit.
+ meshtastic_security::ZeroizingBuffer computedHmac;
+ nRFCrypto.begin();
+ bool hmacOk = computeHMAC(ephemeralKek, AES_KEY_SIZE, buf.data(), TOKEN_BODY_SIZE, computedHmac.data());
+ nRFCrypto.end();
+
+ if (!hmacOk || !constTimeEq(computedHmac.data(), buf.data() + TOKEN_BODY_SIZE, HMAC_SIZE)) {
+ LOG_ERROR("EncryptedStorage: Token HMAC failed — tampered or wrong device, deleting");
+ concurrency::LockGuard g(spiLock);
+ FSCom.remove(TOKEN_FILENAME);
+ lockReason = "token_hmac_fail";
+ return false;
+ }
+
+ // Parse fields from body
+ size_t pos = 4; // skip magic
+ const uint8_t *nonce = buf.data() + pos;
+ pos += NONCE_SIZE;
+ const uint8_t *encDek = buf.data() + pos;
+ pos += AES_KEY_SIZE;
+ uint8_t bootsRemaining = buf[pos++];
+ uint32_t validUntilEpoch;
+ memcpy(&validUntilEpoch, buf.data() + pos, 4);
+ pos += 4;
+ uint32_t sessionMaxSeconds;
+ memcpy(&sessionMaxSeconds, buf.data() + pos, 4);
+ pos += 4;
+ uint32_t tokenMonoCounter;
+ memcpy(&tokenMonoCounter, buf.data() + pos, 4);
+
+ // M4 (audit): reject any token whose monotonic counter is below the
+ // persisted max-seen. An attacker who once read disk could otherwise
+ // restore a higher-bootcount / weaker-policy token even after the
+ // operator unlocked again with tighter parameters; this check makes
+ // such a restore visible and fatal at boot.
+ //
+ // If the token's counter is GREATER than what we've persisted (e.g.
+ // the .tokmono file was lost via factory-erase, or the persist after
+ // a token write itself failed), accept and promote .tokmono to the
+ // current value. Equal is the normal case post-write.
+ uint32_t maxSeenCounter = readMonoCounter();
+ if (tokenMonoCounter < maxSeenCounter) {
+ LOG_ERROR("EncryptedStorage: Token rollback detected (counter=%u, max-seen=%u), deleting", (unsigned)tokenMonoCounter,
+ (unsigned)maxSeenCounter);
+ concurrency::LockGuard g(spiLock);
+ FSCom.remove(TOKEN_FILENAME);
+ lockReason = "token_rollback";
+ return false;
+ }
+ if (tokenMonoCounter > maxSeenCounter) {
+ // Self-heal: this token is newer than what we knew, promote it.
+ writeMonoCounter(tokenMonoCounter);
+ }
+
+ // Check boot count
+ if (bootsRemaining == 0) {
+ LOG_WARN("EncryptedStorage: Token boot count exhausted, deleting");
+ concurrency::LockGuard g(spiLock);
+ FSCom.remove(TOKEN_FILENAME);
+ lockReason = "token_boots_zero";
+ return false;
+ }
+
+ // Check time expiry. A wall-clock TTL (validUntilEpoch != 0) needs a
+ // currently valid RTC to verify. getValidTime() returns 0 unless we
+ // actually have an RTC source — getTime() would return a boot-relative
+ // count, which an attacker can reset by power-cycling with no RTC sync.
+ //
+ // If the wall-clock TTL is set but we can't verify it right now:
+ // - boot count still has budget -> fall back to the boot-count TTL,
+ // keep the token. The boot count is independently verifiable
+ // without an RTC, so the token is not unbounded.
+ // - boot count is the only thing we had and it's zero -> already
+ // rejected above. (validUntilEpoch is never the *sole* TTL here:
+ // bootsRemaining > 0 is guaranteed by the check above.)
+ // We only hard-reject (delete) a token whose wall-clock TTL we *can*
+ // evaluate and find expired.
+ if (validUntilEpoch != 0) {
+ uint32_t now = getValidTime(RTCQualityDevice);
+ if (now == 0) {
+ LOG_WARN("EncryptedStorage: Token wall-clock TTL unverifiable (no RTC), falling back to boot count (%u left)",
+ bootsRemaining);
+ } else if (now > validUntilEpoch) {
+ LOG_WARN("EncryptedStorage: Token expired (now=%u, until=%u), deleting", now, validUntilEpoch);
+ concurrency::LockGuard g(spiLock);
+ FSCom.remove(TOKEN_FILENAME);
+ lockReason = "token_expired";
+ return false;
+ }
+ }
+
+ // Decrypt DEK from token
+ nRFCrypto.begin();
+ bool decOk = aesCtr128(ephemeralKek, nonce, NONCE_SIZE, encDek, AES_KEY_SIZE, dek);
+ nRFCrypto.end();
+
+ if (!decOk) {
+ LOG_ERROR("EncryptedStorage: Token DEK decrypt failed");
+ meshtastic_security::secure_zero(dek, sizeof(dek));
+ concurrency::LockGuard g(spiLock);
+ FSCom.remove(TOKEN_FILENAME);
+ lockReason = "token_dek_fail";
+ return false;
+ }
+
+ // Decrement boot count and rewrite (or delete if now zero)
+ uint8_t newBoots = bootsRemaining - 1;
+ if (newBoots == 0) {
+ LOG_INFO("EncryptedStorage: Token last boot consumed, deleting");
+ concurrency::LockGuard g(spiLock);
+ FSCom.remove(TOKEN_FILENAME);
+ } else {
+ writeUnlockToken(newBoots, validUntilEpoch, sessionMaxSeconds);
+ }
+
+ dekLoaded = true;
+ s_bootsRemaining = newBoots;
+ s_validUntilEpoch = validUntilEpoch;
+ // Start the session timer if the token carries one. Token-auto-unlocked
+ // boots inherit the same cap that was set at passphrase-unlock time.
+ setSession(sessionMaxSeconds);
+ LOG_INFO("EncryptedStorage: Token valid, DEK loaded (%d boots remaining%s)", newBoots,
+ sessionMaxSeconds ? ", session timer armed" : "");
+ return true;
+#else
+ return false;
+#endif
+}
+
+// ---------------------------------------------------------------------------
+// Internal helpers: DEK generation
+// ---------------------------------------------------------------------------
+
+static bool generateDEK()
+{
+ nRFCrypto.begin();
+ bool ok = nRFCrypto.Random.generate(dek, AES_KEY_SIZE);
+ nRFCrypto.end();
+
+ if (!ok) {
+ LOG_ERROR("EncryptedStorage: TRNG failed generating DEK");
+ meshtastic_security::secure_zero(dek, sizeof(dek));
+ return false;
+ }
+ return true;
+}
+
+// ---------------------------------------------------------------------------
+// Public API: passphrase-gated boot
+// ---------------------------------------------------------------------------
+
+void initLocked()
+{
+ nRFCrypto.begin();
+ bool ephOk = deriveEphemeralKEK();
+ nRFCrypto.end();
+
+ if (!ephOk) {
+ LOG_ERROR("EncryptedStorage: Ephemeral KEK derivation failed");
+ return;
+ }
+
+ // Per-boot increment of bootsSinceFail. Drives the cross-reboot layer of
+ // the passphrase-attempt backoff so an attacker can't bypass exponential
+ // delays just by power-cycling between attempts.
+ bumpBootsSinceFailOnBoot();
+
+ // Attempt to unlock via stored token
+ if (!isProvisioned()) {
+ lockReason = "not_provisioned";
+ } else {
+#ifdef FSCom
+ {
+ concurrency::LockGuard g(spiLock);
+ if (!FSCom.exists(TOKEN_FILENAME))
+ lockReason = "token_missing";
+ }
+#endif
+ }
+
+ if (readAndConsumeToken()) {
+ lockReason = "ok";
+ LOG_INFO("EncryptedStorage: Unlocked via token");
+ return;
+ }
+
+ // Not unlocked — log the device state for the operator
+ if (isProvisioned()) {
+ LOG_WARN("EncryptedStorage: Device LOCKED — reason: %s", lockReason);
+ } else {
+ LOG_WARN("EncryptedStorage: Device NOT PROVISIONED — operator must set passphrase");
+ }
+}
+
+const char *getLockReason()
+{
+ return lockReason;
+}
+
+uint8_t getBootsRemaining()
+{
+ return s_bootsRemaining;
+}
+
+uint32_t getValidUntilEpoch()
+{
+ return s_validUntilEpoch;
+}
+
+uint32_t getBackoffSecondsRemaining()
+{
+ return s_backoffSecondsRemaining;
+}
+
+void setSession(uint32_t maxSeconds)
+{
+ s_sessionMaxMs = maxSeconds * 1000UL;
+ s_sessionStartedMs = millis();
+}
+
+bool isSessionExpired()
+{
+ if (s_sessionMaxMs == 0)
+ return false;
+ return (millis() - s_sessionStartedMs) > s_sessionMaxMs;
+}
+
+uint32_t getSessionRemainingSeconds()
+{
+ if (s_sessionMaxMs == 0)
+ return 0;
+ uint32_t elapsedMs = millis() - s_sessionStartedMs;
+ if (elapsedMs >= s_sessionMaxMs)
+ return 0;
+ return (s_sessionMaxMs - elapsedMs) / 1000UL;
+}
+
+uint8_t consumeSessionBoot()
+{
+ if (s_bootsRemaining == 0) {
+ // Caller-side error: no budget to consume. Don't touch flash.
+ return 0;
+ }
+ uint8_t newBoots = s_bootsRemaining - 1;
+#ifdef FSCom
+ if (newBoots == 0) {
+ // Last session: delete the on-flash token. The DEK in RAM stays
+ // live for this final session; the next session expiry will see
+ // s_bootsRemaining == 0 and exhaust into a hard lock + reboot.
+ concurrency::LockGuard g(spiLock);
+ FSCom.remove(TOKEN_FILENAME);
+ } else {
+ // Rewrite the token with the new count. Carries the existing
+ // validUntilEpoch and the in-RAM sessionMaxSeconds forward so
+ // token-auto-unlock on any future reboot honors the same policy.
+ writeUnlockToken(newBoots, s_validUntilEpoch, s_sessionMaxMs / 1000UL);
+ }
+#endif
+ s_bootsRemaining = newBoots;
+ // Re-arm the uptime session in place. setSession() with the same
+ // duration resets s_sessionStartedMs = millis(), starting the next
+ // session window without rebooting.
+ setSession(s_sessionMaxMs / 1000UL);
+ return newBoots;
+}
+
+bool provisionPassphrase(const uint8_t *passphrase, size_t passphraseLen, uint8_t bootsRemaining, uint32_t validUntilEpoch,
+ uint32_t sessionMaxSeconds)
+{
+ // MED-8: proto private_key field is 32 bytes; cap to match (was incorrectly 64)
+ if (passphraseLen == 0 || passphraseLen > 32) {
+ LOG_ERROR("EncryptedStorage: Invalid passphrase length %d", passphraseLen);
+ return false;
+ }
+
+ if (!ephemeralKekDerived) {
+ nRFCrypto.begin();
+ bool ok = deriveEphemeralKEK();
+ nRFCrypto.end();
+ if (!ok)
+ return false;
+ }
+
+ if (!generateDEK())
+ return false;
+
+ // Derive KEK from passphrase
+ nRFCrypto.begin();
+ bool kekOk = deriveKEK(passphrase, passphraseLen);
+ nRFCrypto.end();
+ if (!kekOk) {
+ meshtastic_security::secure_zero(dek, sizeof(dek));
+ return false;
+ }
+
+ // Save the DEK file
+ if (!saveDEK()) {
+ meshtastic_security::secure_zero(dek, sizeof(dek));
+ kekDerived = false;
+ return false;
+ }
+
+ // Create unlock token (validUntilEpoch is an absolute Unix timestamp from the client; 0 = no limit)
+ if (!writeUnlockToken(bootsRemaining, validUntilEpoch, sessionMaxSeconds)) {
+ LOG_WARN("EncryptedStorage: Token write failed after provision (continuing unlocked)");
+ }
+
+ // H4 (audit): seed an attempts=0 backoff sentinel so the file is
+ // present post-provision. From this point on, a missing backoff file
+ // means an attacker deleted it — readBackoff returns max-attempts and
+ // forces a full backoff window. Without this, the very first failed
+ // attempt would find a missing file and have to choose between fresh
+ // (no penalty, attacker bypass) and tamper (legitimate user locked
+ // out on first typo).
+ clearBackoff();
+
+ dekLoaded = true;
+ s_bootsRemaining = bootsRemaining;
+ s_validUntilEpoch = validUntilEpoch;
+ setSession(sessionMaxSeconds);
+ LOG_INFO("EncryptedStorage: Provisioning complete");
+ return true;
+}
+
+bool unlockWithPassphrase(const uint8_t *passphrase, size_t passphraseLen, uint8_t bootsRemaining, uint32_t validUntilEpoch,
+ uint32_t sessionMaxSeconds)
+{
+ // MED-8: proto private_key field is 32 bytes; cap to match (was incorrectly 64)
+ if (passphraseLen == 0 || passphraseLen > 32) {
+ LOG_ERROR("EncryptedStorage: Invalid passphrase length %d", passphraseLen);
+ return false;
+ }
+
+ // Exponential backoff. Three independent enforcement layers — any one
+ // triggers a block — so the policy is robust whether RTC is valid, never
+ // synced, or being spoofed within reasonable bounds:
+ //
+ // 1. Within-boot: millis() since the last failure on THIS boot. RAM-only,
+ // reliable, immune to RTC tampering.
+ // 2. Wall-clock: lastFailEpoch vs getValidTime(). Only checked when the
+ // RTC is actually valid (getValidTime() returns 0 otherwise) AND the
+ // epoch we persisted at failure was itself valid.
+ // 3. Reboot count: bootsSinceFail must be >= ceil(delay / ~5s reboot).
+ // Survives across reboots without any time source; each reboot only
+ // advances it by 1 and costs the attacker boot time (~3-5 s on nRF52).
+ {
+ uint8_t attempts;
+ uint8_t bootsSinceFail;
+ uint32_t lastFailEpoch;
+ readBackoff(attempts, bootsSinceFail, lastFailEpoch);
+
+ if (attempts > 0) {
+ uint32_t delay = backoffDelay(attempts);
+ uint32_t maxRemaining = 0;
+
+ // (1) within-boot enforcement using millis()
+ if (s_lastFailMillis != 0) {
+ uint32_t elapsedSec = (millis() - s_lastFailMillis) / 1000u;
+ if (elapsedSec < delay) {
+ uint32_t r = delay - elapsedSec;
+ if (r > maxRemaining)
+ maxRemaining = r;
+ }
+ }
+
+ // (2) wall-clock enforcement when RTC is actually valid AND the
+ // persisted lastFailEpoch was recorded with a valid RTC.
+ uint32_t now = getValidTime(RTCQualityDevice);
+ if (now != 0 && lastFailEpoch != 0 && now >= lastFailEpoch) {
+ uint32_t elapsed = now - lastFailEpoch;
+ if (elapsed < delay) {
+ uint32_t r = delay - elapsed;
+ if (r > maxRemaining)
+ maxRemaining = r;
+ }
+ }
+
+ // (3) reboot-count fallback. Always enforced — closes the bypass
+ // where an attacker reboots between attempts (which resets millis
+ // and may leave the RTC unsynced). Conservative: assume ~5 s per
+ // reboot cycle, so require ceil(delay / 5) boots to elapse.
+ uint8_t bootsNeeded = (uint8_t)std::min(255u, (delay + 4u) / 5u);
+ if (bootsNeeded == 0)
+ bootsNeeded = 1;
+ if (bootsSinceFail < bootsNeeded) {
+ // Estimate remaining seconds for client UX: missing boots * 5s.
+ uint32_t r = (uint32_t)(bootsNeeded - bootsSinceFail) * 5u;
+ if (r > maxRemaining)
+ maxRemaining = r;
+ }
+
+ if (maxRemaining > 0) {
+ s_backoffSecondsRemaining = maxRemaining;
+ LOG_WARN("EncryptedStorage: Passphrase attempt blocked by backoff (~%us remaining)", s_backoffSecondsRemaining);
+ return false;
+ }
+ }
+ s_backoffSecondsRemaining = 0;
+ }
+
+ if (!ephemeralKekDerived) {
+ nRFCrypto.begin();
+ bool ok = deriveEphemeralKEK();
+ nRFCrypto.end();
+ if (!ok)
+ return false;
+ }
+
+ // Derive KEK
+ nRFCrypto.begin();
+ bool kekOk = deriveKEK(passphrase, passphraseLen);
+ nRFCrypto.end();
+ if (!kekOk)
+ return false;
+
+ // H3 (audit): RESERVE the attempt slot on disk BEFORE running the HMAC
+ // verify. The previous design wrote the failure record only after a
+ // failed verify, so pulling power between verify and the write left
+ // attempts unchanged — an attacker could glitch the chip mid-call to
+ // bypass the counter. Pre-incrementing means the attempt is durably
+ // recorded regardless of what happens to the chip during verify; the
+ // success path then writes attempts=0 to clear the reservation.
+ uint8_t reservedAttempts;
+ {
+ uint8_t bs;
+ uint32_t lfe;
+ readBackoff(reservedAttempts, bs, lfe);
+ if (reservedAttempts < 255)
+ reservedAttempts++;
+ uint32_t now = getValidTime(RTCQualityDevice);
+ writeBackoff(reservedAttempts, 0, now);
+ }
+ auto onFailure = [reservedAttempts]() {
+ s_lastFailMillis = millis();
+ if (s_lastFailMillis == 0)
+ s_lastFailMillis = 1; // sentinel: never 0 after a real fail
+ s_backoffSecondsRemaining = backoffDelay(reservedAttempts);
+ LOG_WARN("EncryptedStorage: Wrong passphrase (attempt %u, next in ~%us)", (unsigned)reservedAttempts,
+ s_backoffSecondsRemaining);
+ };
+
+ // Load the DEK (verifies HMAC — wrong passphrase will fail here)
+ if (!loadDEK()) {
+ LOG_ERROR("EncryptedStorage: DEK load failed — wrong passphrase?");
+ kekDerived = false;
+ onFailure();
+ return false;
+ }
+
+ // Passphrase correct — clear the reserved attempt by writing an
+ // attempts=0 sentinel (NOT removing the file; see clearBackoff note).
+ clearBackoff();
+
+ // Create fresh unlock token (validUntilEpoch is an absolute Unix timestamp from the client; 0 = no limit)
+ if (!writeUnlockToken(bootsRemaining, validUntilEpoch, sessionMaxSeconds)) {
+ LOG_WARN("EncryptedStorage: Token write failed after unlock (continuing unlocked this boot)");
+ }
+
+ dekLoaded = true;
+ s_bootsRemaining = bootsRemaining;
+ s_validUntilEpoch = validUntilEpoch;
+ setSession(sessionMaxSeconds);
+ LOG_INFO("EncryptedStorage: Unlocked with passphrase");
+ return true;
+}
+
+void lockNow()
+{
+#ifdef FSCom
+ {
+ concurrency::LockGuard g(spiLock);
+ FSCom.remove(TOKEN_FILENAME);
+ }
+#endif
+ secureWipeKeys();
+ s_sessionMaxMs = 0;
+ s_sessionStartedMs = 0;
+ LOG_INFO("EncryptedStorage: Device locked — token deleted, DEK and KEK material zeroed");
+}
+
+void secureWipeKeys()
+{
+ // M11 (audit): callable from fault handlers. Do NOT take spiLock and do
+ // NOT log — interrupt-context safety. Just zero every byte of key
+ // material that the rest of the module might leave in BSS.
+ meshtastic_security::secure_zero(dek, sizeof(dek));
+ dekLoaded = false;
+ meshtastic_security::secure_zero(kek, sizeof(kek));
+ kekDerived = false;
+ meshtastic_security::secure_zero(ephemeralKek, sizeof(ephemeralKek));
+ ephemeralKekDerived = false;
+}
+
+bool isProvisioned()
+{
+#ifdef FSCom
+ concurrency::LockGuard g(spiLock);
+ return FSCom.exists(DEK_FILENAME);
+#else
+ return false;
+#endif
+}
+
+bool isUnlocked()
+{
+ return dekLoaded;
+}
+
+bool isLockdownActive()
+{
+ // Lockdown is "active" iff the device has been provisioned with a
+ // passphrase — i.e. a DEK exists on flash. A lockdown-capable build
+ // (MESHTASTIC_LOCKDOWN) that has never been provisioned, or that has
+ // been disabled via disableLockdown(), is NOT active and behaves like
+ // stock firmware: plaintext storage, no redaction, normal logging.
+ //
+ // Backed by isProvisioned() rather than a separate cached flag so there
+ // is a single source of truth (the .dek file) and no chicken-and-egg
+ // with reading a config bit out of the encrypted config. The filesystem
+ // existence check is cheap (LittleFS stat).
+ return isProvisioned();
+}
+
+// ---------------------------------------------------------------------------
+// Encrypted file I/O
+// ---------------------------------------------------------------------------
+
+bool isEncrypted(const char *filename)
+{
+#ifdef FSCom
+ concurrency::LockGuard g(spiLock);
+ auto f = FSCom.open(filename, FILE_O_READ);
+ if (!f)
+ return false;
+
+ uint32_t magic = 0;
+ size_t bytesRead = f.read((uint8_t *)&magic, 4);
+ f.close();
+
+ return (bytesRead == 4 && magic == MAGIC);
+#else
+ return false;
+#endif
+}
+
+bool readAndDecrypt(const char *filename, uint8_t *outBuf, size_t outBufSize, size_t &outLen)
+{
+ outLen = 0; // MED-6: initialise before any early return so callers never see stale length
+
+ if (!dekLoaded) {
+ LOG_ERROR("EncryptedStorage: Not unlocked");
+ return false;
+ }
+
+ // MED-1: snapshot DEK before any lock acquisition so a concurrent lockNow() cannot
+ // zero dek[] mid-operation. If lockNow() races and zeros dek[] before we copy, the
+ // snapshot will be zero and the HMAC will fail — a secure failure mode.
+ uint8_t dekSnapshot[AES_KEY_SIZE];
+ memcpy(dekSnapshot, dek, AES_KEY_SIZE);
+
+#ifdef FSCom
+ meshtastic_security::ZeroizingArrayPtr fileBuf{nullptr, meshtastic_security::ZeroizingArrayDeleter{0}};
+ size_t fileSize = 0;
+
+ // MED-3: hold spiLock only for file I/O; release it before any crypto operation.
+ // spiLock is a non-recursive binary semaphore — re-entry from the same task deadlocks.
+ {
+ concurrency::LockGuard g(spiLock);
+ auto f = FSCom.open(filename, FILE_O_READ);
+ if (!f) {
+ LOG_ERROR("EncryptedStorage: Can't open %s", filename);
+ meshtastic_security::secure_zero(dekSnapshot, sizeof(dekSnapshot));
+ return false;
+ }
+
+ fileSize = f.size();
+ if (fileSize < OVERHEAD) {
+ LOG_ERROR("EncryptedStorage: File %s too small (%d bytes)", filename, fileSize);
+ f.close();
+ meshtastic_security::secure_zero(dekSnapshot, sizeof(dekSnapshot));
+ return false;
+ }
+
+ // L-1: upper-bound check to prevent OOM / integer overflow on corrupt or oversized files.
+ // Derived from the caller's outBufSize: the ciphertext we accept here
+ // can never decode to more than outBufSize bytes of plaintext, plus
+ // OVERHEAD (nonce + HMAC framing). Anything larger is either corrupt
+ // or maliciously oversized. Avoids a hardcoded 64 KB cap that would
+ // wrongly reject legitimate large NodeDB files on variants where
+ // MAX_NUM_NODES pushes the serialised protobuf past that limit.
+ const size_t maxAcceptedFileSize = outBufSize + OVERHEAD;
+ if (fileSize > maxAcceptedFileSize) {
+ LOG_ERROR("EncryptedStorage: File %s too large (%d bytes, max %d), refusing", filename, fileSize,
+ maxAcceptedFileSize);
+ f.close();
+ meshtastic_security::secure_zero(dekSnapshot, sizeof(dekSnapshot));
+ return false;
+ }
+
+ fileBuf = meshtastic_security::make_zeroizing_array(fileSize);
+ if (!fileBuf) {
+ LOG_ERROR("EncryptedStorage: OOM reading %s", filename);
+ f.close();
+ meshtastic_security::secure_zero(dekSnapshot, sizeof(dekSnapshot));
+ return false;
+ }
+
+ size_t bytesRead = f.read(fileBuf.get(), fileSize);
+ f.close();
+
+ if (bytesRead != fileSize) {
+ LOG_ERROR("EncryptedStorage: Short read on %s", filename);
+ meshtastic_security::secure_zero(dekSnapshot, sizeof(dekSnapshot));
+ return false;
+ }
+ } // spiLock released here — MED-3
+
+ // Parse header (outside spiLock)
+ size_t pos = 0;
+ uint32_t magic;
+ memcpy(&magic, fileBuf.get() + pos, 4);
+ pos += 4;
+ if (magic != MAGIC) {
+ LOG_ERROR("EncryptedStorage: Bad magic in %s", filename);
+ meshtastic_security::secure_zero(dekSnapshot, sizeof(dekSnapshot));
+ return false;
+ }
+
+ uint8_t nonce[NONCE_SIZE];
+ memcpy(nonce, fileBuf.get() + pos, NONCE_SIZE);
+ pos += NONCE_SIZE;
+
+ uint32_t plaintextLen;
+ memcpy(&plaintextLen, fileBuf.get() + pos, 4);
+ pos += 4;
+
+ size_t ciphertextLen = fileSize - HEADER_SIZE - HMAC_SIZE;
+ const uint8_t *ciphertext = fileBuf.get() + pos;
+ const uint8_t *storedHmac = fileBuf.get() + fileSize - HMAC_SIZE;
+
+ // M2 (audit): HMAC now covers the full on-disk header — magic +
+ // plaintext_len in addition to the nonce + ciphertext that the original
+ // design covered. Without this, the 4-byte magic and 4-byte plaintext_len
+ // bytes are integrity-protected only by the equality check
+ // `plaintextLen == ciphertextLen`, which silently breaks the moment we
+ // ever add padding, compression, or AAD to the format. Putting the
+ // header inside the MAC closes that pre-condition cleanly.
+ //
+ // HMAC = HMAC-SHA256(dekSnapshot, magic || nonce || plaintext_len || ciphertext)
+ //
+ // Format-breaking vs. pre-v1-cleanup files; this is acceptable because
+ // we haven't shipped a production lockdown release yet.
+ size_t hmacDataLen = 4 /*magic*/ + NONCE_SIZE + 4 /*plaintext_len*/ + ciphertextLen;
+ auto hmacData = meshtastic_security::make_zeroizing_array(hmacDataLen);
+ if (!hmacData) {
+ LOG_ERROR("EncryptedStorage: OOM for HMAC data");
+ meshtastic_security::secure_zero(dekSnapshot, sizeof(dekSnapshot));
+ return false;
+ }
+ memcpy(hmacData.get(), &magic, 4);
+ memcpy(hmacData.get() + 4, nonce, NONCE_SIZE);
+ memcpy(hmacData.get() + 4 + NONCE_SIZE, &plaintextLen, 4);
+ memcpy(hmacData.get() + 4 + NONCE_SIZE + 4, ciphertext, ciphertextLen);
+
+ uint8_t computedHmac[HMAC_SIZE];
+ nRFCrypto.begin();
+ bool hmacOk = computeHMAC(dekSnapshot, AES_KEY_SIZE, hmacData.get(), hmacDataLen, computedHmac);
+ nRFCrypto.end();
+ hmacData.reset();
+
+ if (!hmacOk || !constTimeEq(computedHmac, storedHmac, HMAC_SIZE)) {
+ LOG_ERROR("EncryptedStorage: HMAC verification failed for %s", filename);
+ meshtastic_security::secure_zero(computedHmac, sizeof(computedHmac));
+ meshtastic_security::secure_zero(dekSnapshot, sizeof(dekSnapshot));
+ return false;
+ }
+ meshtastic_security::secure_zero(computedHmac, sizeof(computedHmac));
+
+ // plaintextLen is not covered by the HMAC, so validate it against the actual ciphertext
+ // length derived from the file size. For AES-CTR the two are always equal in a legitimate
+ // file; a mismatch means the header field was tampered independently of the ciphertext.
+ if (plaintextLen != ciphertextLen) {
+ LOG_ERROR("EncryptedStorage: plaintextLen (%d) != ciphertextLen (%d) in %s — header tampered", plaintextLen,
+ (uint32_t)ciphertextLen, filename);
+ meshtastic_security::secure_zero(dekSnapshot, sizeof(dekSnapshot));
+ return false;
+ }
+
+ // Decrypt using dekSnapshot — MED-1
+ if (plaintextLen > outBufSize) {
+ LOG_ERROR("EncryptedStorage: Output buffer too small for %s (%d > %d)", filename, plaintextLen, outBufSize);
+ meshtastic_security::secure_zero(dekSnapshot, sizeof(dekSnapshot));
+ return false;
+ }
+
+ nRFCrypto.begin();
+ bool decOk = aesCtr128(dekSnapshot, nonce, NONCE_SIZE, ciphertext, ciphertextLen, outBuf);
+ nRFCrypto.end();
+ fileBuf.reset();
+ meshtastic_security::secure_zero(dekSnapshot, sizeof(dekSnapshot));
+
+ if (!decOk) {
+ LOG_ERROR("EncryptedStorage: Decrypt failed for %s", filename);
+ memset(outBuf, 0, ciphertextLen); // MED-7: clear any partial plaintext written to caller's buffer
+ return false;
+ }
+
+ outLen = plaintextLen;
+ LOG_INFO("EncryptedStorage: Decrypted %s (%d bytes)", filename, outLen);
+ return true;
+#else
+ meshtastic_security::secure_zero(dekSnapshot, sizeof(dekSnapshot));
+ return false;
+#endif
+}
+
+bool encryptAndWrite(const char *filename, const uint8_t *plaintext, size_t plaintextLen, bool fullAtomic)
+{
+ if (!dekLoaded) {
+ LOG_ERROR("EncryptedStorage: Not unlocked");
+ return false;
+ }
+
+ // MED-1: snapshot DEK so a concurrent lockNow() cannot zero dek[] mid-operation.
+ uint8_t dekSnapshot[AES_KEY_SIZE];
+ memcpy(dekSnapshot, dek, AES_KEY_SIZE);
+
+#ifdef FSCom
+ uint8_t nonce[NONCE_SIZE];
+ nRFCrypto.begin();
+ if (!nRFCrypto.Random.generate(nonce, NONCE_SIZE)) {
+ LOG_ERROR("EncryptedStorage: TRNG failed for file nonce");
+ nRFCrypto.end();
+ meshtastic_security::secure_zero(dekSnapshot, sizeof(dekSnapshot));
+ return false;
+ }
+
+ size_t ciphertextLen = plaintextLen;
+ auto ciphertext = meshtastic_security::make_zeroizing_array(ciphertextLen > 0 ? ciphertextLen : 1);
+ if (!ciphertext) {
+ LOG_ERROR("EncryptedStorage: OOM for ciphertext");
+ nRFCrypto.end();
+ meshtastic_security::secure_zero(dekSnapshot, sizeof(dekSnapshot));
+ return false;
+ }
+
+ bool encOk = aesCtr128(dekSnapshot, nonce, NONCE_SIZE, plaintext, plaintextLen, ciphertext.get());
+ if (!encOk) {
+ LOG_ERROR("EncryptedStorage: Encrypt failed for %s", filename);
+ nRFCrypto.end();
+ meshtastic_security::secure_zero(dekSnapshot, sizeof(dekSnapshot));
+ return false;
+ }
+
+ // M2 (audit): HMAC covers the full header (magic + plaintext_len) in
+ // addition to nonce + ciphertext. See readAndDecrypt for the rationale.
+ // Must match the read side exactly — keep both updates in lockstep.
+ uint32_t magicForMac = MAGIC;
+ uint32_t plaintextLenForMac = (uint32_t)plaintextLen;
+ size_t hmacDataLen = 4 + NONCE_SIZE + 4 + ciphertextLen;
+ auto hmacData = meshtastic_security::make_zeroizing_array(hmacDataLen);
+ if (!hmacData) {
+ LOG_ERROR("EncryptedStorage: OOM for HMAC data");
+ nRFCrypto.end();
+ meshtastic_security::secure_zero(dekSnapshot, sizeof(dekSnapshot));
+ return false;
+ }
+ memcpy(hmacData.get(), &magicForMac, 4);
+ memcpy(hmacData.get() + 4, nonce, NONCE_SIZE);
+ memcpy(hmacData.get() + 4 + NONCE_SIZE, &plaintextLenForMac, 4);
+ if (ciphertextLen > 0)
+ memcpy(hmacData.get() + 4 + NONCE_SIZE + 4, ciphertext.get(), ciphertextLen);
+
+ uint8_t hmac[HMAC_SIZE];
+ bool hmacOk = computeHMAC(dekSnapshot, AES_KEY_SIZE, hmacData.get(), hmacDataLen, hmac);
+ nRFCrypto.end();
+ hmacData.reset();
+
+ if (!hmacOk) {
+ LOG_ERROR("EncryptedStorage: HMAC computation failed for %s", filename);
+ meshtastic_security::secure_zero(dekSnapshot, sizeof(dekSnapshot));
+ return false;
+ }
+
+ meshtastic_security::secure_zero(dekSnapshot, sizeof(dekSnapshot)); // MED-1: no longer needed after HMAC computed
+
+ // SafeFile handles remove-before-write (nRF52) and tmp+readback+rename (other platforms).
+ // fullAtomic controls whether the old file is kept until the rename succeeds.
+ SafeFile sf(filename, fullAtomic);
+
+ uint32_t magic = MAGIC;
+ sf.write((uint8_t *)&magic, 4);
+ sf.write(nonce, NONCE_SIZE);
+ uint32_t ptLen = (uint32_t)plaintextLen;
+ sf.write((uint8_t *)&ptLen, 4);
+ sf.write(ciphertext.get(), ciphertextLen);
+ sf.write(hmac, HMAC_SIZE);
+ ciphertext.reset();
+
+ if (!sf.close()) {
+ LOG_ERROR("EncryptedStorage: Write/verify failed for %s", filename);
+ return false;
+ }
+
+ LOG_INFO("EncryptedStorage: Encrypted %s (%d bytes plaintext)", filename, plaintextLen);
+ return true;
+#else
+ meshtastic_security::secure_zero(dekSnapshot, sizeof(dekSnapshot));
+ return false;
+#endif
+}
+
+bool migrateFile(const char *filename)
+{
+ // L-2: Precondition — spiLock must NOT be held by the calling task when this function
+ // is called. Both isEncrypted() and encryptAndWrite() (called internally) acquire
+ // spiLock; since it is a non-recursive binary semaphore, re-entry deadlocks the task.
+#ifdef FSCom
+ if (isEncrypted(filename)) {
+ LOG_DEBUG("EncryptedStorage: %s already encrypted, skip migration", filename);
+ return true;
+ }
+
+ meshtastic_security::ZeroizingArrayPtr plaintext{nullptr, meshtastic_security::ZeroizingArrayDeleter{0}};
+ size_t fileSize = 0;
+ {
+ concurrency::LockGuard g(spiLock);
+ auto f = FSCom.open(filename, FILE_O_READ);
+ if (!f) {
+ LOG_WARN("EncryptedStorage: %s doesn't exist, skip migration", filename);
+ return false;
+ }
+
+ fileSize = f.size();
+ if (fileSize == 0) {
+ f.close();
+ return false;
+ }
+
+ // M25 (audit): refuse to allocate a buffer for an attacker-injected
+ // oversized file. The legitimate ceiling is the largest proto file
+ // we ever write — comfortably under 64 KiB on every supported
+ // variant. Anything significantly larger is either corrupt or
+ // hostile (e.g. DFU file inject); reading it into RAM would OOM
+ // the device.
+ constexpr size_t kMigrateMaxFileSize = 64 * 1024;
+ if (fileSize > kMigrateMaxFileSize) {
+ LOG_ERROR("EncryptedStorage: refusing to migrate %s — size %u exceeds %u-byte cap", filename, (unsigned)fileSize,
+ (unsigned)kMigrateMaxFileSize);
+ f.close();
+ return false;
+ }
+
+ plaintext = meshtastic_security::make_zeroizing_array(fileSize);
+ if (!plaintext) {
+ LOG_ERROR("EncryptedStorage: OOM migrating %s", filename);
+ f.close();
+ return false;
+ }
+
+ size_t bytesRead = f.read(plaintext.get(), fileSize);
+ f.close();
+
+ if (bytesRead != fileSize) {
+ LOG_ERROR("EncryptedStorage: Short read migrating %s", filename);
+ return false;
+ }
+ }
+
+ bool ok = encryptAndWrite(filename, plaintext.get(), fileSize);
+
+ if (ok) {
+ LOG_INFO("EncryptedStorage: Migrated %s to encrypted format", filename);
+ }
+ return ok;
+#else
+ return false;
+#endif
+}
+
+bool migrateFileToPlaintext(const char *filename)
+{
+ // Inverse of migrateFile: decrypt an encrypted file and rewrite it as
+ // plaintext, atomically. Idempotent — a file that is already plaintext
+ // is a no-op success, which is what makes the disable flow re-runnable
+ // after a power-loss crash.
+#ifdef FSCom
+ if (!isEncrypted(filename)) {
+ LOG_DEBUG("EncryptedStorage: %s already plaintext, skip revert", filename);
+ return true;
+ }
+ if (!dekLoaded) {
+ LOG_ERROR("EncryptedStorage: cannot revert %s — not unlocked", filename);
+ return false;
+ }
+
+ // Determine the plaintext size so we can size the output buffer. The
+ // ciphertext length == plaintext length for AES-CTR, so file size minus
+ // OVERHEAD is the upper bound. Cap as in migrateFile (M25).
+ size_t fileSize = 0;
+ {
+ concurrency::LockGuard g(spiLock);
+ auto f = FSCom.open(filename, FILE_O_READ);
+ if (!f) {
+ LOG_WARN("EncryptedStorage: %s missing during revert", filename);
+ return false;
+ }
+ fileSize = f.size();
+ f.close();
+ }
+ if (fileSize < OVERHEAD || fileSize > 64 * 1024) {
+ LOG_ERROR("EncryptedStorage: %s bad size %u for revert", filename, (unsigned)fileSize);
+ return false;
+ }
+ size_t plaintextCap = fileSize - OVERHEAD;
+
+ auto plaintext = meshtastic_security::make_zeroizing_array(plaintextCap > 0 ? plaintextCap : 1);
+ if (!plaintext) {
+ LOG_ERROR("EncryptedStorage: OOM reverting %s", filename);
+ return false;
+ }
+ size_t plaintextLen = 0;
+ if (!readAndDecrypt(filename, plaintext.get(), plaintextCap, plaintextLen)) {
+ LOG_ERROR("EncryptedStorage: decrypt failed reverting %s", filename);
+ return false;
+ }
+
+ // Write the plaintext over the encrypted file. SafeFile (non-encrypted
+ // direct write — NOT encryptAndWrite) atomically replaces it.
+ SafeFile sf(filename, /*fullAtomic=*/true);
+ sf.write(plaintext.get(), plaintextLen);
+ if (!sf.close()) {
+ LOG_ERROR("EncryptedStorage: plaintext write failed for %s", filename);
+ return false;
+ }
+ LOG_INFO("EncryptedStorage: Reverted %s to plaintext (%u bytes)", filename, (unsigned)plaintextLen);
+ return true;
+#else
+ return false;
+#endif
+}
+
+void removeLockdownArtifacts()
+{
+#ifdef FSCom
+ {
+ concurrency::LockGuard g(spiLock);
+ FSCom.remove(DEK_FILENAME); // deleting this is the commit point: lockdown is now off
+ FSCom.remove(TOKEN_FILENAME);
+ FSCom.remove(MONO_FILENAME);
+ FSCom.remove(BACKOFF_FILENAME);
+ }
+#endif
+ secureWipeKeys();
+ s_sessionMaxMs = 0;
+ s_sessionStartedMs = 0;
+ LOG_INFO("EncryptedStorage: lockdown artifacts removed — device is no longer in lockdown");
+}
+
+} // namespace EncryptedStorage
+
+#else
+#error "MESHTASTIC_ENCRYPTED_STORAGE requires ARCH_NRF52 (CC310 hardware crypto)."
+#endif // ARCH_NRF52
+
+#endif // MESHTASTIC_ENCRYPTED_STORAGE
diff --git a/src/security/EncryptedStorage.h b/src/security/EncryptedStorage.h
new file mode 100644
index 000000000..9a472611e
--- /dev/null
+++ b/src/security/EncryptedStorage.h
@@ -0,0 +1,287 @@
+#pragma once
+
+#ifdef MESHTASTIC_ENCRYPTED_STORAGE
+
+#include
+#include
+
+/**
+ * Encrypted storage layer for lockdown builds.
+ *
+ * Key hierarchy:
+ * FICR eFuse IDs + passphrase -> SHA-256 -> KEK (16 bytes, never stored)
+ * KEK wraps -> DEK (Data Encryption Key, 16 bytes, random, stored in /prefs/.dek)
+ * DEK encrypts -> proto files via AES-128-CTR + HMAC-SHA256(DEK)
+ * (the DEK file itself is HMAC'd with KEK; only proto files use HMAC(DEK))
+ *
+ * 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
+ *
+ * On-disk formats carry a 4-byte magic but no version byte: this layer has
+ * never shipped, so there are no older files to stay compatible with. The
+ * magic alone identifies each format; a corrupt or foreign file fails the
+ * magic check (and, for the keyed formats, the HMAC).
+ *
+ * Encrypted proto file format ("MENC"):
+ * [4B] Magic 0x4D454E43 ("MENC")
+ * [13B] Nonce (random per write)
+ * [4B] Original plaintext length (LE uint32)
+ * [NB] AES-128-CTR ciphertext
+ * [32B] HMAC-SHA256(DEK, magic || nonce || plaintext_len || ciphertext)
+ * Total overhead: 53 bytes per file.
+ *
+ * DEK file format ("MDEK"):
+ * [4B] Magic 0x4D44454B ("MDEK")
+ * [13B] Nonce (random per write)
+ * [16B] AES-128-CTR(KEK, nonce, DEK)
+ * [32B] HMAC-SHA256(KEK, "mdek-auth" || nonce || encrypted_DEK)
+ * Total: 65 bytes.
+ *
+ * Unlock token format ("UTOK"):
+ * [4B] Magic 0x55544F4B ("UTOK")
+ * [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)
+ * [4B] session_max_seconds (LE uint32, 0 = no session limit)
+ * [4B] monotonic_counter (LE uint32) — see /prefs/.tokmono
+ * [32B] HMAC-SHA256(ephemeralKEK, all above fields)
+ * Total: 78 bytes.
+ *
+ * Monotonic counter file (/prefs/.tokmono):
+ * [4B] highest counter ever issued (LE uint32)
+ * [32B] HMAC-SHA256(ephemeralKEK, "tokmono-auth" || counter)
+ * Total: 36 bytes.
+ * readAndConsumeToken rejects any token whose body counter is less
+ * than the persisted value, defeating a flash-write-only attacker who
+ * tries to restore an older (e.g. higher-boot-count) token.
+ *
+ * Backoff state file (/prefs/.backoff):
+ * [1B] attempts
+ * [1B] bootsSinceFail
+ * [4B] lastFailEpoch (LE uint32)
+ * [32B] HMAC-SHA256(ephemeralKEK, "backoff-auth" || body)
+ * Total: 38 bytes. Missing / short / MAC-fail are all treated as
+ * max-attempts so a tamper-delete can only increase the wait.
+ */
+
+namespace EncryptedStorage
+{
+
+// ---------------------------------------------------------------------------
+// File format constants
+// ---------------------------------------------------------------------------
+
+static constexpr uint32_t MAGIC = 0x4D454E43; // "MENC" — encrypted proto files
+static constexpr size_t NONCE_SIZE = 13;
+static constexpr size_t HMAC_SIZE = 32;
+static constexpr size_t HEADER_SIZE = 4 + NONCE_SIZE + 4; // magic+nonce+plaintext_len
+static constexpr size_t OVERHEAD = HEADER_SIZE + HMAC_SIZE; // 53 bytes
+static constexpr size_t AES_KEY_SIZE = 16;
+static constexpr size_t AES_BLOCK_SIZE = 16;
+
+static constexpr uint32_t DEK_MAGIC = 0x4D44454B; // "MDEK"
+static constexpr size_t DEK_SIZE = 4 + NONCE_SIZE + AES_KEY_SIZE + HMAC_SIZE; // 65 bytes
+
+static constexpr uint32_t TOKEN_MAGIC = 0x55544F4B; // "UTOK"
+// magic(4) + nonce(NONCE_SIZE=13) + encDek(AES_KEY_SIZE=16)
+// + bootsRemaining(1) + validUntilEpoch(4) + sessionMaxSeconds(4)
+// + monotonicCounter(4) = 46 bytes
+static constexpr size_t TOKEN_BODY_SIZE = 4 + NONCE_SIZE + AES_KEY_SIZE + 1 + 4 + 4 + 4;
+static constexpr size_t TOKEN_TOTAL_SIZE = TOKEN_BODY_SIZE + HMAC_SIZE; // 78 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.
+ *
+ * @param passphrase Raw passphrase bytes (need not be NUL-terminated)
+ * @param passphraseLen Length in bytes (1–32; 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)
+ * @param sessionMaxSeconds Per-boot uptime cap on the unlocked session (0 = no cap).
+ * Persists in the token; cold-boot via token inherits the same cap.
+ * @return true on success
+ */
+bool provisionPassphrase(const uint8_t *passphrase, size_t passphraseLen, uint8_t bootsRemaining = TOKEN_DEFAULT_BOOTS,
+ uint32_t validUntilEpoch = 0, uint32_t sessionMaxSeconds = 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 (1–32; 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)
+ * @param sessionMaxSeconds Per-boot uptime cap on the unlocked session (0 = no cap).
+ * Persists in the new token; reboot starts a fresh session window.
+ * @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, uint32_t sessionMaxSeconds = 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();
+
+/**
+ * Wipe in-RAM key material WITHOUT touching flash. Designed to be called
+ * from fault / watchdog handlers before any coredump or RAM-snapshot path
+ * runs, so the DEK / KEK / ephemeralKEK don't end up in crash reports.
+ *
+ * Safe to call from interrupt context: does not take any FreeRTOS locks
+ * and does not log. Equivalent to the RAM-wipe half of lockNow() with the
+ * token file left intact (so the device can still auto-unlock on the
+ * next normal boot via the token).
+ */
+void secureWipeKeys();
+
+/** 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 true when lockdown is active on this device (== isProvisioned()).
+ * The runtime gate for all access-control / redaction / locked-boot
+ * behavior. A lockdown-CAPABLE build that has not been provisioned (or has
+ * been disabled) returns false here and runs like stock firmware.
+ */
+bool isLockdownActive();
+
+/**
+ * Decrypt one encrypted file back to plaintext in place (the inverse of
+ * migrateFile). Idempotent: a file that is already plaintext returns true
+ * without touching it. Requires isUnlocked() (DEK in RAM). Used by the
+ * lockdown-disable flow; NodeDB drives the per-file iteration since it owns
+ * the proto filenames.
+ *
+ * @return true on success or if the file was already plaintext.
+ */
+bool migrateFileToPlaintext(const char *filename);
+
+/**
+ * Final step of disabling lockdown: remove the DEK, unlock token,
+ * monotonic-counter, and backoff files, then wipe the in-RAM keys.
+ * Call this ONLY after every encrypted file has been reverted to plaintext
+ * via migrateFileToPlaintext() — deleting the DEK first would make any
+ * remaining encrypted file permanently unreadable. After this returns,
+ * isProvisioned()/isLockdownActive() are false. APPROTECT is NOT touched
+ * (its lockout is permanent on silicon where it engaged).
+ */
+void removeLockdownArtifacts();
+
+/**
+ * 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 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();
+
+// ---------------------------------------------------------------------------
+// Uptime-based session limit
+// ---------------------------------------------------------------------------
+//
+// Independent of the wall-clock and boot-count TTLs on the token. Caps how
+// long a single auto-unlocked session can keep storage unlocked, measured
+// in firmware millis() since the unlock. Reboot resets the counter, so an
+// attacker who power-cycles to dodge the timer still burns a boot count.
+// Combined hard cap: bootsRemaining * sessionMaxSeconds total exposure.
+//
+// Uptime (not wall-clock) by design: an attacker pulling the RTC backup
+// battery and spoofing GPS to roll the clock back cannot defeat this —
+// we never read getValidTime() for session enforcement. The check only
+// engages when sessionMaxSeconds is non-zero, so 0 = unlimited (the
+// existing token-only behavior, suitable for tower/infra nodes).
+
+/// Start a session timer. Called after a successful passphrase unlock.
+/// maxSeconds = 0 disables the timer for this session.
+void setSession(uint32_t maxSeconds);
+
+/// True if a session timer is set and has elapsed. Idempotent — call
+/// from the main loop on a low-frequency tick.
+bool isSessionExpired();
+
+/// Seconds remaining in the current session. 0 if no timer is set, or if
+/// the timer has expired (use isSessionExpired() to distinguish).
+uint32_t getSessionRemainingSeconds();
+
+/// Consume one boot from the on-flash token (the rollback ledger) and
+/// re-arm the session timer in place — no reboot. Called from the main
+/// loop when a session expires AND there is still budget. Decrements
+/// bootsRemaining on flash (delete-and-rewrite of the token file, or
+/// outright deletion if the new count is 0). Returns the new boot
+/// count. Caller should check getBootsRemaining() == 0 before this
+/// call: when zero, the budget is exhausted and a hard lock + reboot
+/// should be issued instead.
+uint8_t consumeSessionBoot();
+
+// ---------------------------------------------------------------------------
+// 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);
+
+} // namespace EncryptedStorage
+
+#endif // MESHTASTIC_ENCRYPTED_STORAGE
diff --git a/src/security/LockdownDisplay.cpp b/src/security/LockdownDisplay.cpp
new file mode 100644
index 000000000..5855bd5d5
--- /dev/null
+++ b/src/security/LockdownDisplay.cpp
@@ -0,0 +1,59 @@
+#include "configuration.h"
+
+#ifdef MESHTASTIC_LOCKDOWN
+
+#include "LockdownDisplay.h"
+
+#ifdef MESHTASTIC_ENCRYPTED_STORAGE
+#include "security/EncryptedStorage.h"
+#endif
+
+#include
+
+namespace meshtastic_security
+{
+
+// Screen-lock latch. Set when the display powers off (idle timeout etc.),
+// cleared only when a client authenticates with the passphrase. Separate
+// from storage-lock state: the device keeps routing while this is set,
+// only the display is gated.
+//
+// Initialised to true so that even a token-auto-unlocked cold boot comes
+// up with a redacted screen. Otherwise an attacker holding a screen-locked
+// device could simply power-cycle it (RAM latch resets) to get back to a
+// content screen. Operator must authenticate from a client to reveal
+// content after any boot.
+//
+// std::atomic so cross-task reads (PowerFSM / Screen / InputBroker) see
+// writes immediately and the compiler is not free to speculate the load.
+// Plain bool happens to work on single-core Cortex-M4 today but breaks
+// silently the moment lockdown ports to ESP32 / RP2040 / LTO whole-program
+// elision.
+static std::atomic s_screenLocked{true};
+
+bool shouldRedactDisplay()
+{
+#ifdef MESHTASTIC_ENCRYPTED_STORAGE
+ // Lockdown not active (capable build, never provisioned or disabled):
+ // never redact the display — behave like stock firmware.
+ if (!EncryptedStorage::isLockdownActive())
+ return false;
+ if (!EncryptedStorage::isUnlocked())
+ return true;
+#endif
+ return s_screenLocked.load(std::memory_order_relaxed);
+}
+
+void lockScreen()
+{
+ s_screenLocked.store(true, std::memory_order_relaxed);
+}
+
+void unlockScreen()
+{
+ s_screenLocked.store(false, std::memory_order_relaxed);
+}
+
+} // namespace meshtastic_security
+
+#endif // MESHTASTIC_LOCKDOWN
diff --git a/src/security/LockdownDisplay.h b/src/security/LockdownDisplay.h
new file mode 100644
index 000000000..a5d964b65
--- /dev/null
+++ b/src/security/LockdownDisplay.h
@@ -0,0 +1,80 @@
+#pragma once
+
+#include
+
+namespace meshtastic_security
+{
+
+#ifdef MESHTASTIC_LOCKDOWN
+
+/**
+ * Display privacy policy for hardened lockdown builds.
+ *
+ * Renderers (Screen, InkHUD, niche graphics, device-ui) should consult
+ * shouldRedactDisplay() at their top-level draw entry point. When true,
+ * render a static "locked" view (e.g. just product name + battery), NOT
+ * the normal node list / messages / GPS / channel content.
+ *
+ * Redaction triggers on either of two conditions:
+ *
+ * 1. Encrypted storage is locked (no DEK in RAM). NodeDB holds only
+ * defaults, but the explicit gate also keeps cached/stale UI state
+ * from leaking. Only firmware built with MESHTASTIC_ENCRYPTED_STORAGE
+ * has a storage state to check; elsewhere this condition is false.
+ *
+ * 2. The screen-lock latch is set. This is a separate state from
+ * storage-locked: the device stays fully functional on the mesh,
+ * only the display is gated. The latch is set by lockScreen() when
+ * the stock idle timeout powers the screen off (hooked in
+ * Screen::setOn) — so it reuses config.display.screen_on_secs
+ * rather than running a second timer. It is cleared only by
+ * unlockScreen(), called from PhoneAPI's lockdown_auth handler when
+ * a client authenticates with the passphrase over any transport.
+ * Button/joystick input can wake the backlight but does NOT clear
+ * the latch — the woken screen shows the LOCKED frame, not content.
+ * This closes the "operator walked away from an unlocked device"
+ * leak without conflating it with the storage-lock security state.
+ *
+ * The latch starts TRUE at boot so a token-auto-unlocked cold boot
+ * comes up redacted — otherwise an attacker holding a screen-locked
+ * device could power-cycle it (RAM latch resets) to recover a
+ * content screen. After any boot, the operator must authenticate
+ * from a client to reveal content.
+ *
+ * CURRENT COVERAGE
+ * - graphics/Screen.cpp (OLED via OLEDDisplayUi): GATED, renders a centered
+ * "LOCKED" + battery when shouldRedactDisplay() is true.
+ *
+ * KNOWN GAPS — these renderers still leak content under lockdown
+ * - graphics/InkHUD/ (e-ink rich UI on supported boards)
+ * - graphics/niche/ (TFT niche graphics)
+ * - meshtastic/device-ui (T-Deck/TFT, separate submodule)
+ *
+ * Each of those does not flow through Screen::updateUiFrame() and therefore
+ * does not yet consult this policy. Operators using lockdown builds on
+ * InkHUD/niche/device-ui hardware should treat the screen as an
+ * always-on plaintext leak surface until those renderers are wired up.
+ * Wiring the other renderers is a follow-up effort once this lands.
+ */
+bool shouldRedactDisplay();
+
+/// Set the screen-lock latch. Called from Screen::setOn(false) when the
+/// display powers off (idle timeout, shutdown, deep sleep). Idempotent.
+void lockScreen();
+
+/// Clear the screen-lock latch. Called from PhoneAPI's lockdown_auth
+/// handler after a client authenticates with the passphrase.
+void unlockScreen();
+
+#else
+
+inline bool shouldRedactDisplay()
+{
+ return false;
+}
+inline void lockScreen() {}
+inline void unlockScreen() {}
+
+#endif // MESHTASTIC_LOCKDOWN
+
+} // namespace meshtastic_security
diff --git a/src/security/SecureZero.h b/src/security/SecureZero.h
new file mode 100644
index 000000000..ca19833f4
--- /dev/null
+++ b/src/security/SecureZero.h
@@ -0,0 +1,60 @@
+#pragma once
+
+#include
+#include
+#include
+#include
+
+namespace meshtastic_security
+{
+
+// Compiler-barrier wipe: a plain memset on a dying stack/heap buffer can be
+// elided as dead-store. The volatile function pointer forces emission.
+inline void secure_zero(void *p, std::size_t n)
+{
+ if (!p || n == 0)
+ return;
+ static void *(*volatile memset_v)(void *, int, std::size_t) = std::memset;
+ memset_v(p, 0, n);
+}
+
+// Fixed-size RAII buffer for key material; zeroed in destructor.
+template class ZeroizingBuffer
+{
+ public:
+ ZeroizingBuffer() { secure_zero(buf_, N); }
+ ~ZeroizingBuffer() { secure_zero(buf_, N); }
+
+ ZeroizingBuffer(const ZeroizingBuffer &) = delete;
+ ZeroizingBuffer &operator=(const ZeroizingBuffer &) = delete;
+
+ uint8_t *data() { return buf_; }
+ const uint8_t *data() const { return buf_; }
+ constexpr std::size_t size() const { return N; }
+ uint8_t &operator[](std::size_t i) { return buf_[i]; }
+ const uint8_t &operator[](std::size_t i) const { return buf_[i]; }
+
+ private:
+ uint8_t buf_[N];
+};
+
+// unique_ptr deleter that wipes the buffer before delete[].
+struct ZeroizingArrayDeleter {
+ std::size_t n;
+ void operator()(uint8_t *p) const noexcept
+ {
+ if (p) {
+ secure_zero(p, n);
+ delete[] p;
+ }
+ }
+};
+
+using ZeroizingArrayPtr = std::unique_ptr;
+
+inline ZeroizingArrayPtr make_zeroizing_array(std::size_t n)
+{
+ return ZeroizingArrayPtr(new uint8_t[n](), ZeroizingArrayDeleter{n});
+}
+
+} // namespace meshtastic_security
diff --git a/suppressions.txt b/suppressions.txt
index ab57c9298..f06734085 100644
--- a/suppressions.txt
+++ b/suppressions.txt
@@ -55,6 +55,11 @@ uninitMemberVar:*/AudioThread.h
constVariableReference:*/Channels.cpp
constParameterPointer:*/unishox2.c
+// False positive: make_zeroizing_array() returns unique_ptr, so
+// .get() is uint8_t*, not void*. cppcheck can't resolve the custom-deleter alias
+// and reports arithmetic on these buffers as void* pointer math.
+arithOperationsOnVoidPointer:*/EncryptedStorage.cpp
+
useStlAlgorithm
variableScope
\ No newline at end of file
diff --git a/tools/lockdown_provision.py b/tools/lockdown_provision.py
new file mode 100755
index 000000000..eb282c151
--- /dev/null
+++ b/tools/lockdown_provision.py
@@ -0,0 +1,668 @@
+#!/usr/bin/env python3
+r"""
+Lockdown passphrase provisioning / unlock / lock-now over USB serial.
+
+Speaks the AdminMessage.lockdown_auth / FromRadio.lockdown_status wire format
+introduced for MESHTASTIC_LOCKDOWN firmware builds. **This tool is the
+canonical reference implementation** — downstream clients (Meshtastic-Android,
+in-tree TCP/BLE tools) should mirror its packet shape.
+
+==============================================================================
+SECURITY MODEL — READ BEFORE EXTENDING
+==============================================================================
+
+ * USB-ONLY by design. The passphrase is sent **in cleartext over the USB
+ CDC link** between this script and the device's bootloader-managed
+ serial channel. The link is local; an attacker would need physical
+ access to the cable to read it.
+ * DO NOT extend to TCP or BLE transports without first redesigning the
+ handshake — both broadcast the wire format over channels an attacker
+ can passively sniff or actively MITM.
+ * Passphrases entered at a shell prompt land in your shell history. Use
+ --passphrase-file (mode 0600) or the interactive prompt for anything
+ you care about keeping. --passphrase on the command line requires
+ --insecure-passphrase-on-cmdline as an explicit acknowledgement.
+ * Passphrase cannot be recovered. There is no firmware-side reset that
+ leaves stored data intact; losing the passphrase means factory-erasing
+ the device's flash partition.
+
+==============================================================================
+REQUIREMENTS
+==============================================================================
+
+A meshtastic Python package built against protobufs that include
+LockdownAuth (admin.proto tag 104) and LockdownStatus (mesh.proto tag 18).
+If your installed package is older than that, regenerate the Python proto
+bindings from this repo's protobufs/ submodule and either overlay them into
+your site-packages or add them to PYTHONPATH before this script's imports.
+
+==============================================================================
+USAGE
+==============================================================================
+
+ # Interactive provision (prompts twice for passphrase, confirms intent):
+ tools/lockdown_provision.py --port /dev/cu.usbmodem* provision
+
+ # Provision with a passphrase from a 0600-mode file:
+ tools/lockdown_provision.py --port /dev/cu.usbmodem* \\
+ provision --passphrase-file ~/.lockdown-passphrase
+
+ # Re-authenticate this connection on an already-provisioned device:
+ tools/lockdown_provision.py --port /dev/cu.usbmodem* unlock
+
+ # Lock the device immediately (forces reboot into locked state):
+ tools/lockdown_provision.py --port /dev/cu.usbmodem* lock-now --yes
+
+ # Turn lockdown OFF (runtime toggle; reverts storage to plaintext, reboots):
+ tools/lockdown_provision.py --port /dev/cu.usbmodem* disable
+
+ # Just listen for LockdownStatus notifications:
+ tools/lockdown_provision.py --port /dev/cu.usbmodem* watch --seconds 30
+"""
+
+from __future__ import annotations
+
+import argparse
+import getpass
+import os
+import stat
+import sys
+import threading
+import time
+
+try:
+ import meshtastic
+ import meshtastic.mesh_interface
+ import meshtastic.serial_interface
+ from meshtastic.protobuf import admin_pb2, mesh_pb2, portnums_pb2
+except ImportError:
+ sys.stderr.write(
+ "error: meshtastic Python package not installed\n"
+ " pip install meshtastic # or: pipx install meshtastic\n"
+ )
+ sys.exit(2)
+
+# Sanity-check the schema is new enough.
+_missing = []
+if not hasattr(admin_pb2, "LockdownAuth"):
+ _missing.append("admin_pb2.LockdownAuth")
+if not hasattr(mesh_pb2, "LockdownStatus"):
+ _missing.append("mesh_pb2.LockdownStatus")
+if _missing:
+ sys.stderr.write(
+ "error: your meshtastic Python package is too old for the lockdown\n"
+ f" wire format. Missing: {', '.join(_missing)}\n"
+ " Update to a meshtastic release built against protobufs that\n"
+ " contain AdminMessage.lockdown_auth (tag 104) and\n"
+ " FromRadio.lockdown_status (tag 18). See the firmware repo's\n"
+ " protobufs/ submodule for the proto definitions.\n"
+ )
+ sys.exit(2)
+
+
+# Mirrors meshtastic_LockdownStatus_State in mesh.pb.h.
+_STATE_NAMES = {
+ mesh_pb2.LockdownStatus.STATE_UNSPECIFIED: "UNSPECIFIED",
+ mesh_pb2.LockdownStatus.NEEDS_PROVISION: "NEEDS_PROVISION",
+ mesh_pb2.LockdownStatus.LOCKED: "LOCKED",
+ mesh_pb2.LockdownStatus.UNLOCKED: "UNLOCKED",
+ mesh_pb2.LockdownStatus.UNLOCK_FAILED: "UNLOCK_FAILED",
+}
+# DISABLED arrived with the runtime-toggle schema. Guard so older bindings that
+# only know the original five states still import cleanly; without this a
+# capable-but-off boot would print an opaque "state=" instead of DISABLED.
+if hasattr(mesh_pb2.LockdownStatus, "DISABLED"):
+ _STATE_NAMES[mesh_pb2.LockdownStatus.DISABLED] = "DISABLED"
+
+
+# Internal coordination between the FromRadio listener thread and the
+# main thread so we can block until the device replies (M29) instead of
+# sleep()ing a fixed window and hoping.
+class StatusFuture:
+ """Single-shot future for the next LockdownStatus that arrives after arm()."""
+
+ def __init__(self):
+ self._event = threading.Event()
+ self._status: mesh_pb2.LockdownStatus | None = None
+
+ def deliver(self, status: mesh_pb2.LockdownStatus) -> None:
+ if not self._event.is_set():
+ self._status = status
+ self._event.set()
+
+ def wait(self, timeout: float) -> mesh_pb2.LockdownStatus | None:
+ return self._status if self._event.wait(timeout) else None
+
+
+_STATUS_FUTURE: StatusFuture | None = None
+
+
+# ---------------------------------------------------------------------------
+# Transport guard (M30)
+# ---------------------------------------------------------------------------
+
+
+_NON_LOCAL_PREFIXES = (
+ "tcp:",
+ "tcp://",
+ "ble:",
+ "ble://",
+ "udp:",
+ "udp://",
+ "ws:",
+ "wss:",
+)
+
+
+def reject_non_usb_port(port: str | None) -> None:
+ """Refuse anything that looks like a remote transport.
+
+ The wire format sends the passphrase in cleartext. That's tolerable
+ over USB CDC (physical-attacker model) and explicitly NOT tolerable
+ over TCP/BLE/UDP. Reject any --port that names one of those schemes
+ so a copy-paste of an example into a different shell can't silently
+ leak credentials.
+ """
+ if not port:
+ return
+ lowered = port.lower()
+ for prefix in _NON_LOCAL_PREFIXES:
+ if lowered.startswith(prefix):
+ sys.stderr.write(
+ f"error: refusing --port {port!r}: this tool is USB-only by\n"
+ " design (passphrase is cleartext on the wire). See the\n"
+ " SECURITY MODEL block at the top of this file.\n"
+ )
+ sys.exit(2)
+
+
+# ---------------------------------------------------------------------------
+# Passphrase input (M26)
+# ---------------------------------------------------------------------------
+
+
+def read_passphrase_from_file(path: str) -> bytes:
+ """Read a passphrase from a 0600-mode file.
+
+ Refuse to read if the file is world- or group-readable to avoid
+ silently using a passphrase that another user could lift off the
+ filesystem.
+ """
+ try:
+ st = os.stat(path)
+ except OSError as exc:
+ sys.exit(f"error: cannot stat {path}: {exc}")
+ mode = stat.S_IMODE(st.st_mode)
+ if mode & 0o077:
+ sys.exit(
+ f"error: {path} mode is {oct(mode)} — must be 0600 (operator-only).\n"
+ f" run: chmod 600 {path}"
+ )
+ try:
+ with open(path, "rb") as f:
+ raw = f.read()
+ except OSError as exc:
+ sys.exit(f"error: cannot read {path}: {exc}")
+ # Strip a single trailing newline (common when authored with `echo`).
+ if raw.endswith(b"\r\n"):
+ raw = raw[:-2]
+ elif raw.endswith(b"\n"):
+ raw = raw[:-1]
+ return raw
+
+
+def prompt_passphrase(confirm: bool) -> bytes:
+ """Interactive prompt. confirm=True double-enters and matches."""
+ pp = getpass.getpass("passphrase: ").encode("utf-8")
+ if confirm:
+ pp2 = getpass.getpass("passphrase (confirm): ").encode("utf-8")
+ if pp != pp2:
+ sys.exit("error: passphrases do not match")
+ return pp
+
+
+def gather_passphrase(args, *, confirm: bool) -> bytes:
+ """Resolve the passphrase from --passphrase / --passphrase-file / prompt.
+
+ Order of precedence: argv (with --insecure-passphrase-on-cmdline) >
+ --passphrase-file > interactive prompt.
+ """
+ if args.passphrase is not None:
+ if not args.insecure_passphrase_on_cmdline:
+ sys.exit(
+ "error: --passphrase on argv requires "
+ "--insecure-passphrase-on-cmdline.\n"
+ " Reason: argv lands in shell history and is visible via\n"
+ " `ps`. Prefer --passphrase-file or the interactive prompt."
+ )
+ sys.stderr.write(
+ "warning: passphrase passed on argv — visible to other users via\n"
+ " ps(1), and persisted in your shell history file.\n"
+ )
+ pp = args.passphrase.encode("utf-8")
+ elif args.passphrase_file is not None:
+ pp = read_passphrase_from_file(args.passphrase_file)
+ else:
+ pp = prompt_passphrase(confirm)
+
+ if not 1 <= len(pp) <= 32:
+ sys.exit(f"error: passphrase must be 1..32 bytes utf-8, got {len(pp)}")
+ return pp
+
+
+# ---------------------------------------------------------------------------
+# FromRadio notification interception (L7)
+# ---------------------------------------------------------------------------
+
+
+def install_notification_printer(iface) -> None:
+ """Wrap _handleFromRadio to print LockdownStatus frames and feed the future.
+
+ meshtastic-python (as of the version this script was last tested
+ against) does not dispatch LockdownStatus on a public pubsub topic.
+ We hook the private _handleFromRadio entry point, which is the
+ fragility flagged in the audit's L7 finding. If a future lib release
+ breaks this, the missing-attr error will be obvious; until then this
+ is the only seam available.
+ """
+ original = getattr(iface, "_handleFromRadio", None)
+ if original is None:
+ sys.exit(
+ "error: meshtastic.serial_interface.SerialInterface has no\n"
+ " _handleFromRadio method. The lib's private API changed —\n"
+ " this tool needs to be updated. See L7 in the audit notes."
+ )
+
+ def wrapped(fromRadioBytes):
+ try:
+ fr = mesh_pb2.FromRadio()
+ fr.ParseFromString(fromRadioBytes)
+ if fr.HasField("lockdown_status"):
+ ls = fr.lockdown_status
+ state = _STATE_NAMES.get(ls.state, f"state={ls.state}")
+ parts = [state]
+ if ls.lock_reason:
+ parts.append(f"reason={ls.lock_reason}")
+ if ls.boots_remaining:
+ parts.append(f"boots={ls.boots_remaining}")
+ if ls.valid_until_epoch:
+ parts.append(f"until={ls.valid_until_epoch}")
+ if ls.backoff_seconds:
+ parts.append(f"backoff={ls.backoff_seconds}s")
+ print(f"[device:LOCKDOWN] {' '.join(parts)}", flush=True)
+ if _STATUS_FUTURE is not None:
+ _STATUS_FUTURE.deliver(ls)
+ except Exception as exc: # noqa: BLE001 — best-effort logging only
+ print(f"[notif-parse-error] {exc}", flush=True)
+ return original(fromRadioBytes)
+
+ iface._handleFromRadio = wrapped
+
+
+# ---------------------------------------------------------------------------
+# LockdownAuth construction + send
+# ---------------------------------------------------------------------------
+
+
+def build_lockdown_auth(
+ passphrase: bytes,
+ boots: int,
+ hours: int,
+ max_session_seconds: int,
+ lock_now: bool,
+ disable: bool = False,
+):
+ la = admin_pb2.LockdownAuth()
+ if passphrase:
+ la.passphrase = passphrase
+ la.boots_remaining = max(0, min(255, boots))
+ la.valid_until_epoch = int(time.time()) + hours * 3600 if hours > 0 else 0
+ la.max_session_seconds = max(0, max_session_seconds)
+ la.lock_now = lock_now
+ if disable:
+ # disable lives on the runtime-toggle schema. Fail loudly on older
+ # bindings rather than silently sending an unlock the firmware would
+ # honour as a normal auth.
+ if not hasattr(la, "disable"):
+ sys.exit(
+ "error: your meshtastic Python package is too old for the\n"
+ " runtime-toggle disable flow (LockdownAuth.disable\n"
+ " missing). Update the protobuf bindings."
+ )
+ la.disable = True
+ return la
+
+
+def send_lockdown_auth(iface, la, label: str) -> int:
+ """Send AdminMessage.lockdown_auth to this node. Returns mp.id on success."""
+ if iface.myInfo is None:
+ sys.exit(
+ "error: device never sent my_info; cannot determine destination nodenum"
+ )
+ my_node_num = iface.myInfo.my_node_num
+
+ am = admin_pb2.AdminMessage()
+ am.lockdown_auth.CopyFrom(la)
+
+ # _generatePacketId is private but stable across recent lib versions.
+ generate_id = getattr(iface, "_generatePacketId", None)
+ if generate_id is None:
+ sys.exit("error: meshtastic lib missing _generatePacketId — see L7 note")
+ mp = mesh_pb2.MeshPacket()
+ mp.to = my_node_num
+ mp.id = generate_id()
+ mp.channel = 0
+ mp.want_ack = True
+ mp.hop_limit = 7
+ mp.hop_start = 7
+ mp.priority = mesh_pb2.MeshPacket.Priority.RELIABLE
+ mp.decoded.portnum = portnums_pb2.PortNum.ADMIN_APP
+ mp.decoded.payload = am.SerializeToString()
+ # NOTE: pki_encrypted intentionally left False — see top-of-file note in
+ # the original tool. Lockdown firmware drops PKI-encrypted ToRadio.
+
+ tr = mesh_pb2.ToRadio()
+ tr.packet.CopyFrom(mp)
+
+ send_to_radio = getattr(iface, "_sendToRadio", None)
+ if send_to_radio is None:
+ sys.exit("error: meshtastic lib missing _sendToRadio — see L7 note")
+ print(
+ f"[client] sending {label} (to=0x{my_node_num:08x}, id={mp.id}) ...", flush=True
+ )
+ send_to_radio(tr)
+ return mp.id
+
+
+# ---------------------------------------------------------------------------
+# Commands
+# ---------------------------------------------------------------------------
+
+
+def _await_status(timeout: float) -> mesh_pb2.LockdownStatus | None:
+ if _STATUS_FUTURE is None:
+ time.sleep(timeout)
+ return None
+ print(f"[client] waiting up to {timeout}s for LockdownStatus ...", flush=True)
+ return _STATUS_FUTURE.wait(timeout)
+
+
+def cmd_provision(iface, args) -> int:
+ # M27: this is the destructive setup step. Warn explicitly and require
+ # a typed confirmation unless --yes was supplied.
+ if not args.yes:
+ sys.stderr.write(
+ "WARNING: first-time provision binds this device to a passphrase\n"
+ " that cannot be recovered. If you lose it, the only way\n"
+ " back is a factory-erase that wipes ALL stored state\n"
+ " (channels, contacts, messages, position, etc.).\n"
+ )
+ ans = input("Type 'yes' to continue: ").strip().lower()
+ if ans != "yes":
+ sys.exit("aborted")
+
+ pp = gather_passphrase(args, confirm=True)
+ la = build_lockdown_auth(
+ pp,
+ args.boots,
+ args.hours,
+ args.max_session_seconds,
+ lock_now=False,
+ )
+ global _STATUS_FUTURE
+ _STATUS_FUTURE = StatusFuture()
+ send_lockdown_auth(iface, la, "provision/unlock")
+ status = _await_status(args.wait)
+ if status is None:
+ sys.stderr.write("warning: no LockdownStatus received within wait window\n")
+ return 1
+ return _exit_code_for_status(status)
+
+
+def cmd_unlock(iface, args) -> int:
+ pp = gather_passphrase(args, confirm=False)
+ la = build_lockdown_auth(
+ pp,
+ args.boots,
+ args.hours,
+ args.max_session_seconds,
+ lock_now=False,
+ )
+ global _STATUS_FUTURE
+ _STATUS_FUTURE = StatusFuture()
+ send_lockdown_auth(iface, la, "unlock")
+ status = _await_status(args.wait)
+ if status is None:
+ sys.stderr.write("warning: no LockdownStatus received within wait window\n")
+ return 1
+ return _exit_code_for_status(status)
+
+
+def cmd_lock(iface, args) -> int:
+ if not args.yes:
+ sys.stderr.write(
+ "WARNING: 'lock' will revoke all current auth and reboot the\n"
+ " device into the locked state. The next connect will\n"
+ " require the passphrase.\n"
+ )
+ ans = input("Type 'yes' to continue: ").strip().lower()
+ if ans != "yes":
+ sys.exit("aborted")
+ la = build_lockdown_auth(b"", 0, 0, 0, lock_now=True)
+ global _STATUS_FUTURE
+ _STATUS_FUTURE = StatusFuture()
+ send_lockdown_auth(iface, la, "LOCK NOW")
+ # Device may not get an UNLOCKED/LOCKED back to us before it reboots;
+ # accept the lack of a status as "probably worked" for this command.
+ status = _await_status(args.wait)
+ if status is None:
+ print("[client] no status received (device may already be rebooting)")
+ return 0
+ return _exit_code_for_status(status)
+
+
+def cmd_disable(iface, args) -> int:
+ # Runtime-toggle OFF. Unlike 'lock' (which reboots back into the locked
+ # state), 'disable' turns lockdown off entirely: the firmware re-verifies
+ # the passphrase to load the DEK, reverts at-rest encryption to plaintext,
+ # then reboots into normal mode. A non-empty passphrase is REQUIRED — the
+ # firmware rejects an empty one with UNLOCK_FAILED.
+ if not args.yes:
+ sys.stderr.write(
+ "WARNING: 'disable' turns lockdown OFF on this device. Stored files\n"
+ " are reverted to plaintext, per-connection admin auth is no\n"
+ " longer enforced, and the device reboots into normal mode.\n"
+ " (APPROTECT is NOT reversed.)\n"
+ )
+ ans = input("Type 'yes' to continue: ").strip().lower()
+ if ans != "yes":
+ sys.exit("aborted")
+ pp = gather_passphrase(args, confirm=False)
+ # TTL/session fields are ignored by the firmware on a disable request.
+ la = build_lockdown_auth(pp, 0, 0, 0, lock_now=False, disable=True)
+ global _STATUS_FUTURE
+ _STATUS_FUTURE = StatusFuture()
+ send_lockdown_auth(iface, la, "disable")
+ # On success the firmware decrypts every stored file before broadcasting
+ # DISABLED, so a large node DB can take longer than the default wait — bump
+ # --wait if you see no status. The DISABLED broadcast precedes the reboot.
+ status = _await_status(args.wait)
+ if status is None:
+ sys.stderr.write("warning: no LockdownStatus received within wait window\n")
+ return 1
+ return _exit_code_for_status(status)
+
+
+def cmd_watch(_iface, args) -> int:
+ print(
+ f"[client] watching for LockdownStatus notifications for {args.seconds}s — Ctrl-C to exit early",
+ flush=True,
+ )
+ try:
+ time.sleep(args.seconds)
+ except KeyboardInterrupt:
+ print("[client] interrupted")
+ return 0
+
+
+def _exit_code_for_status(status: mesh_pb2.LockdownStatus) -> int:
+ """Map the final LockdownStatus to a shell exit code (M29)."""
+ if status.state == mesh_pb2.LockdownStatus.UNLOCKED:
+ return 0
+ # DISABLED is a terminal success: a runtime-toggle 'disable' completed, or a
+ # capable-but-off device reported its state. Guarded for older bindings.
+ if (
+ hasattr(mesh_pb2.LockdownStatus, "DISABLED")
+ and status.state == mesh_pb2.LockdownStatus.DISABLED
+ ):
+ return 0
+ if status.state == mesh_pb2.LockdownStatus.UNLOCK_FAILED:
+ sys.stderr.write(
+ "error: UNLOCK_FAILED"
+ + (
+ f" — try again in {status.backoff_seconds}s"
+ if status.backoff_seconds
+ else ""
+ )
+ + "\n"
+ )
+ return 4
+ if status.state == mesh_pb2.LockdownStatus.LOCKED:
+ # Common: the firmware emitted LOCKED before our auth could process,
+ # or this is the LOCKED-with-needs_auth that follows a successful
+ # provision-then-disconnect cycle. Treat as ambiguous.
+ return 3
+ if status.state == mesh_pb2.LockdownStatus.NEEDS_PROVISION:
+ return 2
+ return 1
+
+
+# ---------------------------------------------------------------------------
+# Argparse + entrypoint
+# ---------------------------------------------------------------------------
+
+
+def _add_passphrase_args(parser: argparse.ArgumentParser) -> None:
+ parser.add_argument(
+ "--passphrase",
+ help="passphrase on cmdline (requires --insecure-passphrase-on-cmdline)",
+ )
+ parser.add_argument(
+ "--passphrase-file", help="path to a 0600-mode file containing the passphrase"
+ )
+ parser.add_argument(
+ "--insecure-passphrase-on-cmdline",
+ action="store_true",
+ help="acknowledge that --passphrase will be visible via ps and shell history",
+ )
+
+
+def _add_ttl_args(parser: argparse.ArgumentParser) -> None:
+ parser.add_argument(
+ "--boots",
+ type=int,
+ default=0,
+ help="boot-count token TTL (0 = firmware default 50, max 255)",
+ )
+ parser.add_argument(
+ "--hours",
+ type=int,
+ default=0,
+ help="wall-clock token TTL in hours (0 = no time limit)",
+ )
+ parser.add_argument(
+ "--max-session-seconds",
+ type=int,
+ default=0,
+ help="per-boot uptime cap on the unlocked session (0 = unlimited)",
+ )
+
+
+def main() -> int:
+ ap = argparse.ArgumentParser(
+ description=__doc__.split("\n\n")[0],
+ formatter_class=argparse.RawDescriptionHelpFormatter,
+ epilog=__doc__,
+ )
+ ap.add_argument(
+ "--port",
+ help="USB serial device path, e.g. /dev/cu.usbmodem* — TCP/BLE/UDP rejected",
+ )
+ ap.add_argument(
+ "--wait",
+ type=float,
+ default=8.0,
+ help="seconds to wait for response (default: 8)",
+ )
+ ap.add_argument(
+ "--yes", "-y", action="store_true", help="skip interactive confirmation prompts"
+ )
+ sub = ap.add_subparsers(dest="cmd", required=True)
+
+ p_prov = sub.add_parser("provision", help="first-time set passphrase (binds DEK)")
+ _add_passphrase_args(p_prov)
+ _add_ttl_args(p_prov)
+ p_prov.set_defaults(func=cmd_provision)
+
+ p_unlock = sub.add_parser(
+ "unlock", help="re-authenticate this connection with the existing passphrase"
+ )
+ _add_passphrase_args(p_unlock)
+ _add_ttl_args(p_unlock)
+ p_unlock.set_defaults(func=cmd_unlock)
+
+ p_lock = sub.add_parser(
+ "lock", aliases=["lock-now"], help="send LOCK NOW; device reboots locked"
+ )
+ p_lock.set_defaults(func=cmd_lock)
+
+ p_disable = sub.add_parser(
+ "disable",
+ help="turn lockdown OFF (runtime toggle); requires passphrase, reverts to plaintext",
+ )
+ _add_passphrase_args(p_disable)
+ p_disable.set_defaults(func=cmd_disable)
+
+ p_watch = sub.add_parser(
+ "watch", help="just listen for LockdownStatus notifications"
+ )
+ p_watch.add_argument(
+ "--seconds", type=float, default=60.0, help="how long to watch (default: 60)"
+ )
+ p_watch.set_defaults(func=cmd_watch)
+
+ args = ap.parse_args()
+ reject_non_usb_port(args.port)
+
+ sys.stderr.write(
+ "lockdown_provision: USB-only, passphrase travels cleartext on the cable.\n"
+ " See SECURITY MODEL block at top of this file.\n"
+ )
+
+ print(f"[client] opening serial port (port={args.port or 'auto'}) ...", flush=True)
+ iface = meshtastic.serial_interface.SerialInterface(
+ devPath=args.port,
+ noNodes=True,
+ connectNow=False,
+ )
+ install_notification_printer(iface)
+ try:
+ iface.connect()
+ print("[client] config handshake complete", flush=True)
+ except meshtastic.mesh_interface.MeshInterface.MeshInterfaceError as exc:
+ # Locked device may never send config_complete_id; we can still send
+ # lockdown_auth because the firmware reads ToRadio independent of the
+ # client's config-download state.
+ print(f"[client] handshake timed out ({exc}); proceeding anyway", flush=True)
+
+ rc = 1
+ try:
+ rc = args.func(iface, args)
+ finally:
+ print("[client] closing", flush=True)
+ iface.close()
+ return rc
+
+
+if __name__ == "__main__":
+ sys.exit(main())