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())