Add LockdownAuth and LockdownStatus messages for hardened firmware builds#911
Merged
thebentern merged 1 commit intoMay 11, 2026
Merged
Conversation
thebentern
reviewed
May 7, 2026
Contributor
There was a problem hiding this comment.
Pull request overview
Adds new protobuf messages to support “lockdown” mode in hardened Meshtastic firmware builds (MESHTASTIC_LOCKDOWN), replacing the prior workaround of encoding lockdown state in ClientNotification.message and repurposing SecurityConfig.private_key for passphrase transport.
Changes:
- Add
FromRadio.lockdown_status(tag 18) and newLockdownStatusmessage to report device lockdown state and related details. - Add
AdminMessage.lockdown_auth(tag 104) and newLockdownAuthmessage to provision/unlock/lock-now via passphrase delivery and optional TTL overrides.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
meshtastic/mesh.proto |
Adds LockdownStatus and wires it into FromRadio as lockdown_status. |
meshtastic/admin.proto |
Adds LockdownAuth and wires it into AdminMessage as lockdown_auth. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
…ilds Companion proto changes for meshtastic/firmware#10349 (MESHTASTIC_LOCKDOWN hardened build option). Replaces a previous "no schema change" hack that repurposed SecurityConfig.private_key as the passphrase byte transport. AdminMessage.lockdown_auth (= 104): LockdownAuth carries a passphrase plus optional boots/until-epoch overrides, and a lock_now sentinel. Used for first-time provisioning, unlock on subsequent reboots, re-verification on already-unlocked devices, and Lock Now. Firmware decides between provision and unlock based on its own state. FromRadio.lockdown_status (= 18): LockdownStatus reports lockdown state to the client (NEEDS_PROVISION / LOCKED / UNLOCKED / UNLOCK_FAILED) plus structured fields for lock reason, token TTL, and unlock-failure backoff. Sent post-config and in response to each LockdownAuth command. Replaces the earlier scheme of encoding state as magic-string prefixes inside ClientNotification. Both messages are documented inline. No existing fields are altered.
740ce37 to
ae5ccf5
Compare
thebentern
approved these changes
May 11, 2026
niccellular
added a commit
to niccellular/firmware
that referenced
this pull request
May 12, 2026
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.
Non-nRF52: #warning, only DEBUG_MUTE activates. tools/lockdown_provision.py
drives provision / unlock / lock-now / watch over USB.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
niccellular
added a commit
to niccellular/firmware
that referenced
this pull request
May 12, 2026
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.
Non-nRF52: #warning, only DEBUG_MUTE activates. tools/lockdown_provision.py
drives provision / unlock / lock-now / watch over USB.
niccellular
added a commit
to niccellular/firmware
that referenced
this pull request
May 12, 2026
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.
Non-nRF52: #warning, only DEBUG_MUTE activates. tools/lockdown_provision.py
drives provision / unlock / lock-now / watch over USB.
niccellular
added a commit
to niccellular/firmware
that referenced
this pull request
May 13, 2026
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.
niccellular
added a commit
to niccellular/firmware
that referenced
this pull request
May 13, 2026
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.
This was referenced May 13, 2026
Closed
niccellular
added a commit
to niccellular/firmware
that referenced
this pull request
May 15, 2026
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.
niccellular
added a commit
to niccellular/firmware
that referenced
this pull request
May 15, 2026
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.
niccellular
added a commit
to niccellular/firmware
that referenced
this pull request
May 15, 2026
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.
niccellular
added a commit
to niccellular/firmware
that referenced
this pull request
May 15, 2026
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.
niccellular
added a commit
to niccellular/firmware
that referenced
this pull request
May 15, 2026
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.
niccellular
added a commit
to niccellular/firmware
that referenced
this pull request
May 15, 2026
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.
niccellular
added a commit
to niccellular/firmware
that referenced
this pull request
May 15, 2026
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.
niccellular
added a commit
to niccellular/firmware
that referenced
this pull request
May 15, 2026
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.
niccellular
added a commit
to niccellular/firmware
that referenced
this pull request
May 15, 2026
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).
niccellular
added a commit
to niccellular/firmware
that referenced
this pull request
May 18, 2026
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).
thebentern
added a commit
to meshtastic/firmware
that referenced
this pull request
Jun 11, 2026
…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>
oscgonfer
pushed a commit
to meshtastic/firmware
that referenced
this pull request
Jun 14, 2026
…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>
raghumad
pushed a commit
to raghumad/mezulla-firmware
that referenced
this pull request
Jun 25, 2026
…eshtastic#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 file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Companion proto changes for meshtastic/firmware#10349 (
MESHTASTIC_LOCKDOWN). Replaces a workaround that repurposedSecurityConfig.private_keyas the passphrase transport.