Skip to content

enhancement: Add rak4631_lockdown hardened build variant #9771

Open
niccellular wants to merge 3 commits intodevelopfrom
feature/rak4631-lockdown
Open

enhancement: Add rak4631_lockdown hardened build variant #9771
niccellular wants to merge 3 commits intodevelopfrom
feature/rak4631-lockdown

Conversation

@niccellular
Copy link
Member

Summary

Adds a new hardened build variant for the RAK4631 (nRF52840): rak4631_lockdown, enabled by a single compile flag -DMESHTASTIC_LOCKDOWN=1.

New files:

  • variants/nrf52840/rak4631_lockdown/ — PlatformIO environment, variant header
  • src/security/EncryptedStorage.h/.cpp — AES-128-CTR + HMAC-SHA256 encrypted proto storage via CC310 hardware, passphrase-gated DEK, TTL/boot-count unlock token, brute-force backoff
  • src/security/APProtect.h/.cpp — UICR APPROTECT (SWD lockout)

Modified files:

  • configuration.h — MESHTASTIC_LOCKDOWN derives PHONEAPI_ACCESS_CONTROL, ENCRYPTED_STORAGE, ENABLE_APPROTECT, HARDENED_DEFAULTS (nRF52 only, no-op on other platforms)
  • NodeDB.cpp/.h — encrypted loadProto/saveProto, locked-boot early return, plaintext→MENC migration, hardened defaults
  • PhoneAPI.cpp/.h — per-connection auth requirement, redacts channel PSKs and security keys for unauthorized clients, revokeAllAuth() for Lock Now
  • AdminModule.cpp — passphrase delivery via set_config(security), provision/unlock/re-verify flow, Lock Now sentinel
  • PowerFSM.cpp — guard serialEnter() BLE disable on nRF52

All changes are guarded by #ifdef — zero impact on any existing build.

🤝 Attestations

  • [x ] I have tested that my proposed changes behave as described.
  • [ x] I have tested that my proposed changes do not cause any obvious regressions on the following devices:
    • Heltec (Lora32) V3
    • LilyGo T-Deck
    • [ x] LilyGo T-Beam
    • [ x] RAK WisBlock 4631
    • Seeed Studio T-1000E tracker card
    • Other (please specify below)

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
@github-actions github-actions bot added needs-review Needs human review hardware-support Hardware related: new devices or modules, problems specific to hardware labels Feb 27, 2026
@NomDeTom
Copy link
Contributor

What does the hardened mode do?

@niccellular niccellular changed the title Add rak4631_lockdown hardened build variant enhancement: Add rak4631_lockdown hardened build variant Feb 27, 2026
@niccellular
Copy link
Member Author

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.

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_lockdown PlatformIO env + variant header with hardened build flags and reduced feature surface.
  • Adds an encrypted storage layer (EncryptedStorage) using CC310 crypto and integrates it into NodeDB load/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.

Comment on lines +897 to +907
// 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) {
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
// 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) {

Copilot uses AI. Check for mistakes.
memcpy(&plaintextLen, fileBuf + pos, 4);
pos += 4;

size_t ciphertextLen = fileSize - HEADER_SIZE - HMAC_SIZE;
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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;
}

Copilot uses AI. Check for mistakes.
Comment on lines 138 to 141
#ifdef MESHTASTIC_PHONEAPI_ACCESS_CONTROL
// Authorize local PhoneAPI connections to dump full config
PhoneAPI::authorizeLocalAdmin();
#endif
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Suggested change
#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.

Copilot uses AI. Check for mistakes.
Comment on lines 1268 to 1272
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);
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +1194 to +1198
LOG_ERROR("Decrypt failed for %s, treating as corrupt", filename);
state = LoadFileResult::DECODE_FAILED;
}
delete[] decBuf;
return state;
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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[].

Copilot uses AI. Check for mistakes.
Comment on lines +1557 to +1563
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);
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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;
}

Copilot uses AI. Check for mistakes.
* 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)
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
* DEK encrypts -> proto files via AES-128-CTR + HMAC-SHA256(KEK)
* DEK encrypts -> proto files via AES-128-CTR + HMAC-SHA256(DEK, nonce || ciphertext)

Copilot uses AI. Check for mistakes.
Comment on lines 104 to 106
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;
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Suggested change
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);

Copilot uses AI. Check for mistakes.
Comment on lines +62 to +67
// 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;
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
// 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;

Copilot uses AI. Check for mistakes.
Comment on lines 533 to 534
// Derives MESHTASTIC_PHONEAPI_ACCESS_CONTROL (PhoneAPI access control) on all platforms,
// and MESHTASTIC_ENCRYPTED_STORAGE (CC310 flash encryption) on nRF52 only.
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
// 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).

Copilot uses AI. Check for mistakes.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

hardware-support Hardware related: new devices or modules, problems specific to hardware needs-review Needs human review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants