security: runtime-toggleable MESHTASTIC_LOCKDOWN hardening for nRF52 (#10349)

* 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<bool>` 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<uint8_t[], ...>
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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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=<num>.
  - 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) <noreply@anthropic.com>

---------

Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Nick
2026-06-11 15:39:49 -05:00
committed by GitHub
co-authored by GitHub Ben Meadors Claude Opus 4.8
parent eb719f6fca
commit 07a87a8254
26 changed files with 4773 additions and 35 deletions
+4
View File
@@ -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);
}
+73
View File
@@ -573,5 +573,78 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
#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"
+133 -3
View File
@@ -41,6 +41,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
#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<EInkDisplay *>(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);
+15 -1
View File
@@ -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;
@@ -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:
+19
View File
@@ -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;
}
+143 -1
View File
@@ -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
+13
View File
@@ -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
+6
View File
@@ -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
+244
View File
@@ -36,6 +36,11 @@
#include <power/PowerHAL.h>
#include <vector>
#ifdef MESHTASTIC_ENCRYPTED_STORAGE
#include "security/EncryptedStorage.h"
#include "security/SecureZero.h"
#endif
#ifdef ARCH_ESP32
#if HAS_WIFI
#include "mesh/wifi/WiFiAPClient.h"
@@ -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();
+32
View File
@@ -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;
+776 -1
View File
@@ -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<uint16_t>(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
#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;
#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;
#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;
#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;
#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;
#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,6 +866,12 @@ size_t PhoneAPI::getFromRadio(uint8_t *buf)
config_state++;
// Advance when we have sent all of our ModuleConfig objects
if (config_state > (_meshtastic_AdminMessage_ModuleConfigType_MAX + 1)) {
#ifdef MESHTASTIC_PHONEAPI_ACCESS_CONTROL
if (!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
@@ -590,24 +953,58 @@ size_t PhoneAPI::getFromRadio(uint8_t *buf)
fromRadioScratch.queueStatus = *queueStatusPacketForPhone;
releaseQueueStatusPhonePacket();
} else if (mqttClientProxyMessageForPhone) {
#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) {
#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) {
#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<volatile uint8_t *>(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<PhoneAPI *>(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<volatile uint8_t *>(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
+68
View File
@@ -4,6 +4,7 @@
#include "concurrency/Lock.h"
#include "mesh-pb-constants.h"
#include "meshtastic/portnums.pb.h"
#include <atomic>
#include <cstdint>
#include <deque>
#include <iterator>
@@ -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;
};
+8
View File
@@ -35,6 +35,14 @@ class Router : protected concurrency::OSThread, protected PacketHistory
*/
void addInterface(std::unique_ptr<RadioInterface> _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.
+62 -1
View File
@@ -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
*/
+17 -1
View File
@@ -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;
+14
View File
@@ -1,6 +1,10 @@
#include "configuration.h"
#include <core_cm4.h>
#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) {
+103
View File
@@ -0,0 +1,103 @@
#include "configuration.h"
#ifdef MESHTASTIC_ENABLE_APPROTECT
#ifdef ARCH_NRF52
#include "APProtect.h"
#include <nrf.h>
// 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
+32
View File
@@ -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
File diff suppressed because it is too large Load Diff
+287
View File
@@ -0,0 +1,287 @@
#pragma once
#ifdef MESHTASTIC_ENCRYPTED_STORAGE
#include <cstddef>
#include <cstdint>
/**
* 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 (132; matches the proto private_key field size)
* @param bootsRemaining Token valid for this many boots (default TOKEN_DEFAULT_BOOTS)
* @param validUntilEpoch Absolute Unix timestamp after which token expires (0 = no time limit)
* @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 (132; matches the proto private_key field size)
* @param bootsRemaining New token valid for this many boots
* @param validUntilEpoch Absolute Unix timestamp after which token expires (0 = no time limit)
* @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
+59
View File
@@ -0,0 +1,59 @@
#include "configuration.h"
#ifdef MESHTASTIC_LOCKDOWN
#include "LockdownDisplay.h"
#ifdef MESHTASTIC_ENCRYPTED_STORAGE
#include "security/EncryptedStorage.h"
#endif
#include <atomic>
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<bool> 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
+80
View File
@@ -0,0 +1,80 @@
#pragma once
#include <cstdint>
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
+60
View File
@@ -0,0 +1,60 @@
#pragma once
#include <cstddef>
#include <cstdint>
#include <cstring>
#include <memory>
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 <std::size_t N> 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<uint8_t[], ZeroizingArrayDeleter>;
inline ZeroizingArrayPtr make_zeroizing_array(std::size_t n)
{
return ZeroizingArrayPtr(new uint8_t[n](), ZeroizingArrayDeleter{n});
}
} // namespace meshtastic_security
+5
View File
@@ -55,6 +55,11 @@ uninitMemberVar:*/AudioThread.h
constVariableReference:*/Channels.cpp
constParameterPointer:*/unishox2.c
// False positive: make_zeroizing_array() returns unique_ptr<uint8_t[], ...>, 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
+668
View File
@@ -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=<num>" 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())