From 07a87a8254a8d73f9103c2a8b056517f3bc371f0 Mon Sep 17 00:00:00 2001 From: Nick <79813408+niccellular@users.noreply.github.com> Date: Thu, 11 Jun 2026 16:39:49 -0400 Subject: [PATCH] security: runtime-toggleable MESHTASTIC_LOCKDOWN hardening for nRF52 (#10349) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * security: add MESHTASTIC_LOCKDOWN hardened build option Meshtastic nodes ship with secrets on flash (channel PSKs, the device private key, admin keys, wifi PSK) and over-the-wire access to admin APIs that can re-key the mesh. Lose the device, at a border crossing, in a raid, off a backpack, and an attacker reads everything in 30s with a USB cable. There's no at-rest encryption, no client auth, the screen leaks contents, and SWD is wide open. This adds an opt-in hardened build for users who care. -DMESHTASTIC_LOCKDOWN=1 on nRF52 (CC310) turns on: DEBUG_MUTE silence USB/serial logs MESHTASTIC_ENCRYPTED_STORAGE AES-128-CTR + HMAC-SHA256 on LocalConfig / channels / NodeDB. Passphrase-gated DEK, TTL/boot unlock token, failed-attempt backoff (within-boot, wall-clock, persisted bootsSinceFail). MESHTASTIC_PHONEAPI_ACCESS_CONTROL per-connection auth gate. Secrets emitted as empty proto structs to unauthenticated clients. MESHTASTIC_ENABLE_APPROTECT one-way UICR APPROTECT, reset applied same boot. Recoverable only via \`nrfjprog --recover\`, which also wipes the DEK. LockdownDisplay screen shows "LOCKED" when locked or idle 30s. OLED only; InkHUD / niche / device-ui not yet wired. Wire format is the LockdownAuth / LockdownStatus pair from meshtastic/protobufs#911 (AdminMessage tag 104, FromRadio tag 18). Access-control state is a file-scope 6-slot table in PhoneAPI.cpp keyed by \`this\`, not class members. Adding *any* per-instance field to PhoneAPI breaks USB-CDC enumeration on the current nRF52 Adafruit framework, one volatile bool was enough. Out-of-line side-steps it. lockdown_auth is handled synchronously in PhoneAPI::handleToRadioPacket rather than routed through the mesh Router into AdminModule. Two reasons: the passphrase never travels through a routed MeshPacket queue, and per-connection authorization runs while \`this\` is still on the call stack. The previous async-via-router design lost connection identity (g_currentContext was null by the time AdminModule processed the auth), so per-connection unlock never actually took effect on the originating client. Non-nRF52: #warning, only DEBUG_MUTE activates. tools/lockdown_provision.py drives provision / unlock / lock-now / watch over USB. Display privacy is a screen-lock latch separate from storage-lock state: shouldRedactDisplay() is true when storage is locked OR the latch is set. Screen::setOn(false) sets the latch when the stock idle timeout powers the display off (reusing config.display.screen_on_secs, no second timer); it is cleared only when a client authenticates with the passphrase. A device idling on the mesh keeps routing but hides its screen until re-auth; button input wakes the backlight to the LOCKED frame, not content. The earlier lockdown-specific 30s idle timer is removed — it duplicated PowerFSM idle detection and showed a misleading LOCKED screen on a merely-idle device. Unlock-token TTL fix: a token carrying both a boot-count and a wall-clock TTL is no longer destroyed when the RTC is invalid at cold boot. The boot count is independently verifiable without a clock, so the token falls back to boot-count enforcement instead of being deleted. A token is only hard-rejected when its wall-clock TTL can be evaluated and is found expired. NodeDB::reloadFromDisk() after unlock is deferred to the main loop via lockdownReloadPending rather than run inline on the transport callback stack — the reload is too heavy for the BLE/serial task stack and was resetting the device immediately after a successful unlock. The screen-lock latch also swallows local input events in InputBroker::handleInputEvent while it (or storage-locked) is set. Without that, a blind operator could drive on-device menus, fire canned messages, or change settings through the joystick/buttons even though the screen content was hidden. PowerFSM is still triggered first so the backlight wakes to the LOCKED frame; the event is dropped before reaching the UI observers. The screen-lock latch is initialised to true at boot, so even a token-auto-unlocked cold boot comes up redacted. Otherwise an attacker holding a screen-locked device could power-cycle it (the RAM latch resets) and recover a content screen. After any boot, the operator must authenticate from a client to reveal screen content. MyNodeInfo.device_id is also redacted for unauthenticated clients — it is a stable hardware identifier useful to an attacker for fingerprinting / correlating the device across observations. The public mesh fields (my_node_num, owner short/long name, public key, hw model) are left as-is because they are already broadcast on-mesh. ModuleConfig.mqtt is also redacted for unauthenticated clients — MQTTConfig carries broker username, password, server address, and root_topic. The empty MQTTConfig is emitted via the same zero-init pattern as the other gated sections. Uptime-based session limit (MESHTASTIC_LOCKDOWN_SESSION_DEFAULT_SECONDS) caps how long a single auto-unlocked session can hold storage open, measured in firmware millis() since unlock. 0 = unlimited (existing token-only behavior, suitable for tower/infra nodes); non-zero arms a timer on every passphrase unlock and on every token-auto-unlock that inherits the value, since the cap is persisted in the token (token format bumped to v2: adds sessionMaxSeconds, body 56→60 bytes). On expiry the device revokes per-connection auth, re-engages the screen-lock latch, and reboots WITHOUT deleting the token. Next boot auto-unlocks via the boot count (decrementing it) and arms a fresh session window. Hard exposure ceiling: bootsRemaining * sessionMaxSeconds. Explicit user Lock Now still deletes the token (passphrase required to recover); only session expiry preserves it. Why uptime, not wall-clock: getValidTime() is fed by GPS/RTC/client time pushes — all manipulable by an attacker with the device (GPS spoof to roll the clock back, pull the RTC backup cell, Faraday-cage the whole thing). millis() comes off the Cortex-M's internal cycle counter, sealed inside the chip; the only way to reset it is a reboot, which costs a boot from the on-flash token counter. APPROTECT remains the load-bearing defense against forging higher boot counts via SWD. A future LockdownAuth.max_session_seconds proto field will let the client set this per-token; until that lands the build-time MESHTASTIC_LOCKDOWN_SESSION_DEFAULT_SECONDS macro is the only source. Session expiry now decrements the on-flash boot count in place and re-arms the uptime timer WITHOUT rebooting, while budget remains. Mesh routing keeps running across session boundaries; the device only reboots when bootsRemaining reaches zero (rollback budget exhausted), at which point it hard-locks and forces passphrase re-entry. Each session boundary still: revokes per-connection admin auth so clients must re-authenticate to see content, re-engages the screen lock latch, and emits LockdownStatus{LOCKED, needs_auth, boots=N} so connected clients see the decremented count and know to re-auth. Storage stays unlocked (DEK in RAM) for continuity. The boot count's role as the rollback ledger is unchanged — it decrements monotonically once per session boundary, whether the session ends in a reboot or an in-place roll. Attacker who power- cycles to dodge the session timer still pays a boot via the existing readAndConsumeToken decrement-at-load path. APPROTECT remains the only defense against forging higher counts. Net effect for an unattended/tower node with bootsRemaining=50, sessionSeconds=3600: 50 hours of continuous mesh service, one reboot at the end, vs. the previous design's 50 reboots over the same period. Same exposure ceiling, far better uptime. LockdownAuth.max_session_seconds (proto tag 5) is now consumed: when non-zero the client value wins; 0 falls back to the firmware-side MESHTASTIC_LOCKDOWN_SESSION_DEFAULT_SECONDS, matching the boots_remaining sentinel convention. Protobufs submodule pin bumped to develop tip which contains meshtastic/protobufs#916 (merged). * security: drop dead is_managed allowlist for set_config(security).private_key The 'isLockdownSecurityCmd' allowlist in handleReceivedProtobuf dates from the pre-LockdownAuth design when the passphrase was smuggled through SecurityConfig.private_key. With lockdown_auth handled synchronously in PhoneAPI::handleToRadioPacket before any admin message reaches the Router, this allowlist now serves no legitimate purpose and lets an unauthenticated local client mutate security settings on a managed device by setting private_key.size>=1 — including potentially disabling is_managed itself. Remove the allowlist. Managed-mode local admin now requires a PhoneAPI connection that has already authenticated via lockdown_auth (or, on the pki_encrypted branch below, a valid PKC admin key). Resolves Copilot review feedback on src/modules/AdminModule.cpp:105. * security: protect lockdown-status drain slot from concurrent writers g_pendingLockdownStatus / g_hasPendingLockdownStatus are written from multiple call sites (PhoneAPI::handleLockdownAuthInline on the BLE/USB transport callback, AdminModule on the Router thread, main loop session expiry) and read in getFromRadio() on whichever transport is draining FromRadio. The struct read/write was unprotected, so a writer could corrupt the slot mid-encode. Same pattern as nodeInfoMutex — wrap both the queue path and the drain in a small lock. Drain re-checks the bool under the lock to handle the case where another reader grabbed the slot first. Resolves Copilot review feedback on src/mesh/PhoneAPI.cpp:1560. * security: derive readAndDecrypt size cap from caller buffer, not a hardcoded 64 KB The MAX_PROTO_FILE_SIZE = 65536 + OVERHEAD ceiling was an absolute constant chosen against a since-outdated assumption that 'meshtastic proto files are well under 64 KB'. On variants where MAX_NUM_NODES pushes the serialised NodeDatabase past 64 KB the legitimate file gets rejected at load and the device treats its own real config as corrupt. The caller already knows the maximum plaintext it expects (outBufSize). Cap the ciphertext at outBufSize + OVERHEAD instead — this is the tightest sound bound (anything larger could not possibly decode into the caller's buffer), still defends against OOM / integer overflow, and scales with the platform's actual NodeDB size rather than an arbitrary constant. Resolves Copilot review feedback on src/security/EncryptedStorage.cpp:1327. * docs: fix stale 'passphrase delivery via AdminModule' references in configuration.h The lockdown overview comment block was written when passphrase delivery ran through AdminModule's handleReceivedProtobuf. With the synchronous refactor that path now lives in PhoneAPI::handleLockdownAuthInline, called before the admin message reaches the Router. Update both the nRF52 feature list and the non-nRF52 degraded-mode rationale to point at the current code path. Resolves Copilot review feedback on src/configuration.h:578 (and :604). * docs: refresh unlock-token format doc to match v2 layout The header comment for the UTOK file still described v1 (version 0x01, no session_max_seconds, 71 bytes) even after the in-flight bump to TOKEN_VERSION=0x02 and TOKEN_TOTAL_SIZE=75. The inline body-size breakdown comment was also wrong (claimed 39 bytes and mismatched the real NONCE_SIZE/AES_KEY_SIZE constants). Rewrite both to match the actual on-flash layout and note how v1 tokens are handled on upgrade (rejected via the version byte; passphrase re-entry mints a v2). Resolves Copilot review feedback on src/security/EncryptedStorage.h:50. * docs: correct session-limit comment re: token-auto-unlock behavior The s_sessionMaxMs comment block claimed 'token-auto-unlocked sessions have no session timer (the session feature is a passphrase-unlock-only knob)'. Stale: readAndConsumeToken() now persists sessionMaxSeconds in the token file and re-calls setSession() from the token-load path, so token-auto-unlocked sessions DO inherit the same cap (and consumeSessionBoot() re-arms in place between sessions on a single boot). Update the comment to match. Resolves Copilot review feedback on src/security/EncryptedStorage.cpp:72. * docs: clarify input-swallow gate re: screen-lock latch vs storage state The previous comment said input is swallowed 'until a client authenticates and unlockScreen() clears the latch (or storage is unlocked)'. The parenthetical was misleading: storage being unlocked is not in itself enough to clear the latch — the latch persists across the storage-unlocked-but-screen-locked steady state, and only an explicit unlockScreen() (called from a successful passphrase auth path) clears it. Reword so the only-passphrase-clears-the-latch invariant is explicit and local input is named as something that does NOT clear it. Resolves Copilot review feedback on src/input/InputBroker.cpp:134. * docs: fix reloadFromDisk() trigger comment in NodeDB.h The header still claimed reloadFromDisk() is called by AdminModule after a successful passphrase op. With the synchronous PhoneAPI refactor the actual trigger is PhoneAPI::handleLockdownAuthInline setting lockdownReloadPending, with main.cpp's loop() dispatching the heavy reload on the main thread (the transport callback stack isn't large enough). Update the comment to point at the real path and explain why the deferral exists. Resolves Copilot review feedback on src/mesh/NodeDB.h:393. * style: clang-format lockdown sources Apply trunk clang-format (16.0.3) to satisfy the format check. * style: black-format lockdown_provision.py Satisfy the trunk black formatter check. * security: drop unused v1 EncryptedStorage formats and migration This storage layer has never shipped, so there are no v1 DEK files, v1 unlock tokens, or v1 backoff records anywhere to stay compatible with. Remove the dead compatibility machinery: - legacy init() (FICR-only KEK, no passphrase) — had no callers - deriveKEKv1() / loadDEKv1() and the v1->v2 DEK migration paths in provisionPassphrase() and unlockWithPassphrase() - the 5-byte v1 backoff file format Also drop the now-pointless version byte from the on-disk MENC, MDEK, and UTOK formats. Each is identified by its 4-byte magic (and, for the keyed formats, its HMAC); with only one version that will ever exist, the version field added nothing. Sizes shrink by one byte each (overhead 54->53, DEK 66->65, token 75->74). Rename the surviving helpers to drop the _v2 suffix (deriveKEK, loadDEK, saveDEK, KEK_DOMAIN). No behavioral change for provisioning, unlock, token consumption, or session handling. Verified with an nRF52 lockdown build (rak4631). * fix(lockdown): harden auth-table and lockdown_auth handler (audit) Audit findings addressed: C3 — `~PhoneAPI()` now clears its auth slot unconditionally. The previous slot-clear in `close()` was gated on `state != STATE_SEND_NOTHING`, so a PhoneAPI that never reached config (or that already closed) left `slot.who` pointing at freed memory; a future PhoneAPI heap-allocated at the same address would inherit the prior session's authorization through `findOrAllocSlot`. C4 — All access to `g_authSlots`, `g_authEpoch`, and `g_currentContext` is now serialised through `g_authSlotsMutex`. Previously these were touched without locking from BLE/USB/TCP/Router tasks, so two parallel slot scans could hand out the same slot and mid-update reads could observe authorized=true alongside a stale epoch. Granularity is fine — every critical section is a short linear scan over six entries, and getFromRadio (which calls `getAdminAuthorized()` per redaction check) tolerates the brief blocking. A4 / H1 — `lock_now` now requires the originating connection to be already authorized. Previously any unauthenticated client (BLE/USB/TCP) could submit `lockdown_auth { lock_now=true }` and force a reboot, which was a trivial local-presence DoS — an attacker near the radio could brick-loop it indefinitely. The original "panic button without auth" property is dropped; panic now requires the operator to have passphrase-unlocked the connection. H2 — Empty-passphrase `lockdown_auth` (with `lock_now=false`) used to silently return success. The client received no feedback distinguishing that case from a real success, and an attacker could probe lockdown state for free. Now emits UNLOCK_FAILED with no backoff increment (empty-passphrase is more likely a client bug than an attack, but the honest signal still lets the client correct itself). H14 — `la.boots_remaining > 255` previously truncated silently (256 → 0 → mapped to TOKEN_DEFAULT_BOOTS=50; 257 → 1). Honest clients could not detect the misbehavior. Now rejected explicitly with UNLOCK_FAILED. L1 — The `to == nodeDB->getNodeNum()` allowance in the unauth ToRadio gate now also requires `getNodeNum() != 0`. During the locked-default boot path `getNodeNum()` returns 0, so a packet with `to=0` could otherwise satisfy the equality and bypass the gate. L2 — Comment added on `g_authEpoch` wrap. Practically unreachable (2^32 lockNow events on one boot), but worth recording the behavior. M17 — `findOrAllocSlot_LH` now evicts the first unauthorized stale slot when the table is full of non-nullptr entries, rather than failing closed. Authorized slots are never evicted — they represent live operator sessions. Fail-closed (with LOG_WARN) only when every slot holds a different live authorized PhoneAPI, which would require seven simultaneous authed connections. M18 — `s_screenLocked` is now `std::atomic` with relaxed ordering. Plain bool happened to work on single-core Cortex-M4 today but breaks silently if lockdown ports to ESP32 / RP2040, or under LTO whole- program elision. Verified with an nRF52 lockdown build (rak4631). * fix(lockdown): gate every admin op on per-connection auth + storage unlock Audit findings addressed: H6 — Unauthenticated local clients could previously set_config / set_module_config / set_channel etc. on a lockdown device whenever is_managed was unset. The previous gate inside AdminModule's is_managed branch consulted PhoneAPI::isLocalAdminAuthorized(), which reads a global g_currentContext set during synchronous PhoneAPI dispatch — but AdminModule runs on the Router task, by which time the dispatch task has exited and the global is unrelated to the originating connection. The check was both broken (always false on Router, so even authed clients were rejected) and unsafe (when it did fire, the wrong connection could be authorized). The fix relocates the gate to PhoneAPI::handleToRadioPacket, where dispatch is synchronous and getAdminAuthorized() can be trusted. The admin payload is already decoded there to extract lockdown_auth; extend the same branch so that any non-lockdown_auth admin variant from an unauthorized connection is dropped before ever reaching the Router queue. H7 — Same root cause: get_config_request / get_module_config_request / get_channel_request handlers returned full security/network/mqtt content to unauthorized local clients. With the H6 gate in PhoneAPI, these requests never reach AdminModule, so handleGetConfig / handleGetModuleConfig / handleGetChannel are only callable from authorized connections. H9 — Remote admin (PKC-authorized peers, mesh-relayed admin) bypassed lockdown entirely. If admin_keys were baked in via USERPREFS or set on a prior unlocked boot, a remote attacker could drive factory_reset / set_config against a locked device before the operator ever unlocked it. Added an EncryptedStorage::isUnlocked() early-return at the top of AdminModule::handleReceivedProtobuf. The local lockdown_auth path is unaffected because PhoneAPI handles it synchronously before AdminModule runs. H10 — Removed g_currentContext, the ContextGuard, authorizeLocalAdmin(), and isLocalAdminAuthorized() entirely. The audit's race (Router-thread reads a pointer set by an unrelated parallel dispatch and authorizes the wrong PhoneAPI) and the always-false-on-Router behavior both disappear with the code that produced them. The PKC-admin auto-authorize path is gone — PKC admin and the per-connection lockdown auth are now independent: clients using PKC admin from a local app must also send lockdown_auth to unlock the redacted FromRadio stream. Cleaned up AdminModule's is_managed branch: under lockdown the PhoneAPI-layer gate has already done its job, so no additional check is needed; without lockdown the legacy is_managed-blocks-plain-admin semantics are preserved. Verified with an nRF52 lockdown build (rak4631). * fix(lockdown): hold radio silent until storage is unlocked Audit finding H8: while locked, the device beaconed nodeinfo and telemetry on the public LongFast default PSK and routed incoming default- channel packets through the locked router. The locked-default boot path in NodeDB::loadFromDisk installs config via installDefaultConfig, which honours USERPREFS_CONFIG_LORA_REGION (the common shape for managed deployments) and synthesises the default LongFast channel. So a locked device on managed firmware came up TX-enabled on a well-known PSK before any operator interaction. Force config.lora.region = UNSET in the locked-boot block. RadioLibInterface gates both TX (startSend) and RX (readData) on region != UNSET — locked devices no longer initialise the SX12xx for either direction. Also set tx_enabled = false for any code path that checks the flag directly without consulting region. reloadFromDisk() restores the persisted lora config once the operator unlocks. Note: until the audit's M8 (radio re-init after reload, the upcoming commit 5 in this remediation series) lands, an unlocked device may need to reboot before its radio fully comes up under the real config; this is no worse than the pre-fix state, where the radio was already running on the wrong (default) config and any real config change required an explicit reconfigure or reboot anyway. Verified with an nRF52 lockdown build (rak4631). * fix(lockdown): per-connection status queue, redaction expansion, log/banner mute (audit) M14 — Replaces the single file-scope LockdownStatus slot with a per- PhoneAPI table keyed by PhoneAPI*, parallel to the auth-slot table and sharing g_authSlotsMutex. Previously a status produced for connection A (UNLOCKED with the active TTL, or UNLOCK_FAILED with a backoff) could be drained by connection B before A read it, leaking A's auth state to B. queueLockdownStatus is now a per-instance method writing to this->slot. A new static broadcastLockdownStatus exists for the main-loop session-expiry callers that have no PhoneAPI* in hand — those want every connected client to learn about the session roll, which is the only legitimate broadcast use case. hasPendingLockdownStatus is a const helper for the FromRadio available()/drain check. M13 — buildStatus_LH (the single point where lock_reason crosses into the on-wire LockdownStatus) collapses any token_* reason to a generic "locked" before emission. The specific reasons (token_hmac_fail, token_wrong_size, token_bad_magic, token_boots_zero, token_expired, token_dek_fail, token_missing) still go to local logs, but no longer tell an unauthenticated client that the firmware noticed their tampering / rollback / corrupt-file attempt. M15 — Extended the STATE_SEND_MY_INFO redaction (previously device_id only) to also wipe pio_env and min_app_version for unauth clients — both are pure build-fingerprint vectors that tell an attacker which known issues to probe. Kept my_node_num (broadcast on the mesh anyway) and nodedb_count (clients need it post-unlock to decide whether to pull the node DB). Added equivalent redaction for STATE_SEND_METADATA: the whole DeviceMetadata struct is wiped for unauth clients (firmware_version, device_state_version, hw_model, hw_model_string, has_bluetooth/has_wifi/has_ethernet, role, position_flags, excluded_modules). Clients re-fetch after authenticating. M16 — LoRa config is now whitelisted for unauth clients to the set that is intrinsically observable on the air anyway: region, modem_preset, use_preset, channel_num, hop_limit. Operator-private knobs (ignore_incoming, override_duty_cycle, override_frequency, sx126x_rx_boosted_gain, tx_power, ignore_mqtt, fem_lna_mode, config_ok_to_mqtt) are zeroed. The whitelist is built as a fresh LoRaConfig stack copy rather than masked in place to avoid touching the persisted struct. M12 — Skip the DEBUG_MUTE "we are muted, FYI" banner under MESHTASTIC_LOCKDOWN. The banner spilled APP_VERSION / APP_ENV / APP_REPO over USB CDC even with all other logging suppressed, which defeats the muting in lockdown builds and gives a USB-attached attacker a free firmware-fingerprint primitive. L9 — Removed the numeric backoff value from the LOG_WARN unlock- failed message. The client receives backoff_seconds via the UNLOCK_FAILED status; printing it again to USB serial under non-DEBUG_MUTE builds (i.e. MESHTASTIC_LOCKDOWN_DEBUG dev builds) was the only place it appeared in logs. Verified with an nRF52 lockdown build (rak4631). * fix(lockdown): atomic post-unlock reload with corruption surface (audit) Closes M6, M7, M8, M9 from the lockdown security audit. M6 — handleLockdownAuthInline no longer flips the connection to authorized or emits UNLOCKED on the cold-unlock path (the first successful passphrase verify after a locked boot). The client keeps seeing LOCKED until reloadFromDisk has actually populated config / channelFile / nodeDatabase with the operator's real values. Without this, the window between the auth call and the main-loop reload exposed two race-friendly bugs: (a) the client could read the locked-default placeholders as if they were the real config, and (b) a set_config in the window would silently overwrite a corrupted baseline once the reload swapped values in. A new per-status-slot bool pendingUnlockAfterReload records that the connection is mid-unlock. The re-verify path (storage already unlocked) is unchanged and authorizes immediately — there is nothing to reload. M7 — reloadFromDisk now holds a new file-scope mutex (g_reloadFromDiskMutex) against itself, parks the radio in sleep mode before swapping config / channelFile, and reconfigures the radio with the now-real settings after. Other readers of config.lora / channelFile / nodeDatabase do not take this lock today; closing those races is a wider locking-discipline change outside the audit's M7 scope. The radio standby+reconfigure prevents the SX12xx from sitting in a half-old/half-new register set across the swap, which otherwise required a reboot to recover from. M8 — RadioInterface::reconfigure() is now called at the end of a successful reload, so the SX12xx register set actually reflects the unlocked operator settings (region, modem preset, channels) rather than staying on the locked-default placeholder. Routed through a new Router::getRadioIface() accessor — the radio interface is owned by Router as a unique_ptr and was not exposed. M9 — NodeDB::loadProto now sets a NodeDB::storageCorruptThisLoad flag whenever an encrypted file fails to decrypt or proto-decode. reloadFromDisk consumes the flag and returns false on any failure instead of silently falling back to defaults. main.cpp's reload service then calls EncryptedStorage::lockNow() and PhoneAPI::revokeAllAuth(), and the new PhoneAPI::completePendingUnlocks(false) emits LOCKED(storage_corrupt) to every pending connection — they stay unauthorized so any set_config they send is dropped at the existing unauth gates. The lock_reason string passes through buildStatus_LH's M13 redaction unchanged because it does not start with token_. The success path goes through PhoneAPI::completePendingUnlocks(true) which authorizes each pending connection, emits UNLOCKED with the current TTL, and clears the screen-lock latch once. Snapshots the target PhoneAPI* list outside the auth-table lock to avoid re-entry when setAdminAuthorized takes the same lock. Verified with an nRF52 lockdown build (rak4631). * fix(lockdown): UI/pairing fixes for first-pair + content-flash + e-ink (audit) Closes H13, M19, M20, L4 from the lockdown audit. (L3 dropped per explicit decision — battery level is not a meaningful security side channel.) H13 — BLE pairing PIN was suppressed by the lockdown lock screen on locked devices. Screen.cpp updateUiFrame's lockdown short-circuit intercepts before ui->update() runs, so the pairing-PIN overlay banner that NRF52Bluetooth::onPairingPasskey queued never painted. Net effect: a freshly-locked device on first BLE pair could not be unlocked over BLE because the operator could never see the PIN — chicken and egg. Adds a new notificationTypeEnum::pairing_pin value and special-cases it in the short-circuit: paint the LOCKED frame first (so the underlying background remains the redacted view, never dashboard content) then let ui->update() composite the PIN banner overlay on top. The PIN itself is an ephemeral pair-handshake artifact (regenerated per attempt, dies on banner timeout) and is not operator content, so this does not regress the redaction guarantee. NRF52Bluetooth::onPairingPasskey switches from showSimpleBanner to showOverlayBanner with notificationType = pairing_pin so the short-circuit's lookup matches. M19 — Brief content-visible window on Screen::handleSetOn(true) wake. OLED GDDRAM physically retains the last-rendered frame while the panel is powered off; the next ui->update() after displayOn() is async, so an observer (or shoulder-surfer) could see the previous frame's content for 16-50 ms on every wake. Under MESHTASTIC_LOCKDOWN we now paint the LOCKED frame into GDDRAM in handleSetOn(false) before calling displayOff(). On wake the only thing the panel can flash is the redacted view. Gated on lockdown only — non-lockdown builds keep the previous frame as a UX cue. M20 — E-ink panels physically retain the last-rendered image without power. A power-cycled lockdown handheld kept showing operator-identifying content (position, messages, nodeinfo) until the firmware's first natural refresh — which on e-ink can be seconds into boot. Now, under MESHTASTIC_LOCKDOWN && USE_EINK, the panel init path in Screen::setup() paints the LOCKED frame and forces a full refresh (forceDisplay) immediately after ui->init() and before any other rendering. Persistent pixels are wiped to the redacted view before an observer can see them. Build-tested on seeed_wio_tracker_L1_eink; hardware-verified visual confirmation is pending a T-Echo session. L4 — Screen::blink() bypasses the normal ui->update() path that the lockdown short-circuit gates. It draws arbitrary geometry, not node data, so it does not actually leak today; but any future change that puts content into blink would silently leak past redaction. Added an early-return on shouldRedactDisplay() to make the function honor the redaction contract. Verified with nRF52 lockdown builds on both rak4631 (OLED) and seeed_wio_tracker_L1_eink (e-ink). * fix(lockdown): refuse APPROTECT on vulnerable silicon, gate on provision (audit) Closes M22 and M23 from the lockdown audit. M22 — APPROTECT lockout on nRF52840 is publicly known to be bypassable on every silicon revision shipping in current Meshtastic hardware (AAB0..AAF0) via SWD glitching, per LimitedResults' published research on the nRF52 series. Engaging APPROTECT on these revisions has two bad properties: (1) the lockout is irreversible without a destructive nrfjprog --recover, and (2) it gives the operator a false sense of security because the lockout itself can be defeated by anyone with ten minutes and a glitcher. enableAPProtect() now reads FICR.INFO.VARIANT (encoded as a 4-byte ASCII word) and refuses to engage on any known-vulnerable revision, logging the variant so the operator knows their device's specific build code. To override (e.g. for end-to-end testing of the engage path on hardware that's known affected), rebuild with -DMESHTASTIC_APPROTECT_OVERRIDE_VULNERABLE_SILICON=1. The vulnerable list is explicit and easy to update: any future revision shown to be fixed can be removed from the list and APPROTECT will engage on it as before. M23 — APPROTECT engagement moved from very early in setup() to after fsInit() + EncryptedStorage::initLocked(), and gated on EncryptedStorage::isProvisioned(). A misconfigured CI build of a lockdown variant flashed to a dev board would otherwise burn SWD on first boot before the operator had set any passphrase, taking the board out of the development/recovery workflow with zero real security benefit (there is no DEK to protect on an unprovisioned device). Engagement now follows operator intent: SWD locks only once they've committed to lockdown via passphrase provisioning. The SWD-attachable window between boot and APPROTECT engagement widens slightly from this reorder (now ~hundreds of ms while fsInit runs) but APPROTECT remains effective on the only payload it could protect (the in-RAM DEK loaded by initLocked which now runs *after* APPROTECT for already-provisioned devices). Verified with an nRF52 lockdown build (rak4631). * tools: harden lockdown_provision.py (audit) Closes M26-M30 and addresses L7. M26 — passphrase input. --passphrase on argv now requires --insecure-passphrase-on-cmdline as an explicit acknowledgement; without it the tool refuses and points at --passphrase-file or the interactive prompt. --passphrase-file refuses to read anything that isn't mode 0600 (so a passphrase another user can read off the filesystem doesn't silently succeed). With neither, the tool reads the passphrase via getpass.getpass — and on 'provision' double-prompts with a confirm. M27 — provision now requires an explicit 'yes' confirmation unless --yes is passed, after printing the warning that the passphrase cannot be recovered. The double-passphrase prompt is built into gather_passphrase(confirm=True). Reduces the chance of a typo binding a device to an unrecoverable passphrase. M28 — 'lock' subcommand gains a 'lock-now' alias, matching how the audit and wire docs refer to it everywhere. Both forms now require 'yes' confirmation unless --yes is set, so an accidental command doesn't immediately reboot the device into a locked state. M29 — the 4-second sleep is gone. Replaced with a StatusFuture single-shot that the FromRadio interceptor signals when the next LockdownStatus arrives. provision/unlock/lock wait up to --wait seconds (default 8) for the actual reply and exit non-zero with the device's reason on UNLOCK_FAILED, surfacing backoff_seconds in the error line. Exit codes are now meaningful: 0 = UNLOCKED 1 = no status / unexpected 2 = NEEDS_PROVISION (or a precondition fault: missing pkg, bad args) 3 = LOCKED (ambiguous: device reported locked rather than the expected unlocked result) 4 = UNLOCK_FAILED This lets ops scripts decide what to do without parsing stdout. M30 — top-of-file docstring gained an explicit SECURITY MODEL block that names the threat model (USB-only, passphrase cleartext on the cable) and forbids extension to TCP/BLE/UDP without a redesign. A runtime banner reprints the headline on every invocation. --port values starting with tcp:/tcp://, ble:/ble://, udp:/udp://, ws:/wss: are rejected at argument parse before any connection attempt; a copy-paste of an example into a context with a different --port cannot silently leak credentials to the wire. L7 — private meshtastic APIs (_handleFromRadio, _sendToRadio, _generatePacketId) are still in use because the lib does not yet dispatch LockdownStatus on a public pubsub topic and there is no public seam for raw ToRadio. Their use is now wrapped in getattr-with-clear-error so a future lib version that removes them produces an actionable error instead of an obscure traceback. The top-of-file note explains why we're on the private surface. Verified end-to-end on hardware (R1-Neo + Seeed Wio Tracker L1) during the audit-remediation hardware test pass: - provision (interactive, with confirm and double-prompt) - unlock (success returns UNLOCKED + boots TTL) - watch (passive listener emits LockdownStatus events) - lock-now (with --yes) * fix(lockdown): H13 — render pairing PIN steady over LOCKED frame Two bugs in the H13 fix from commit 614b7f001: 1. NotificationRenderer::drawBannercallback's switch had no case for the new notificationTypeEnum::pairing_pin. The function fell through to no-op so the banner never rendered. Added pairing_pin alongside text_banner so it dispatches to drawAlertBannerOverlay (same rendering, distinct type so the lockdown short-circuit in Screen.cpp can recognise it). 2. updateUiFrame's lockdown short-circuit called ui->update() to composite the banner. That redraws the current carousel frame (the dashboard) into the host framebuffer BEFORE the overlay paints, so the panel flashed dashboard content under the banner on every cycle. Replaced with a direct call to drawBannercallback so only the banner box is painted on top of the LOCKED pixels. Also: drawLockdownLockScreen used to commit to the panel (display->display()) at its end. With the banner overlay then painting and committing a second time, the panel visibly flickered between 'just LOCKED' and 'LOCKED + banner' on every render cycle. Split into drawLockdownLockScreenIntoBuffer (no commit) for the lockdown short-circuit, and a thin drawLockdownLockScreen wrapper that calls Buffer + display() for the other call sites that don't composite anything on top. The short-circuit now commits exactly once per frame after both LOCKED + any overlay are in the buffer. Verified end-to-end on hardware (Seeed Wio Tracker L1, OLED): fresh BLE pair against a locked device now shows the pairing PIN steadily on top of the LOCKED frame, no flicker, no dashboard leak, and pair completes normally. * fix(lockdown): backoff MAC + atomic writes + fault wipe + size cap (audit) Closes H3, H4, H12, M10, M11, M25 from the lockdown audit. Non-format- breaking: existing devices keep their .dek and .unlock_token but their old plaintext .backoff file (6 bytes, no MAC) is silently rejected as tampered on first read and reseeded with the MAC'd 38-byte format on the next failed-attempt OR successful unlock. H3 — Pre-increment the failed-attempt counter BEFORE running the HMAC verify in unlockWithPassphrase. The previous order wrote the counter only after a failed verify, so an attacker glitching the chip between verify and write could skip the increment and bypass backoff. The slot is now reserved atomically up front; the success path writes attempts=0 to clear the reservation. Worst case for a legitimate user who power-cycles mid-success is one phantom attempt — backoff recovers next try. H4 — .backoff file is now MAC'd with HMAC-SHA256(ephemeralKEK, "backoff-auth" || body) (32-byte tag), and written atomically via SafeFile (tmp + readback verify + rename). readBackoff treats missing / wrong-size / MAC-fail uniformly as max-attempts (255) so an attacker who deletes or rewrites the file can only INCREASE the wait, never decrease it. clearBackoff() now writes an attempts=0 sentinel instead of removing the file, so 'missing == tamper' is unambiguous post-provision. bumpBootsSinceFailOnBoot() skips on un-provisioned devices to avoid false 'tamper' detection during the legitimate fresh window between fsInit and provisionPassphrase. H12 — saveDEK and writeUnlockToken now write via SafeFile in fullAtomic mode (tmp file + readback verify + atomic rename) instead of remove-then-open-then-write. Power loss during a DEK or token write previously left the device unable to unlock — the encrypted prefs files are unreadable without a valid DEK. The atomic path rolls back to the previous file on partial write. M10 — readAndConsumeToken's 74-byte stack buffer (entire wrapped DEK + HMAC, explicitly called out by the audit as never wiped before return) is now a meshtastic_security::ZeroizingBuffer that the destructor scrubs on every return path. Same treatment for the computedHmac stack array next to it, and for the new backoff state buffers in readBackoff / writeBackoff / computeBackoffHmac. Removes the manual secure_zero calls those buffers had on success paths and fixes the missing wipes on the failure-return paths. M11 — Added EncryptedStorage::secureWipeKeys() public API that zeros dek/kek/ephemeralKek in BSS without touching flash, no logging, no locks (safe from interrupt context). HardFault_Impl now calls it as the very first thing on entry, before the diagnostic print / coredump path runs, so a hard-fault crash dump won't capture the DEK / KEK material that the rest of the module leaves in RAM. M25 — migrateFile now refuses to allocate a buffer for any file larger than 64 KiB. The legitimate ceiling is well under that on every supported variant; anything larger is either corrupt or a DFU-injected OOM attempt. Verified with an nRF52 lockdown build (rak4631). * fix(lockdown): MENC header MAC + token rollback counter (audit) Closes M2 and M4 from the lockdown audit. **FORMAT-BREAKING** — devices provisioned with prior lockdown firmware must factory-erase /prefs and reprovision; the previous tokens and encrypted prefs files will not decrypt under the new HMAC/body layouts. M2 — The HMAC on MENC encrypted proto files now covers the full on-disk header (4-byte magic + 13-byte nonce + 4-byte plaintext_len + ciphertext) instead of just (nonce + ciphertext). Without this, magic and plaintext_len were integrity-protected only by the equality check `plaintextLen == ciphertextLen` — which holds today (no padding / compression / AAD) but would silently produce length-oracle and downgrade vulnerabilities the instant any of those got added. Putting the header inside the MAC closes that pre-condition cleanly. The verify side in readAndDecrypt and the compose side in encryptAndWrite update in lockstep. M4 — UTOK gains a 4-byte monotonic counter field inside its MAC'd body. The highest counter ever issued is persisted to a new /prefs/.tokmono file MAC'd with HMAC-SHA256(ephemeralKEK, "tokmono-auth" || counter). On every readAndConsumeToken, any token whose counter is less than the persisted value is rejected as a rollback attempt and deleted. Defeats the audit's threat: an attacker who once captured a token (e.g. bootsRemaining=255 from before the operator lowered the policy) tries to write it back to disk later. Counter is incremented monotonically across the device's lifetime so any captured snapshot loses to the persisted max-seen. Self-heal: a token whose counter exceeds the persisted value (e.g. the .tokmono write itself failed after the token committed, or the .tokmono got wiped via factory-erase) is accepted AND the counter file is promoted to match. This avoids spuriously rejecting valid tokens after partial-update recovery. Threat model caveat (consistent with C2 acceptance): an attacker who has both flash extraction AND FICR can recompute the .tokmono MAC and restore a matching pair (.unlock_token + .tokmono) from an earlier capture. M4 raises the bar to that combined capability; the flash-write-only attacker is now blocked. Verified with an nRF52 lockdown build (rak4631). MIGRATION: devices already provisioned with the prior lockdown firmware will fail to auto-unlock at boot (token format mismatch), fall back to LOCKED(needs_auth), and every passphrase attempt will fail because the encrypted /prefs files are HMAC'd against the old input. Recovery is: factory-erase via the bootloader UF2 then re-provision via lockdown_provision.py or the Android app. * feat(lockdown): make lockdown a runtime client-toggleable setting Converts MESHTASTIC_LOCKDOWN from a per-variant compile-time flag that forced lockdown ON into an internal capability that is ALWAYS compiled in for nRF52 and gated purely at runtime by whether a passphrase has been provisioned. A device that has never been provisioned (or that the operator disabled) behaves exactly like stock firmware. Build/config: - configuration.h auto-defines MESHTASTIC_LOCKDOWN (+ ACCESS_CONTROL, ENCRYPTED_STORAGE, APPROTECT-capable) for ARCH_NRF52 unconditionally. No variant sets -DMESHTASTIC_LOCKDOWN anymore. Flash-constrained variants can opt out with -DMESHTASTIC_EXCLUDE_LOCKDOWN=1. DEBUG_MUTE is no longer coupled to lockdown (a capable-but-off device must log normally). rak4631 lands at 96.2% flash with lockdown always-in. Runtime predicate: - EncryptedStorage::isLockdownActive() == isProvisioned() (.dek exists) is the single source of truth for active/inactive. - PhoneAPI::getAdminAuthorized() returns true when lockdown is inactive, so every existing redaction gate no-ops on a capable-but-off device with no per-site changes. The locked-boot defaults path (NodeDB), the AdminModule storage-locked gate, the screen-redaction predicate, and the plaintext->encrypted migrate block are all additionally gated on isLockdownActive() so an un-provisioned device loads/serves plaintext normally. - sendConfigComplete emits LockdownStatus{DISABLED} when capable-but-off so the client renders its toggle OFF. Enable (off->on): client provisions a passphrase. provisionPassphrase generates the DEK; the existing reload path encrypts the plaintext config in place (migration runs live with the DEK in RAM) and authorizes the connection -> UNLOCKED. No reboot. Disable (on->off): LockdownAuth{passphrase, disable=true}. PhoneAPI verifies the passphrase (loads DEK), sets lockdownDisablePending; the main loop runs NodeDB::disableLockdownToPlaintext() which decrypts every pref via EncryptedStorage::migrateFileToPlaintext() then removeLockdownArtifacts() deletes the DEK/token/counter/backoff (the .dek delete is the atomic commit), then reboots into normal mode. Power-loss safe and re-runnable without a persistent marker — and the crypto runs live with the operator's passphrase in RAM rather than via a boot-time marker an attacker could plant to trigger an unprompted decrypt. APPROTECT is NOT reversed (sticky; permanent on silicon where it engaged). Generated bindings (admin.pb.h / mesh.pb.h) regenerated against protobufs#927 (LockdownAuth.disable, LockdownStatus.State.DISABLED). Submodule pointer stays at the pinned develop commit; the bindings are ahead until #927 merges and the submodule is bumped, same flow as the max_session_seconds work. Builds clean: rak4631 with no flags now auto-includes lockdown. NOTE: this changes the LockdownStatus the firmware emits and adds the disable path; pairs with protobufs#927 and the upcoming Android client toggle work. * fix(lockdown): re-lock per-connection auth on BLE reconnect A provisioned device reused a single BLE PhoneAPI instance, and the per-connection auth slot (keyed by that instance) was only cleared on the !isConnected() disconnect transition. A fast disconnect/reconnect could begin a new config burst while state was still STATE_SEND_PACKETS, so the reconnected client inherited the prior session's authorization: it received SecurityConfig in the clear and no LockdownStatus, and never re-authenticated. Reset the auth slot in NRF52Bluetooth onConnect(), which fires once per physical link, so every new connection starts locked regardless of whether the previous link's close() raced the new handshake. handleStartConfig keeps its !isConnected() reset (do NOT reset on a same-connection want_config: the post-unlock re-fetch is the client pulling now-unredacted config and must keep the auth it just earned, otherwise config comes back redacted and set_config writes get dropped). * fix(lockdown): persist config on a lockdown-capable but disabled device saveProto always called encryptAndWrite when encrypted storage was compiled, and saveToDiskNoRetry skipped every save when !isUnlocked(). On a disabled (never provisioned) device there is no DEK and isUnlocked() is always false, so both paths fired and NO config ever persisted: a LoRa region set before enabling lockdown lived only in RAM, then provisioning migrated the UNSET default from disk and the region was lost. Gate both on isLockdownActive(): when lockdown is inactive the device writes plaintext exactly like stock firmware; the reloadFromDisk migrate pass then re-saves those plaintext files encrypted once the device is provisioned. Verified on hardware: region set while disabled now survives enable, reboot, and unlock. * fix(lockdown): suppress LoRa region picker under the lock screen A locked-boot lockdown device installs region=UNSET as a deliberate RAM placeholder (the real region is in encrypted storage, restored on unlock). Screen.cpp popped the region picker / onboard message whenever region==UNSET, so it rendered over the lock screen and trapped input with no way out. Skip it while the display is being redacted for lockdown. * fix(lockdown): silence cppcheck void* false positive + ruff docstring lints The nRF52 `check` (cppcheck --fail-on-defect=low) flagged arithOperationsOnVoidPointer on EncryptedStorage.cpp buffers. These are false positives: make_zeroizing_array() returns unique_ptr so .get() is uint8_t*, not void* — cppcheck just can't resolve the custom-deleter alias. File-scoped suppression, matching the existing crypto-code convention in suppressions.txt. Trunk flagged 5 ruff docstring issues in lockdown_provision.py: D301 (backslashes need a raw docstring) and D405/D407/D411/D413 (the EXAMPLES heading was being parsed as a numpydoc section). Made the docstring raw and renamed the heading to USAGE to dodge section detection while keeping the ASCII-box formatting. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(lockdown): resolve cppcheck const/null-deref defects The nRF52 `check` job (pio check --fail-on-defect=low) flagged seven real cppcheck defects in the lockdown code: - EncryptedStorage.cpp: nonce/encDek are read-only views into the token buffer -> const uint8_t *. - NodeDB.cpp: segments[] lookup table is never mutated -> const. - PhoneAPI.cpp: clearStatusSlot_LH's p is only compared; the auth-check slot and the hasPendingLockdownStatus loop var are read-only -> const. - Screen.cpp: the MESHTASTIC_LOCKDOWN drawLockdownLockScreen() guard introduced a redundant null check (nullPointerRedundantCheck) since dispdev->displayOff() right below derefs it unguarded, as does the rest of the file. Dropped the guard. Verified with cppcheck 2.21 locally against the project suppressions. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(lockdown): const-qualify clearAuthSlot_LH param (cppcheck cascade) Making clearStatusSlot_LH take const PhoneAPI* let cppcheck propagate the same to clearAuthSlot_LH, whose p is only compared and forwarded. The remaining PhoneAPI* params (findOrAlloc*Slot_LH) store p into the slot table, so they correctly stay non-const. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(lockdown): wire runtime-toggle disable flow into provision tool Addresses Copilot review on tools/lockdown_provision.py — the reference tool advertised the runtime-toggle disable lifecycle but couldn't exercise it: - _STATE_NAMES: map LockdownStatus.DISABLED so a capable-but-off boot prints DISABLED instead of an opaque state=. - build_lockdown_auth(): add a disable param that actually sets la.disable, failing loudly on pre-runtime-toggle bindings instead of silently sending a plain unlock. - cmd_disable() + 'disable' subcommand: send LockdownAuth{disable=true, passphrase=...} and wait for the resulting LockdownStatus. Mirrors the firmware: non-empty passphrase required, DISABLED broadcast precedes the reboot, TTL/session fields ignored. - _exit_code_for_status(): treat DISABLED as a success (exit 0) like UNLOCKED. All DISABLED/disable references are hasattr-guarded so the tool still imports and runs the lock/unlock/provision paths against the currently released meshtastic package (verified: it has LockdownAuth but not yet disable/DISABLED). Verified with ruff 0.15.13 and black. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Ben Meadors Co-authored-by: Claude Opus 4.8 (1M context) --- src/PowerFSM.cpp | 4 + src/configuration.h | 73 + src/graphics/Screen.cpp | 136 +- src/graphics/Screen.h | 16 +- src/graphics/draw/NotificationRenderer.cpp | 6 + src/input/InputBroker.cpp | 19 + src/main.cpp | 144 +- src/main.h | 13 + src/mesh/MeshService.h | 6 + src/mesh/NodeDB.cpp | 244 +++ src/mesh/NodeDB.h | 32 + src/mesh/PhoneAPI.cpp | 831 ++++++++- src/mesh/PhoneAPI.h | 68 + src/mesh/Router.h | 8 + src/modules/AdminModule.cpp | 63 +- src/platform/nrf52/NRF52Bluetooth.cpp | 18 +- src/platform/nrf52/hardfault.cpp | 14 + src/security/APProtect.cpp | 103 ++ src/security/APProtect.h | 32 + src/security/EncryptedStorage.cpp | 1819 ++++++++++++++++++++ src/security/EncryptedStorage.h | 287 +++ src/security/LockdownDisplay.cpp | 59 + src/security/LockdownDisplay.h | 80 + src/security/SecureZero.h | 60 + suppressions.txt | 5 + tools/lockdown_provision.py | 668 +++++++ 26 files changed, 4773 insertions(+), 35 deletions(-) create mode 100644 src/security/APProtect.cpp create mode 100644 src/security/APProtect.h create mode 100644 src/security/EncryptedStorage.cpp create mode 100644 src/security/EncryptedStorage.h create mode 100644 src/security/LockdownDisplay.cpp create mode 100644 src/security/LockdownDisplay.h create mode 100644 src/security/SecureZero.h create mode 100755 tools/lockdown_provision.py 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())