enhancement: Add rak4631_lockdown hardened build variant #9771
enhancement: Add rak4631_lockdown hardened build variant #9771niccellular wants to merge 3 commits intodevelopfrom
Conversation
Introduces a new RAK4631 build variant (-DMESHTASTIC_LOCKDOWN=1) with encrypted flash storage, BLE/USB config access control, and hardened defaults. New files: - variants/nrf52840/rak4631_lockdown/ — PlatformIO env, variant header - src/security/EncryptedStorage.h/.cpp — AES-128-CTR + HMAC-SHA256 encrypted proto storage using CC310 hardware, passphrase-gated DEK, TTL/boot-count unlock token, backoff on failed attempts - src/security/APProtect.h/.cpp — UICR APPROTECT (SWD lockout) Modified files: - configuration.h — MESHTASTIC_LOCKDOWN flag derives PHONEAPI_ACCESS_CONTROL, ENCRYPTED_STORAGE, ENABLE_APPROTECT, HARDENED_DEFAULTS (nRF52 only) - NodeDB.cpp/.h — encrypted loadProto/saveProto, locked-boot early return, migration of plaintext files to MENC, hardened defaults, saveToDiskNoRetry guard prevents FSCom.format() when storage locked - PhoneAPI.cpp/.h — per-connection auth reset, redacts channel PSKs and security keys for unauthorized clients, revokeAllAuth() for Lock Now, post-config TAK_LOCKED/TAK_NEEDS_PROVISION notification - AdminModule.cpp — passphrase delivery via set_config(security), provision/unlock/re-verify flow, Lock Now sentinel (0xFF), PKC admin auth callback to PhoneAPI - PowerFSM.cpp — guard serialEnter() BLE disable on nRF52
|
What does the hardened mode do? |
Basically, increases the security posture of a physical node by encrypting the sensitive files on the filesystem, gates access to PhoneAPI based off passphrase, and disables logs and debug port. The intent was to make it harder for unauthorized people to do anything with an unattended node. |
There was a problem hiding this comment.
Pull request overview
Adds a hardened “lockdown” build flavor for the RAK4631 (nRF52840) that enables encrypted on-device protobuf storage, tighter local PhoneAPI access control with secret redaction, and optional SWD lockout (APPROTECT), all gated behind -DMESHTASTIC_LOCKDOWN=1.
Changes:
- Introduces a new
rak4631_lockdownPlatformIO env + variant header with hardened build flags and reduced feature surface. - Adds an encrypted storage layer (
EncryptedStorage) using CC310 crypto and integrates it intoNodeDBload/save and boot flow. - Adds connection-level PhoneAPI gating/redaction hooks and AdminModule flows for provisioning/unlocking/“Lock Now”.
Reviewed changes
Copilot reviewed 14 out of 14 changed files in this pull request and generated 10 comments.
Show a summary per file
| File | Description |
|---|---|
| variants/nrf52840/rak4631_lockdown/variant.h | New lockdown variant header inheriting RAK4631 and disabling ethernet. |
| variants/nrf52840/rak4631_lockdown/platformio.ini | New PlatformIO env enabling MESHTASTIC_LOCKDOWN, muting logs, excluding modules. |
| src/security/EncryptedStorage.h | Public API + format documentation for encrypted proto storage, DEK/token scheme. |
| src/security/EncryptedStorage.cpp | CC310-backed AES-CTR + HMAC implementation, token/backoff logic, file I/O. |
| src/security/APProtect.h | Declares enableAPProtect() for SWD lockout builds. |
| src/security/APProtect.cpp | Implements UICR APPROTECT enablement on nRF52. |
| src/modules/AdminModule.cpp | Adds managed-mode gating, passphrase transport via security config, Lock Now flow. |
| src/mesh/PhoneAPI.h | Adds access-control flags/APIs for per-connection authorization + global authorization helper. |
| src/mesh/PhoneAPI.cpp | Enforces unauthorized-client gating, redacts secrets for unauthorized clients, sends lock/provision notifications. |
| src/mesh/NodeDB.h | Adds reloadFromDisk() for post-unlock runtime reload. |
| src/mesh/NodeDB.cpp | Encrypt/decrypt proto load/save paths; locked-boot behavior; plaintext→encrypted migration; save skip when locked. |
| src/main.cpp | Calls enableAPProtect() and EncryptedStorage::initLocked() at boot (when enabled). |
| src/configuration.h | Adds MESHTASTIC_LOCKDOWN macro derivations for nRF52. |
| src/PowerFSM.cpp | Avoids disabling BLE in serialEnter() on nRF52. |
| // private_key.bytes/size — raw passphrase (1–64 bytes) | ||
| // size==1, bytes[0]==0xFF — LOCK NOW sentinel | ||
| // admin_key[1].bytes[0] — boots_remaining for new token (0 → default) | ||
| // admin_key[2].bytes[0..3] LE u32 — valid_until_epoch (absolute Unix timestamp; 0 → no time limit) | ||
| { | ||
| const auto &sec = c.payload_variant.security; | ||
| const uint8_t *pp = sec.private_key.bytes; | ||
| size_t ppLen = sec.private_key.size; | ||
|
|
||
| // LOCK NOW sentinel — always honoured regardless of lock state | ||
| if (ppLen == 1 && pp[0] == 0xFF) { |
There was a problem hiding this comment.
Using ppLen==1 && pp[0]==0xFF as a LOCK NOW sentinel means a 1-byte passphrase value of 0xFF can never be used (even though passphrases are treated as raw bytes). Consider moving the sentinel to a separate field or using a multi-byte/structured marker to avoid colliding with valid passphrases.
| // private_key.bytes/size — raw passphrase (1–64 bytes) | |
| // size==1, bytes[0]==0xFF — LOCK NOW sentinel | |
| // admin_key[1].bytes[0] — boots_remaining for new token (0 → default) | |
| // admin_key[2].bytes[0..3] LE u32 — valid_until_epoch (absolute Unix timestamp; 0 → no time limit) | |
| { | |
| const auto &sec = c.payload_variant.security; | |
| const uint8_t *pp = sec.private_key.bytes; | |
| size_t ppLen = sec.private_key.size; | |
| // LOCK NOW sentinel — always honoured regardless of lock state | |
| if (ppLen == 1 && pp[0] == 0xFF) { | |
| // private_key.bytes/size — raw passphrase (1–64 bytes) | |
| // size==0 & admin_key[0].bytes[0]==0xFF | |
| // — LOCK NOW sentinel (uses admin_key[0] as a structured flag) | |
| // admin_key[1].bytes[0] — boots_remaining for new token (0 → default) | |
| // admin_key[2].bytes[0..3] LE u32 — valid_until_epoch (absolute Unix timestamp; 0 → no time limit) | |
| { | |
| const auto &sec = c.payload_variant.security; | |
| const uint8_t *pp = sec.private_key.bytes; | |
| size_t ppLen = sec.private_key.size; | |
| // LOCK NOW sentinel — always honoured regardless of lock state | |
| const auto &lockCmd = sec.admin_key[0]; | |
| if (ppLen == 0 && lockCmd.size == 1 && lockCmd.bytes[0] == 0xFF) { |
| memcpy(&plaintextLen, fileBuf + pos, 4); | ||
| pos += 4; | ||
|
|
||
| size_t ciphertextLen = fileSize - HEADER_SIZE - HMAC_SIZE; |
There was a problem hiding this comment.
plaintextLen is read from the header but never validated against the actual ciphertext length (fileSize - HEADER_SIZE - HMAC_SIZE). Because downstream code trusts outLen = plaintextLen, a tampered header could make callers read beyond decrypted data (uninitialized bytes) or accept truncated data. Validate plaintextLen == ciphertextLen (or at least plaintextLen <= ciphertextLen and decrypt only plaintextLen) before returning.
| size_t ciphertextLen = fileSize - HEADER_SIZE - HMAC_SIZE; | |
| size_t ciphertextLen = fileSize - HEADER_SIZE - HMAC_SIZE; | |
| if (plaintextLen == 0 || plaintextLen > ciphertextLen) { | |
| LOG_ERROR("EncryptedStorage: Invalid plaintext length %u (ciphertext length %u) in %s", | |
| (unsigned)plaintextLen, (unsigned)ciphertextLen, filename); | |
| memset(fileBuf, 0, fileSize); | |
| delete[] fileBuf; | |
| return false; | |
| } |
| #ifdef MESHTASTIC_PHONEAPI_ACCESS_CONTROL | ||
| // Authorize local PhoneAPI connections to dump full config | ||
| PhoneAPI::authorizeLocalAdmin(); | ||
| #endif |
There was a problem hiding this comment.
PhoneAPI::authorizeLocalAdmin() is triggered for any PKC admin payload with an authorized sender key, which sets a global flag affecting local PhoneAPI redaction/gating. That means a remote PKC admin message (or any other context that passes this check) can unintentionally authorize local config dumps/injection without a passphrase. Consider scoping this authorization to the originating connection/session (or at least gating this call so it only applies to truly local authorization events).
| #ifdef MESHTASTIC_PHONEAPI_ACCESS_CONTROL | |
| // Authorize local PhoneAPI connections to dump full config | |
| PhoneAPI::authorizeLocalAdmin(); | |
| #endif | |
| // Note: Do NOT authorize local PhoneAPI admin sessions based solely on a remote PKC admin payload. | |
| // Remote possession of an admin key should not globally relax local PhoneAPI redaction/gating. |
src/security/EncryptedStorage.cpp
Outdated
| concurrency::LockGuard g(spiLock); | ||
| FSCom.remove(filename); // prevent O_APPEND accumulation on nRF52 | ||
| auto f = FSCom.open(filename, FILE_O_WRITE); | ||
| if (!f) { | ||
| LOG_ERROR("EncryptedStorage: Can't create %s", filename); |
There was a problem hiding this comment.
encryptAndWrite() ignores fullAtomic and writes directly via FSCom.open() instead of using the existing SafeFile pattern (atomic replace + readback verification). That reduces reliability guarantees for preference files on nRF52 and can also trigger unused-parameter warnings. Consider implementing the verified write path (e.g., with SafeFile) or explicitly marking fullAtomic unused if it’s intentionally unsupported.
| LOG_ERROR("Decrypt failed for %s, treating as corrupt", filename); | ||
| state = LoadFileResult::DECODE_FAILED; | ||
| } | ||
| delete[] decBuf; | ||
| return state; |
There was a problem hiding this comment.
decBuf contains decrypted plaintext (including secrets) but is freed without being wiped. For a hardened build, leaving decrypted config/data in heap memory increases exposure if the memory is later reused or disclosed. Consider zeroing decBuf (using the decrypted length) before delete[].
| LOG_ERROR("Error: can't encode protobuf %s", PB_GET_ERROR(&stream)); | ||
| delete[] pbBuf; | ||
| return false; | ||
| } | ||
|
|
||
| size_t encodedSize = stream.bytes_written; | ||
| bool ok = EncryptedStorage::encryptAndWrite(filename, pbBuf, encodedSize, fullAtomic); |
There was a problem hiding this comment.
pbBuf holds plaintext (including keys/PSKs) and is freed without being wiped after encryptAndWrite(). Consider zeroing pbBuf (at least the encoded bytes) before delete[] to avoid leaving sensitive material in heap memory.
| LOG_ERROR("Error: can't encode protobuf %s", PB_GET_ERROR(&stream)); | |
| delete[] pbBuf; | |
| return false; | |
| } | |
| size_t encodedSize = stream.bytes_written; | |
| bool ok = EncryptedStorage::encryptAndWrite(filename, pbBuf, encodedSize, fullAtomic); | |
| LOG_ERROR("Error: can't encode protobuf %s", PB_GET_ERROR(&stream)); | |
| // Wipe potentially sensitive data before freeing | |
| volatile uint8_t *pbBufVolatile = pbBuf; | |
| for (size_t i = 0; i < protoSize; ++i) { | |
| pbBufVolatile[i] = 0; | |
| } | |
| delete[] pbBuf; | |
| return false; | |
| } | |
| size_t encodedSize = stream.bytes_written; | |
| bool ok = EncryptedStorage::encryptAndWrite(filename, pbBuf, encodedSize, fullAtomic); | |
| // Wipe plaintext protobuf contents before freeing buffer | |
| volatile uint8_t *pbBufVolatile = pbBuf; | |
| for (size_t i = 0; i < encodedSize; ++i) { | |
| pbBufVolatile[i] = 0; | |
| } |
| * Key hierarchy: | ||
| * FICR eFuse IDs + passphrase -> SHA-256 -> KEK_v2 (16 bytes, never stored) | ||
| * KEK_v2 wraps -> DEK (Data Encryption Key, 16 bytes, random, stored in /prefs/.dek) | ||
| * DEK encrypts -> proto files via AES-128-CTR + HMAC-SHA256(KEK) |
There was a problem hiding this comment.
The header comment says encrypted proto files use HMAC-SHA256(KEK), but the file format description and implementation use HMAC-SHA256(DEK, nonce || ciphertext). Please correct the comment to match the actual crypto design to avoid future maintenance/security mistakes.
| * DEK encrypts -> proto files via AES-128-CTR + HMAC-SHA256(KEK) | |
| * DEK encrypts -> proto files via AES-128-CTR + HMAC-SHA256(DEK, nonce || ciphertext) |
src/modules/AdminModule.cpp
Outdated
| r->set_config.payload_variant.security.private_key.size >= 1) || | ||
| r->which_payload_variant == meshtastic_AdminMessage_factory_reset_config_tag || | ||
| r->which_payload_variant == meshtastic_AdminMessage_nodedb_reset_tag; |
There was a problem hiding this comment.
isTakSecurityCmd whitelists factory_reset_config and nodedb_reset when is_managed and storage is unlocked. This allows an unauthenticated local client (from=0) to perform destructive resets on a managed device. These reset commands should require the same authorization as other admin actions (passphrase-authorized connection or PKC/admin key).
| r->set_config.payload_variant.security.private_key.size >= 1) || | |
| r->which_payload_variant == meshtastic_AdminMessage_factory_reset_config_tag || | |
| r->which_payload_variant == meshtastic_AdminMessage_nodedb_reset_tag; | |
| r->set_config.payload_variant.security.private_key.size >= 1); |
| // New physical connection: reset auth so the client must present the passphrase. | ||
| // Do NOT reset here on re-requests (want_config_id sent again within the same | ||
| // connection after auth) — that would strip auth from a client who just unlocked | ||
| // and is re-fetching the full unredacted config. | ||
| isAdminAuthorized = false; | ||
| s_localAdminAuthorized = false; |
There was a problem hiding this comment.
s_localAdminAuthorized is a static/global auth flag, but it’s reset on every new connection in handleStartConfig(). This makes authorization fragile (it can be cleared by reconnects) and couples auth state across different PhoneAPI transports. Consider making authorization strictly per-connection (remove the static and authorize the active connection) or keeping it device-wide but only clearing it on explicit revocation (e.g., Lock Now) / reboot.
| // New physical connection: reset auth so the client must present the passphrase. | |
| // Do NOT reset here on re-requests (want_config_id sent again within the same | |
| // connection after auth) — that would strip auth from a client who just unlocked | |
| // and is re-fetching the full unredacted config. | |
| isAdminAuthorized = false; | |
| s_localAdminAuthorized = false; | |
| // New physical connection: reset per-connection auth so the client must present | |
| // the passphrase again. Do NOT clear any device-wide/static authorization flag | |
| // here; that should only be done on explicit revocation (e.g. "Lock Now") or reboot. | |
| // Do NOT reset here on re-requests (want_config_id sent again within the same | |
| // connection after auth) — that would strip auth from a client who just unlocked | |
| // and is re-fetching the full unredacted config. | |
| isAdminAuthorized = false; |
src/configuration.h
Outdated
| // Derives MESHTASTIC_PHONEAPI_ACCESS_CONTROL (PhoneAPI access control) on all platforms, | ||
| // and MESHTASTIC_ENCRYPTED_STORAGE (CC310 flash encryption) on nRF52 only. |
There was a problem hiding this comment.
The comment says MESHTASTIC_LOCKDOWN derives MESHTASTIC_PHONEAPI_ACCESS_CONTROL “on all platforms”, but the implementation only enables it under ARCH_NRF52 and otherwise ignores the flag. Please update the comment (or the macro logic) so they match.
| // Derives MESHTASTIC_PHONEAPI_ACCESS_CONTROL (PhoneAPI access control) on all platforms, | |
| // and MESHTASTIC_ENCRYPTED_STORAGE (CC310 flash encryption) on nRF52 only. | |
| // On nRF52 builds, derives MESHTASTIC_PHONEAPI_ACCESS_CONTROL (PhoneAPI access control), | |
| // MESHTASTIC_ENCRYPTED_STORAGE (CC310 flash encryption), and other hardened defaults. | |
| // On non-nRF52 platforms this flag is ignored (see ARCH_NRF52 guard below). |
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Summary
Adds a new hardened build variant for the RAK4631 (nRF52840): rak4631_lockdown, enabled by a single compile flag -DMESHTASTIC_LOCKDOWN=1.
New files:
Modified files:
All changes are guarded by #ifdef — zero impact on any existing build.
🤝 Attestations