diff --git a/src/PowerFSM.cpp b/src/PowerFSM.cpp
index 2153dbfd5..6dd408682 100644
--- a/src/PowerFSM.cpp
+++ b/src/PowerFSM.cpp
@@ -182,7 +182,11 @@ static void darkEnter()
static void serialEnter()
{
LOG_POWERFSM("State: serialEnter");
+#ifndef ARCH_NRF52
+ // nRF52 runs BLE on SoftDevice independently of USB serial — no need to disable it.
+ // (Same rationale as nbEnter() which already guards this with #ifdef ARCH_ESP32)
setBluetoothEnable(false);
+#endif
if (screen) {
screen->setOn(true);
}
diff --git a/src/configuration.h b/src/configuration.h
index 53ae30d51..a06c2e75b 100644
--- a/src/configuration.h
+++ b/src/configuration.h
@@ -528,5 +528,21 @@ along with this program. If not, see .
#define HAS_SCREEN 0
#endif
+// -----------------------------------------------------------------------------
+// MESHTASTIC_LOCKDOWN — combined hardened build flag
+// Derives MESHTASTIC_PHONEAPI_ACCESS_CONTROL (PhoneAPI access control) on all platforms,
+// and MESHTASTIC_ENCRYPTED_STORAGE (CC310 flash encryption) on nRF52 only.
+// -----------------------------------------------------------------------------
+#ifdef MESHTASTIC_LOCKDOWN
+#ifdef ARCH_NRF52
+#define MESHTASTIC_PHONEAPI_ACCESS_CONTROL 1
+#define MESHTASTIC_ENCRYPTED_STORAGE 1
+#define MESHTASTIC_ENABLE_APPROTECT 1
+#define HARDENED_DEFAULTS 1
+#else
+#warning "MESHTASTIC_LOCKDOWN requires nRF52 (CC310 hardware crypto) — ignoring on this platform"
+#endif
+#endif
+
#include "DebugConfiguration.h"
#include "RF95Configuration.h"
diff --git a/src/main.cpp b/src/main.cpp
index 5e5bee867..ee635e6d9 100644
--- a/src/main.cpp
+++ b/src/main.cpp
@@ -58,6 +58,13 @@ NimbleBluetooth *nimbleBluetooth = nullptr;
NRF52Bluetooth *nrf52Bluetooth = nullptr;
#endif
+#ifdef MESHTASTIC_ENABLE_APPROTECT
+#include "security/APProtect.h"
+#endif
+#ifdef MESHTASTIC_ENCRYPTED_STORAGE
+#include "security/EncryptedStorage.h"
+#endif
+
#if HAS_WIFI || defined(USE_WS5500)
#include "mesh/api/WiFiServerAPI.h"
#include "mesh/wifi/WiFiAPClient.h"
@@ -364,6 +371,10 @@ void setup()
consoleInit(); // Set serial baud rate and init our mesh console
#endif
+#ifdef MESHTASTIC_ENABLE_APPROTECT
+ enableAPProtect();
+#endif
+
#ifdef UNPHONE
unphone.printStore();
#endif
@@ -461,6 +472,17 @@ void setup()
fsInit();
+#ifdef MESHTASTIC_ENCRYPTED_STORAGE
+ EncryptedStorage::initLocked();
+ if (!EncryptedStorage::isUnlocked()) {
+ if (!EncryptedStorage::isProvisioned()) {
+ LOG_WARN("TAK: Device not provisioned — connect and set a passphrase to unlock storage");
+ } else {
+ LOG_WARN("TAK: Device locked — connect and provide passphrase to unlock storage");
+ }
+ }
+#endif
+
#if !MESHTASTIC_EXCLUDE_I2C
#if defined(I2C_SDA1) && defined(ARCH_RP2040)
Wire1.setSDA(I2C_SDA1);
diff --git a/src/mesh/NodeDB.cpp b/src/mesh/NodeDB.cpp
index a52fa4478..3342394c1 100644
--- a/src/mesh/NodeDB.cpp
+++ b/src/mesh/NodeDB.cpp
@@ -30,6 +30,10 @@
#include
#include
+#ifdef MESHTASTIC_ENCRYPTED_STORAGE
+#include "security/EncryptedStorage.h"
+#endif
+
#ifdef ARCH_ESP32
#if HAS_WIFI
#include "mesh/wifi/WiFiAPClient.h"
@@ -646,6 +650,20 @@ void NodeDB::installDefaultConfig(bool preserveKey = false)
config.security.private_key.size = 0;
}
config.security.public_key.size = 0;
+
+#ifdef HARDENED_DEFAULTS
+ config.security.serial_enabled = false; // Disable USB serial PhoneAPI — no auth, physical access only
+ config.security.debug_log_api_enabled = false; // No debug log leaks
+ config.security.admin_channel_enabled = false; // No legacy admin channel
+ config.security.is_managed = true; // Block all from=0 admin when unlocked;
+ // operator must use PKC admin key to configure.
+ // Passphrase delivery still works when locked
+ // (AdminModule special-cases !isUnlocked()).
+ config.device.rebroadcast_mode = meshtastic_Config_DeviceConfig_RebroadcastMode_LOCAL_ONLY;
+ // FIXED_PIN allows the operator to connect via BLE and provision the passphrase.
+ config.bluetooth.mode = meshtastic_Config_BluetoothConfig_PairingMode_FIXED_PIN;
+#endif
+
#ifdef PIN_GPS_EN
config.position.gps_en_gpio = PIN_GPS_EN;
#endif
@@ -711,7 +729,9 @@ void NodeDB::installDefaultConfig(bool preserveKey = false)
#ifdef USERPREFS_FIXED_BLUETOOTH
config.bluetooth.fixed_pin = USERPREFS_FIXED_BLUETOOTH;
config.bluetooth.mode = meshtastic_Config_BluetoothConfig_PairingMode_FIXED_PIN;
-#else
+#elif !defined(HARDENED_DEFAULTS)
+ // Default: use RANDOM_PIN only if device has a screen (to display the PIN).
+ // set FIXED_PIN in the HARDENED_DEFAULTS block above.
config.bluetooth.mode = hasScreen ? meshtastic_Config_BluetoothConfig_PairingMode_RANDOM_PIN
: meshtastic_Config_BluetoothConfig_PairingMode_FIXED_PIN;
#endif
@@ -1149,6 +1169,37 @@ LoadFileResult NodeDB::loadProto(const char *filename, size_t protoSize, size_t
void *dest_struct)
{
LoadFileResult state = LoadFileResult::OTHER_FAILURE;
+
+#ifdef MESHTASTIC_ENCRYPTED_STORAGE
+ // check if the file is encrypted and decrypt before protobuf decode
+ if (EncryptedStorage::isEncrypted(filename)) {
+ uint8_t *decBuf = new uint8_t[protoSize];
+ if (!decBuf) {
+ LOG_ERROR("OOM decrypting %s", filename);
+ return LoadFileResult::OTHER_FAILURE;
+ }
+ size_t decLen = 0;
+ if (EncryptedStorage::readAndDecrypt(filename, decBuf, protoSize, decLen)) {
+ LOG_INFO("Load encrypted %s", filename);
+ pb_istream_t stream = pb_istream_from_buffer(decBuf, decLen);
+ if (fields != &meshtastic_NodeDatabase_msg)
+ memset(dest_struct, 0, objSize);
+ if (!pb_decode(&stream, fields, dest_struct)) {
+ LOG_ERROR("Error: can't decode protobuf %s", PB_GET_ERROR(&stream));
+ state = LoadFileResult::DECODE_FAILED;
+ } else {
+ LOG_INFO("Loaded encrypted %s successfully", filename);
+ state = LoadFileResult::LOAD_SUCCESS;
+ }
+ } else {
+ LOG_ERROR("Decrypt failed for %s, treating as corrupt", filename);
+ state = LoadFileResult::DECODE_FAILED;
+ }
+ delete[] decBuf;
+ return state;
+ }
+#endif
+
#ifdef FSCom
concurrency::LockGuard g(spiLock);
@@ -1225,6 +1276,21 @@ void NodeDB::loadFromDisk()
}
#endif
+
+#ifdef MESHTASTIC_ENCRYPTED_STORAGE
+ if (!EncryptedStorage::isUnlocked()) {
+ // Encrypted storage is locked — install defaults and wait for passphrase via BLE/serial.
+ // reloadFromDisk() will be called by AdminModule once the device is unlocked.
+ LOG_WARN("NodeDB: Encrypted storage locked, using default config until unlocked");
+ installDefaultNodeDatabase();
+ installDefaultDeviceState();
+ installDefaultConfig();
+ installDefaultModuleConfig();
+ installDefaultChannels();
+ return;
+ }
+#endif
+
auto state = loadProto(nodeDatabaseFileName, getMaxNodesAllocatedSize(), sizeof(meshtastic_NodeDatabase),
&meshtastic_NodeDatabase_msg, &nodeDatabase);
if (nodeDatabase.version < DEVICESTATE_MIN_VER) {
@@ -1386,6 +1452,35 @@ void NodeDB::loadFromDisk()
LOG_INFO("Loaded UIConfig");
}
+#ifdef MESHTASTIC_ENCRYPTED_STORAGE
+ // Ensure all config segments are persisted to encrypted storage.
+ // installDefaultConfig/installDefaultModuleConfig only set in-memory structs
+ // without saving to disk, so we force a save here to ensure encrypted files exist.
+ {
+ const char *filesToCheck[] = {configFileName, moduleConfigFileName, channelFileName, deviceStateFileName,
+ nodeDatabaseFileName};
+ int segments[] = {SEGMENT_CONFIG, SEGMENT_MODULECONFIG, SEGMENT_CHANNELS, SEGMENT_DEVICESTATE, SEGMENT_NODEDATABASE};
+ int toSave = 0;
+ for (int i = 0; i < 5; i++) {
+ if (!EncryptedStorage::isEncrypted(filesToCheck[i])) {
+ toSave |= segments[i];
+ }
+ }
+ if (toSave) {
+ LOG_INFO("TAK: Saving unencrypted segments to encrypted storage (mask=0x%x)", toSave);
+ saveToDisk(toSave);
+ }
+
+ // Migrate any remaining plaintext proto files (from standard firmware upgrade)
+ for (const char *fn : filesToCheck) {
+ if (!EncryptedStorage::isEncrypted(fn)) {
+ LOG_INFO("Migrating %s to encrypted storage", fn);
+ EncryptedStorage::migrateFile(fn);
+ }
+ }
+ }
+#endif
+
// 2.4.X - configuration migration to update new default intervals
if (moduleConfig.version < 23) {
LOG_DEBUG("ModuleConfig version %d is stale, upgrading to new default intervals", moduleConfig.version);
@@ -1421,8 +1516,21 @@ void NodeDB::loadFromDisk()
}
#endif
+
}
+#ifdef MESHTASTIC_ENCRYPTED_STORAGE
+/**
+ * Re-run loadFromDisk() after encrypted storage is unlocked at runtime.
+ * Called by AdminModule after a successful provisionPassphrase / unlockWithPassphrase.
+ */
+void NodeDB::reloadFromDisk()
+{
+ LOG_INFO("NodeDB: Reloading config from encrypted storage after unlock");
+ loadFromDisk();
+}
+#endif
+
/** Save a protobuf from a file, return true for success */
bool NodeDB::saveProto(const char *filename, size_t protoSize, const pb_msgdesc_t *fields, const void *dest_struct,
bool fullAtomic)
@@ -1435,6 +1543,34 @@ bool NodeDB::saveProto(const char *filename, size_t protoSize, const pb_msgdesc_
return false;
}
+#ifdef MESHTASTIC_ENCRYPTED_STORAGE
+ // Encrypt all files except uiconfig (no secrets) and the DEK file (self-encrypted)
+ if (strcmp(filename, uiconfigFileName) != 0) {
+ // First encode protobuf to a temporary buffer
+ uint8_t *pbBuf = new uint8_t[protoSize];
+ if (!pbBuf) {
+ LOG_ERROR("OOM encoding %s for encryption", filename);
+ return false;
+ }
+
+ pb_ostream_t stream = pb_ostream_from_buffer(pbBuf, protoSize);
+ if (!pb_encode(&stream, fields, dest_struct)) {
+ 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);
+ delete[] pbBuf;
+
+ if (!ok) {
+ LOG_ERROR("EncryptedStorage: Failed to encrypt and write %s", filename);
+ }
+ return ok;
+ }
+#endif
+
bool okay = false;
#ifdef FSCom
auto f = SafeFile(filename, fullAtomic);
@@ -1527,6 +1663,16 @@ bool NodeDB::saveToDiskNoRetry(int saveWhat)
return false;
}
+#ifdef MESHTASTIC_ENCRYPTED_STORAGE
+ // When encrypted storage is locked, encryptAndWrite() returns false for every file.
+ // That would cause saveToDisk()'s nRF52 retry path to call FSCom.format(), wiping all
+ // encrypted proto files from flash. Return true here — "nothing to save, not an error."
+ if (!EncryptedStorage::isUnlocked()) {
+ LOG_WARN("NodeDB: saveToDisk skipped — encrypted storage locked");
+ return true;
+ }
+#endif
+
bool success = true;
#ifdef FSCom
spiLock->lock();
diff --git a/src/mesh/NodeDB.h b/src/mesh/NodeDB.h
index adf2b42ea..635e5befd 100644
--- a/src/mesh/NodeDB.h
+++ b/src/mesh/NodeDB.h
@@ -307,6 +307,12 @@ class NodeDB
newStatus.notifyObservers(&status);
}
+#ifdef MESHTASTIC_ENCRYPTED_STORAGE
+ /// Re-run loadFromDisk() after the encrypted storage is unlocked at runtime.
+ /// Called by AdminModule after a successful provisionPassphrase / unlockWithPassphrase.
+ void reloadFromDisk();
+#endif
+
private:
bool duplicateWarned = false;
bool localPositionUpdatedSinceBoot = false;
diff --git a/src/mesh/PhoneAPI.cpp b/src/mesh/PhoneAPI.cpp
index 9050ee89d..b7f77fd07 100644
--- a/src/mesh/PhoneAPI.cpp
+++ b/src/mesh/PhoneAPI.cpp
@@ -3,6 +3,9 @@
#include "GPS.h"
#endif
+#ifdef MESHTASTIC_ENCRYPTED_STORAGE
+#include "security/EncryptedStorage.h"
+#endif
#include "Channels.h"
#include "Default.h"
#include "FSCommon.h"
@@ -54,6 +57,14 @@ void PhoneAPI::handleStartConfig()
observe(&service->fromNumChanged);
#ifdef FSCom
observe(&xModem.packetReady);
+#endif
+#ifdef MESHTASTIC_PHONEAPI_ACCESS_CONTROL
+ // 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;
#endif
}
@@ -127,6 +138,10 @@ void PhoneAPI::close()
config_state = 0;
pauseBluetoothLogging = false;
heartbeatReceived = false;
+#ifdef MESHTASTIC_PHONEAPI_ACCESS_CONTROL
+ isAdminAuthorized = false;
+ s_localAdminAuthorized = false;
+#endif
}
}
@@ -155,6 +170,21 @@ bool PhoneAPI::handleToRadio(const uint8_t *buf, size_t bufLength)
if (pb_decode_from_bytes(buf, bufLength, &meshtastic_ToRadio_msg, &toRadioScratch)) {
switch (toRadioScratch.which_payload_variant) {
case meshtastic_ToRadio_packet_tag:
+#ifdef MESHTASTIC_PHONEAPI_ACCESS_CONTROL
+ if (!isAdminAuthorized && !s_localAdminAuthorized) {
+ // Allow admin messages addressed to this device — passphrase delivery must get through.
+ // AdminModule handles its own is_managed gate for those.
+ // Block everything else — unauthorized clients cannot inject mesh traffic.
+ // Require the packet to carry a decoded (not encrypted) payload so portnum is valid.
+ bool isLocalAdmin = toRadioScratch.packet.which_payload_variant == meshtastic_MeshPacket_decoded_tag &&
+ toRadioScratch.packet.decoded.portnum == meshtastic_PortNum_ADMIN_APP &&
+ toRadioScratch.packet.to == nodeDB->getNodeNum();
+ if (!isLocalAdmin) {
+ LOG_INFO("TAK: Dropping non-admin ToRadio packet from unauthorized client");
+ return false;
+ }
+ }
+#endif
return handleToRadioPacket(toRadioScratch.packet);
case meshtastic_ToRadio_want_config_id_tag:
config_nonce = toRadioScratch.want_config_id;
@@ -166,6 +196,12 @@ bool PhoneAPI::handleToRadio(const uint8_t *buf, size_t bufLength)
close();
break;
case meshtastic_ToRadio_xmodemPacket_tag:
+#ifdef MESHTASTIC_PHONEAPI_ACCESS_CONTROL
+ if (!isAdminAuthorized && !s_localAdminAuthorized) {
+ LOG_INFO("TAK: Dropping xmodem packet from unauthorized client");
+ break;
+ }
+#endif
LOG_INFO("Got xmodem packet");
#ifdef FSCom
xModem.handlePacket(toRadioScratch.xmodemPacket);
@@ -286,8 +322,15 @@ size_t PhoneAPI::getFromRadio(uint8_t *buf)
}
if (config_nonce == SPECIAL_NONCE_ONLY_NODES) {
// If client only wants node info, jump directly to sending nodes
- state = STATE_SEND_OTHER_NODEINFOS;
- onNowHasData(0);
+#ifdef MESHTASTIC_PHONEAPI_ACCESS_CONTROL
+ if (!isAdminAuthorized && !s_localAdminAuthorized) {
+ state = STATE_SEND_COMPLETE_ID; // Unauthorized: skip node DB
+ } else
+#endif
+ {
+ state = STATE_SEND_OTHER_NODEINFOS;
+ onNowHasData(0);
+ }
} else {
state = STATE_SEND_METADATA;
}
@@ -303,7 +346,17 @@ size_t PhoneAPI::getFromRadio(uint8_t *buf)
case STATE_SEND_CHANNELS:
fromRadioScratch.which_payload_variant = meshtastic_FromRadio_channel_tag;
- fromRadioScratch.channel = channels.getByIndex(config_state);
+#ifdef MESHTASTIC_PHONEAPI_ACCESS_CONTROL
+ if (!isAdminAuthorized && !s_localAdminAuthorized) {
+ // Send channel structure but zero out PSK to prevent key extraction
+ fromRadioScratch.channel = channels.getByIndex(config_state);
+ memset(fromRadioScratch.channel.settings.psk.bytes, 0, sizeof(fromRadioScratch.channel.settings.psk.bytes));
+ fromRadioScratch.channel.settings.psk.size = 0;
+ } else
+#endif
+ {
+ fromRadioScratch.channel = channels.getByIndex(config_state);
+ }
config_state++;
// Advance when we have sent all of our Channels
if (config_state >= MAX_NUM_CHANNELS) {
@@ -336,6 +389,13 @@ size_t PhoneAPI::getFromRadio(uint8_t *buf)
LOG_DEBUG("Send config: network");
fromRadioScratch.config.which_payload_variant = meshtastic_Config_network_tag;
fromRadioScratch.config.payload_variant.network = config.network;
+#ifdef MESHTASTIC_PHONEAPI_ACCESS_CONTROL
+ if (!isAdminAuthorized && !s_localAdminAuthorized) {
+ // Redact wifi PSK
+ memset(fromRadioScratch.config.payload_variant.network.wifi_psk, 0,
+ sizeof(fromRadioScratch.config.payload_variant.network.wifi_psk));
+ }
+#endif
break;
case meshtastic_Config_display_tag:
LOG_DEBUG("Send config: display");
@@ -356,6 +416,30 @@ size_t PhoneAPI::getFromRadio(uint8_t *buf)
LOG_DEBUG("Send config: security");
fromRadioScratch.config.which_payload_variant = meshtastic_Config_security_tag;
fromRadioScratch.config.payload_variant.security = config.security;
+#ifdef MESHTASTIC_PHONEAPI_ACCESS_CONTROL
+ if (!isAdminAuthorized && !s_localAdminAuthorized) {
+ // Redact private key
+ memset(fromRadioScratch.config.payload_variant.security.private_key.bytes, 0,
+ sizeof(fromRadioScratch.config.payload_variant.security.private_key.bytes));
+ fromRadioScratch.config.payload_variant.security.private_key.size = 0;
+ // Redact admin keys
+ for (int i = 0; i < 3; i++) {
+ memset(fromRadioScratch.config.payload_variant.security.admin_key[i].bytes, 0,
+ sizeof(fromRadioScratch.config.payload_variant.security.admin_key[i].bytes));
+ fromRadioScratch.config.payload_variant.security.admin_key[i].size = 0;
+ }
+#ifdef MESHTASTIC_ENCRYPTED_STORAGE
+ // Signal provisioning state via admin_key[0] so the client knows
+ // whether to show "Set passphrase" vs "Enter passphrase":
+ // admin_key[0].size == 0 → not provisioned (first time setup)
+ // admin_key[0].size == 1, bytes[0] == 0x01 → provisioned, locked
+ if (EncryptedStorage::isProvisioned()) {
+ fromRadioScratch.config.payload_variant.security.admin_key[0].bytes[0] = 0x01;
+ fromRadioScratch.config.payload_variant.security.admin_key[0].size = 1;
+ }
+#endif
+ }
+#endif
break;
case meshtastic_Config_sessionkey_tag:
LOG_DEBUG("Send config: sessionkey");
@@ -454,6 +538,12 @@ size_t PhoneAPI::getFromRadio(uint8_t *buf)
config_state++;
// Advance when we have sent all of our ModuleConfig objects
if (config_state > (_meshtastic_AdminMessage_ModuleConfigType_MAX + 1)) {
+#ifdef MESHTASTIC_PHONEAPI_ACCESS_CONTROL
+ if (!isAdminAuthorized && !s_localAdminAuthorized) {
+ // Unauthorized client: skip node DB and file manifest — only send config complete
+ state = STATE_SEND_COMPLETE_ID;
+ } else
+#endif
// Handle special nonce behaviors:
// - SPECIAL_NONCE_ONLY_CONFIG: Skip node info, go directly to file manifest
// - SPECIAL_NONCE_ONLY_NODES: After sending nodes, skip to complete
@@ -544,24 +634,46 @@ size_t PhoneAPI::getFromRadio(uint8_t *buf)
fromRadioScratch.queueStatus = *queueStatusPacketForPhone;
releaseQueueStatusPhonePacket();
} else if (mqttClientProxyMessageForPhone) {
- fromRadioScratch.which_payload_variant = meshtastic_FromRadio_mqttClientProxyMessage_tag;
- fromRadioScratch.mqttClientProxyMessage = *mqttClientProxyMessageForPhone;
- releaseMqttClientProxyPhonePacket();
+#ifdef MESHTASTIC_PHONEAPI_ACCESS_CONTROL
+ if (!isAdminAuthorized && !s_localAdminAuthorized) {
+ releaseMqttClientProxyPhonePacket(); // Discard — unauthorized client
+ } else
+#endif
+ {
+ fromRadioScratch.which_payload_variant = meshtastic_FromRadio_mqttClientProxyMessage_tag;
+ fromRadioScratch.mqttClientProxyMessage = *mqttClientProxyMessageForPhone;
+ releaseMqttClientProxyPhonePacket();
+ }
} else if (xmodemPacketForPhone.control != meshtastic_XModem_Control_NUL) {
- fromRadioScratch.which_payload_variant = meshtastic_FromRadio_xmodemPacket_tag;
- fromRadioScratch.xmodemPacket = xmodemPacketForPhone;
- xmodemPacketForPhone = meshtastic_XModem_init_zero;
+#ifdef MESHTASTIC_PHONEAPI_ACCESS_CONTROL
+ if (!isAdminAuthorized && !s_localAdminAuthorized) {
+ xmodemPacketForPhone = meshtastic_XModem_init_zero; // Discard — unauthorized client
+ } else
+#endif
+ {
+ fromRadioScratch.which_payload_variant = meshtastic_FromRadio_xmodemPacket_tag;
+ fromRadioScratch.xmodemPacket = xmodemPacketForPhone;
+ xmodemPacketForPhone = meshtastic_XModem_init_zero;
+ }
} else if (clientNotification) {
+ // Always deliver clientNotification — required for TAK_UNLOCKED/TAK_LOCKED to reach client
fromRadioScratch.which_payload_variant = meshtastic_FromRadio_clientNotification_tag;
fromRadioScratch.clientNotification = *clientNotification;
releaseClientNotification();
} else if (packetForPhone) {
- printPacket("phone downloaded packet", packetForPhone);
+#ifdef MESHTASTIC_PHONEAPI_ACCESS_CONTROL
+ if (!isAdminAuthorized && !s_localAdminAuthorized) {
+ releasePhonePacket(); // Discard mesh traffic — unauthorized client
+ } else
+#endif
+ {
+ printPacket("phone downloaded packet", packetForPhone);
- // Encapsulate as a FromRadio packet
- fromRadioScratch.which_payload_variant = meshtastic_FromRadio_packet_tag;
- fromRadioScratch.packet = *packetForPhone;
- releasePhonePacket();
+ // Encapsulate as a FromRadio packet
+ fromRadioScratch.which_payload_variant = meshtastic_FromRadio_packet_tag;
+ fromRadioScratch.packet = *packetForPhone;
+ releasePhonePacket();
+ }
}
break;
@@ -604,6 +716,28 @@ void PhoneAPI::sendConfigComplete()
service->api_state = service->STATE_ETH;
}
+#ifdef MESHTASTIC_ENCRYPTED_STORAGE
+ // Immediately after config_complete_id, notify the client if it hasn't authenticated.
+ // Gate on client auth state (not storage lock state) — storage may be auto-unlocked via
+ // boot token, but each new BLE/USB connection must still present the passphrase.
+ if (!isAdminAuthorized && !s_localAdminAuthorized) {
+ char msg[48];
+ if (!EncryptedStorage::isProvisioned()) {
+ strncpy(msg, "TAK_NEEDS_PROVISION", sizeof(msg));
+ } else {
+ // Provisioned: client must authenticate. Use lock reason if storage is hard-locked,
+ // otherwise signal that passphrase auth is required for this connection.
+ if (!EncryptedStorage::isUnlocked()) {
+ snprintf(msg, sizeof(msg), "TAK_LOCKED:%s", EncryptedStorage::getLockReason());
+ } else {
+ strncpy(msg, "TAK_LOCKED:needs_auth", sizeof(msg));
+ }
+ }
+ sendNotification(meshtastic_LogRecord_Level_WARNING, 0, msg);
+ LOG_INFO("PhoneAPI: sent %s to client", msg);
+ }
+#endif
+
// Allow subclasses to know we've entered steady-state so they can lower power consumption
onConfigComplete();
@@ -842,3 +976,24 @@ int PhoneAPI::onNotify(uint32_t newValue)
return timeout ? -1 : 0; // If we timed out, MeshService should stop iterating through observers as we just removed one
}
+
+#ifdef MESHTASTIC_PHONEAPI_ACCESS_CONTROL
+bool PhoneAPI::s_localAdminAuthorized = false;
+
+void PhoneAPI::authorizeLocalAdmin()
+{
+ s_localAdminAuthorized = true;
+ LOG_INFO("TAK: Local admin authorized via PKC");
+}
+
+bool PhoneAPI::isLocalAdminAuthorized()
+{
+ return s_localAdminAuthorized;
+}
+
+void PhoneAPI::revokeAllAuth()
+{
+ s_localAdminAuthorized = false;
+ LOG_INFO("TAK: All connection auth revoked (Lock Now)");
+}
+#endif
diff --git a/src/mesh/PhoneAPI.h b/src/mesh/PhoneAPI.h
index 7f79b5792..bfc438cd8 100644
--- a/src/mesh/PhoneAPI.h
+++ b/src/mesh/PhoneAPI.h
@@ -138,6 +138,19 @@ class PhoneAPI
bool isConnected() { return state != STATE_SEND_NOTHING; }
bool isSendingPackets() { return state == STATE_SEND_PACKETS; }
+#ifdef MESHTASTIC_PHONEAPI_ACCESS_CONTROL
+ /// Set by AdminModule after successful PKC admin key validation.
+ /// When true, config dumps include full unredacted secrets.
+ void setAdminAuthorized(bool authorized) { isAdminAuthorized = authorized; }
+ bool getAdminAuthorized() const { return isAdminAuthorized; }
+
+ /// Static: authorize all local PhoneAPI instances (called by AdminModule)
+ static void authorizeLocalAdmin();
+ static bool isLocalAdminAuthorized();
+ /// Static: immediately revoke all connection-level auth (called on Lock Now)
+ static void revokeAllAuth();
+#endif
+
protected:
/// Our fromradio packet while it is being assembled
meshtastic_FromRadio fromRadioScratch = {};
@@ -179,6 +192,11 @@ class PhoneAPI
APIType api_type = TYPE_NONE;
+#ifdef MESHTASTIC_PHONEAPI_ACCESS_CONTROL
+ bool isAdminAuthorized = false;
+ static bool s_localAdminAuthorized;
+#endif
+
private:
void releasePhonePacket();
diff --git a/src/modules/AdminModule.cpp b/src/modules/AdminModule.cpp
index 419d2b773..9df9cadad 100644
--- a/src/modules/AdminModule.cpp
+++ b/src/modules/AdminModule.cpp
@@ -24,6 +24,12 @@
#include "Default.h"
#include "TypeConversions.h"
+#ifdef MESHTASTIC_PHONEAPI_ACCESS_CONTROL
+#include "mesh/PhoneAPI.h"
+#endif
+#ifdef MESHTASTIC_ENCRYPTED_STORAGE
+#include "security/EncryptedStorage.h"
+#endif
#if !MESHTASTIC_EXCLUDE_MQTT
#include "mqtt/MQTT.h"
@@ -82,11 +88,38 @@ bool AdminModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshta
// and only allowing responses from that remote.
if (messageIsResponse(r)) {
LOG_DEBUG("Allow admin response message");
- } else if (mp.from == 0) {
+ } else if (mp.from == 0 && !mp.pki_encrypted) {
+ // Plain (non-PKC) local admin from BLE/USB client.
+ // When locked: always allow through — passphrase delivery and LOCK NOW must work.
+ // When unlocked and is_managed: block unless this is a TAK security command
+ // (passphrase re-verify / LOCK NOW) or the connection is already passphrase-authorized.
+ // from=0 + pki_encrypted is NOT matched here and falls through to the PKC check below.
+#ifdef MESHTASTIC_ENCRYPTED_STORAGE
+ if (config.security.is_managed && EncryptedStorage::isUnlocked()) {
+ // TAK security commands carry a non-empty private_key — always let them through.
+ // The passphrase HMAC in EncryptedStorage is the security gate for those packets.
+ bool isTakSecurityCmd =
+ (r->which_payload_variant == meshtastic_AdminMessage_set_config_tag &&
+ r->set_config.which_payload_variant == meshtastic_Config_security_tag &&
+ 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;
+#ifdef MESHTASTIC_PHONEAPI_ACCESS_CONTROL
+ if (!isTakSecurityCmd && !PhoneAPI::isLocalAdminAuthorized()) {
+#else
+ if (!isTakSecurityCmd) {
+#endif
+ LOG_INFO("Ignore local admin payload because is_managed");
+ myReply = allocErrorResponse(meshtastic_Routing_Error_NOT_AUTHORIZED, &mp);
+ return handled;
+ }
+ }
+#else
if (config.security.is_managed) {
LOG_INFO("Ignore local admin payload because is_managed");
return handled;
}
+#endif
} else if (strcasecmp(ch->settings.name, Channels::adminChannel) == 0) {
if (!config.security.admin_channel_enabled) {
LOG_INFO("Ignore admin channel, legacy admin is disabled");
@@ -102,6 +135,11 @@ bool AdminModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshta
memcmp(mp.public_key.bytes, config.security.admin_key[2].bytes, 32) == 0)) {
LOG_INFO("PKC admin payload with authorized sender key");
+#ifdef MESHTASTIC_PHONEAPI_ACCESS_CONTROL
+ // Authorize local PhoneAPI connections to dump full config
+ PhoneAPI::authorizeLocalAdmin();
+#endif
+
// Automatically favorite the node that is using the admin key
auto remoteNode = nodeDB->getMeshNode(mp.from);
if (remoteNode && !remoteNode->is_favorite) {
@@ -853,6 +891,101 @@ void AdminModule::handleSetConfig(const meshtastic_Config &c)
config.bluetooth = c.payload_variant.bluetooth;
break;
case meshtastic_Config_security_tag:
+#ifdef MESHTASTIC_ENCRYPTED_STORAGE
+ // set_config(security) doubles as the TAK passphrase transport (no schema changes).
+ // private_key field encodes the command:
+ // 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) {
+ LOG_INFO("AdminModule: TAK LOCK NOW command received");
+ EncryptedStorage::lockNow();
+#ifdef MESHTASTIC_PHONEAPI_ACCESS_CONTROL
+ // Immediately revoke all connection-level auth so that any config
+ // re-request in the window before reboot gets redacted data.
+ PhoneAPI::revokeAllAuth();
+#endif
+ sendWarning("TAK_LOCKED");
+ reboot(DEFAULT_REBOOT_SECONDS);
+ return; // No config changed; skip saveChanges entirely
+ }
+
+ if (ppLen >= 1 && ppLen <= 64) {
+ uint8_t boots = EncryptedStorage::TOKEN_DEFAULT_BOOTS;
+ if (sec.admin_key[1].size >= 1 && sec.admin_key[1].bytes[0] != 0)
+ boots = sec.admin_key[1].bytes[0];
+
+ uint32_t validUntilEpoch = 0;
+ if (sec.admin_key[2].size >= 4)
+ memcpy(&validUntilEpoch, sec.admin_key[2].bytes, 4);
+
+ bool ok = false;
+ if (!EncryptedStorage::isUnlocked()) {
+ // Locked: provision on first boot, otherwise unlock
+ if (!EncryptedStorage::isProvisioned()) {
+ LOG_INFO("AdminModule: TAK first-time provisioning with passphrase");
+ ok = EncryptedStorage::provisionPassphrase(pp, ppLen, boots, validUntilEpoch);
+ } else {
+ LOG_INFO("AdminModule: TAK unlock with passphrase");
+ ok = EncryptedStorage::unlockWithPassphrase(pp, ppLen, boots, validUntilEpoch);
+ }
+ if (ok) {
+#ifdef MESHTASTIC_PHONEAPI_ACCESS_CONTROL
+ PhoneAPI::authorizeLocalAdmin();
+#endif
+ nodeDB->reloadFromDisk();
+ {
+ char unlockedMsg[64];
+ snprintf(unlockedMsg, sizeof(unlockedMsg), "TAK_UNLOCKED:boots=%d:until=%u",
+ (int)EncryptedStorage::getBootsRemaining(),
+ (unsigned)EncryptedStorage::getValidUntilEpoch());
+ sendWarning(unlockedMsg);
+ }
+ LOG_INFO("AdminModule: TAK storage unlocked, config reloaded");
+ }
+ } else {
+ // Already unlocked: verify passphrase to authorize this connection for admin.
+ // unlockWithPassphrase() re-derives KEK, unwraps DEK, refreshes the token.
+ LOG_INFO("AdminModule: TAK passphrase re-verify for admin authorization");
+ ok = EncryptedStorage::unlockWithPassphrase(pp, ppLen, boots, validUntilEpoch);
+ if (ok) {
+#ifdef MESHTASTIC_PHONEAPI_ACCESS_CONTROL
+ PhoneAPI::authorizeLocalAdmin();
+#endif
+ {
+ char unlockedMsg[64];
+ snprintf(unlockedMsg, sizeof(unlockedMsg), "TAK_UNLOCKED:boots=%d:until=%u",
+ (int)EncryptedStorage::getBootsRemaining(),
+ (unsigned)EncryptedStorage::getValidUntilEpoch());
+ sendWarning(unlockedMsg);
+ }
+ LOG_INFO("AdminModule: TAK passphrase verified, local admin authorized");
+ }
+ }
+
+ if (!ok) {
+ uint32_t backoff = EncryptedStorage::getBackoffSecondsRemaining();
+ char failMsg[64];
+ if (backoff > 0)
+ snprintf(failMsg, sizeof(failMsg), "TAK_UNLOCK_FAILED:backoff=%u", backoff);
+ else
+ snprintf(failMsg, sizeof(failMsg), "TAK_UNLOCK_FAILED:wrong_passphrase");
+ sendWarning(failMsg);
+ LOG_WARN("AdminModule: TAK passphrase verification failed");
+ }
+ return; // Passphrase handled; no config changed, skip saveChanges
+ }
+ }
+ // No passphrase (private_key absent or empty): fall through to normal security config.
+ // Reachable only when is_managed=false or device is locked (gate allows it in those states).
+#endif
LOG_INFO("Set config: Security");
config.security = c.payload_variant.security;
#if !(MESHTASTIC_EXCLUDE_PKI_KEYGEN) && !(MESHTASTIC_EXCLUDE_PKI)
diff --git a/src/security/APProtect.cpp b/src/security/APProtect.cpp
new file mode 100644
index 000000000..0011a6236
--- /dev/null
+++ b/src/security/APProtect.cpp
@@ -0,0 +1,43 @@
+#include "configuration.h"
+
+#ifdef MESHTASTIC_ENABLE_APPROTECT
+#ifdef ARCH_NRF52
+
+#include "APProtect.h"
+#include
+
+void enableAPProtect()
+{
+ // APPROTECT register: 0x00 = enabled (protected), 0xFF = disabled (open)
+ // On nRF52840, UICR.APPROTECT at address 0x10001208
+ if (NRF_UICR->APPROTECT != 0x00) {
+ LOG_WARN("Enabling APPROTECT - debug port will be disabled after reset");
+
+ // UICR writes require NVMC to be in write mode
+ NRF_NVMC->CONFIG = NVMC_CONFIG_WEN_Wen;
+ while (NRF_NVMC->READY == NVMC_READY_READY_Busy)
+ ;
+
+ NRF_UICR->APPROTECT = 0x00;
+ while (NRF_NVMC->READY == NVMC_READY_READY_Busy)
+ ;
+
+ // Return NVMC to read-only mode
+ NRF_NVMC->CONFIG = NVMC_CONFIG_WEN_Ren;
+ while (NRF_NVMC->READY == NVMC_READY_READY_Busy)
+ ;
+
+ LOG_INFO("APPROTECT set, will take effect after next reset");
+ } else {
+ LOG_DEBUG("APPROTECT already enabled");
+ }
+}
+
+#else
+// Non-nRF52 builds - no-op
+void enableAPProtect()
+{
+ LOG_DEBUG("APPROTECT not supported on this platform");
+}
+#endif // ARCH_NRF52
+#endif // MESHTASTIC_ENABLE_APPROTECT
diff --git a/src/security/APProtect.h b/src/security/APProtect.h
new file mode 100644
index 000000000..f38ddb441
--- /dev/null
+++ b/src/security/APProtect.h
@@ -0,0 +1,16 @@
+#pragma once
+
+#ifdef MESHTASTIC_ENABLE_APPROTECT
+
+/**
+ * Enable APPROTECT on nRF52840 to block debug probe access.
+ * Writes NRF_UICR->APPROTECT = 0x00 if not already set.
+ * Must be called early in setup() before any sensitive data is loaded.
+ *
+ * WARNING: This is a one-way operation. Once APPROTECT is enabled,
+ * the debug port is permanently disabled. The device can still be
+ * re-flashed via the bootloader (USB/DFU), but SWD/JTAG access is blocked.
+ */
+void enableAPProtect();
+
+#endif // MESHTASTIC_ENABLE_APPROTECT
diff --git a/src/security/EncryptedStorage.cpp b/src/security/EncryptedStorage.cpp
new file mode 100644
index 000000000..bf0973142
--- /dev/null
+++ b/src/security/EncryptedStorage.cpp
@@ -0,0 +1,1442 @@
+#include "configuration.h"
+
+#ifdef MESHTASTIC_ENCRYPTED_STORAGE
+#ifdef ARCH_NRF52
+
+#include "EncryptedStorage.h"
+#include "FSCommon.h"
+#include "RTC.h"
+#include "SPILock.h"
+#include
+#include
+
+// CC310 hardware AES and HMAC APIs
+extern "C" {
+#include "nrf_cc310/include/crys_hmac.h"
+#include "nrf_cc310/include/ssi_aes.h"
+#include "nrf_cc310/include/ssi_aes_defs.h"
+}
+
+namespace EncryptedStorage {
+
+// ---------------------------------------------------------------------------
+// File paths and domain-separation strings
+// ---------------------------------------------------------------------------
+
+static const char *DEK_FILENAME = "/prefs/.dek";
+static const char *TOKEN_FILENAME = "/prefs/.unlock_token";
+static const char *BACKOFF_FILENAME = "/prefs/.backoff";
+
+// v1 (legacy): FICR-only KEK, no passphrase
+static const char *KEK_DOMAIN_V1 = "meshtastic-tak-kek-v1";
+// v2: passphrase-mixed KEK
+static const char *KEK_DOMAIN_V2 = "meshtastic-tak-kek-v2";
+// Ephemeral: FICR-only, used only to wrap DEK inside the unlock token
+static const char *EPHEMERAL_KEK_DOMAIN = "meshtastic-tak-ephemeral-v1";
+// HMAC auth label for DEK v2 file
+static const char *DEK_AUTH_LABEL = "mdek-auth";
+
+// ---------------------------------------------------------------------------
+// Module-level key state
+// ---------------------------------------------------------------------------
+
+// Passphrase-mixed KEK (v2), derived on provision/unlock
+static uint8_t kek[AES_KEY_SIZE];
+static bool kekDerived = false;
+
+// FICR-only ephemeral KEK for token wrapping/unwrapping
+static uint8_t ephemeralKek[AES_KEY_SIZE];
+static bool ephemeralKekDerived = false;
+
+// Data Encryption Key — loaded from /prefs/.dek, never stored in plaintext
+static uint8_t dek[AES_KEY_SIZE];
+static bool dekLoaded = false;
+
+// Reason the device is currently locked (set in initLocked / readAndConsumeToken)
+static const char *lockReason = "not_provisioned";
+
+// Token state after successful unlock — exposed via getBootsRemaining() / getValidUntilEpoch()
+static uint8_t s_bootsRemaining = 0;
+static uint32_t s_validUntilEpoch = 0;
+
+// Backoff state — seconds remaining before next passphrase attempt is allowed
+static uint32_t s_backoffSecondsRemaining = 0;
+
+// ---------------------------------------------------------------------------
+// Passphrase attempt backoff helpers
+// ---------------------------------------------------------------------------
+
+// Returns the delay in seconds for a given number of failed attempts.
+// Schedule: 5, 10, 20, 40, 80, 160, 320, 900 (capped)
+static uint32_t backoffDelay(uint8_t attempts)
+{
+ if (attempts == 0) return 0;
+ uint32_t delay = 5u;
+ for (uint8_t i = 1; i < attempts; i++) {
+ delay *= 2;
+ if (delay >= 900) return 900;
+ }
+ return delay;
+}
+
+// Read attempt count and last-fail epoch from /prefs/.backoff (5 bytes).
+// Returns silently on missing or corrupt file (treated as zero attempts).
+static void readBackoff(uint8_t &attempts, uint32_t &lastFail)
+{
+ attempts = 0;
+ lastFail = 0;
+#ifdef FSCom
+ concurrency::LockGuard g(spiLock);
+ auto f = FSCom.open(BACKOFF_FILENAME, FILE_O_READ);
+ if (!f || f.size() < 5) {
+ if (f) f.close();
+ return;
+ }
+ attempts = f.read();
+ uint8_t buf[4] = {0, 0, 0, 0};
+ size_t n = f.read(buf, 4);
+ f.close();
+ if (n != 4) {
+ attempts = 0; // Corrupt backoff file — treat as no backoff
+ return;
+ }
+ memcpy(&lastFail, buf, 4);
+#endif
+}
+
+// Write attempt count and last-fail epoch to /prefs/.backoff.
+static void writeBackoff(uint8_t attempts, uint32_t lastFail)
+{
+#ifdef FSCom
+ concurrency::LockGuard g(spiLock);
+ FSCom.remove(BACKOFF_FILENAME);
+ auto f = FSCom.open(BACKOFF_FILENAME, FILE_O_WRITE);
+ if (!f) return;
+ f.write(attempts);
+ uint8_t buf[4];
+ memcpy(buf, &lastFail, 4);
+ f.write(buf, 4);
+ f.close();
+#endif
+}
+
+// Delete the backoff file and clear in-RAM state on successful unlock.
+static void clearBackoff()
+{
+#ifdef FSCom
+ concurrency::LockGuard g(spiLock);
+ FSCom.remove(BACKOFF_FILENAME);
+#endif
+ s_backoffSecondsRemaining = 0;
+}
+
+// ---------------------------------------------------------------------------
+// Internal helpers: FICR data extraction
+// ---------------------------------------------------------------------------
+
+static void readFICR(uint8_t efuseData[16])
+{
+ // Copy FICR registers to local vars before memcpy (registers are volatile)
+ uint32_t tmp;
+ tmp = NRF_FICR->DEVICEID[0];
+ memcpy(efuseData, &tmp, 4);
+ tmp = NRF_FICR->DEVICEID[1];
+ memcpy(efuseData + 4, &tmp, 4);
+ tmp = NRF_FICR->DEVICEADDR[0];
+ memcpy(efuseData + 8, &tmp, 4);
+ tmp = NRF_FICR->DEVICEADDR[1];
+ memcpy(efuseData + 12, &tmp, 4);
+}
+
+// ---------------------------------------------------------------------------
+// Internal helpers: CC310 crypto primitives
+// ---------------------------------------------------------------------------
+
+/// AES-128-CTR encrypt/decrypt (symmetric). Caller holds CC310.
+static bool aesCtr128(const uint8_t *key, const uint8_t *nonce, size_t nonceLen,
+ const uint8_t *input, size_t inputLen, uint8_t *output)
+{
+ if (inputLen == 0)
+ return true;
+
+ SaSiAesUserContext_t ctx;
+ SaSiAesUserKeyData_t keyData;
+ SaSiAesIv_t iv;
+
+ memset(iv, 0, sizeof(iv));
+ size_t copyLen = (nonceLen < sizeof(iv)) ? nonceLen : sizeof(iv);
+ memcpy(iv, nonce, copyLen);
+
+ SaSiError_t err = SaSi_AesInit(&ctx, SASI_AES_ENCRYPT, SASI_AES_MODE_CTR, SASI_AES_PADDING_NONE);
+ if (err != 0) {
+ LOG_ERROR("EncryptedStorage: AES init failed: 0x%x", err);
+ return false;
+ }
+
+ keyData.pKey = (uint8_t *)key;
+ keyData.keySize = AES_KEY_SIZE;
+ err = SaSi_AesSetKey(&ctx, SASI_AES_USER_KEY, &keyData, sizeof(keyData));
+ if (err != 0) {
+ LOG_ERROR("EncryptedStorage: AES setkey failed: 0x%x", err);
+ SaSi_AesFree(&ctx);
+ return false;
+ }
+
+ err = SaSi_AesSetIv(&ctx, iv);
+ if (err != 0) {
+ LOG_ERROR("EncryptedStorage: AES setiv failed: 0x%x", err);
+ SaSi_AesFree(&ctx);
+ return false;
+ }
+
+ size_t processed = 0;
+ size_t fullBlocks = (inputLen / AES_BLOCK_SIZE) * AES_BLOCK_SIZE;
+ if (fullBlocks > 0) {
+ err = SaSi_AesBlock(&ctx, (uint8_t *)input, fullBlocks, output);
+ if (err != 0) {
+ LOG_ERROR("EncryptedStorage: AES block failed: 0x%x", err);
+ SaSi_AesFree(&ctx);
+ return false;
+ }
+ processed = fullBlocks;
+ }
+
+ size_t remaining = inputLen - processed;
+ size_t finishOutSize = remaining;
+ err = SaSi_AesFinish(&ctx, remaining, (uint8_t *)input + processed, remaining, output + processed,
+ &finishOutSize);
+ if (err != 0) {
+ LOG_ERROR("EncryptedStorage: AES finish failed: 0x%x", err);
+ SaSi_AesFree(&ctx);
+ return false;
+ }
+
+ SaSi_AesFree(&ctx);
+ return true;
+}
+
+/// Compute HMAC-SHA256(key, data). Caller holds CC310.
+static bool computeHMAC(const uint8_t *key, size_t keyLen, const uint8_t *data, size_t dataLen,
+ uint8_t *hmacOut)
+{
+ CRYS_HASH_Result_t hmacResult;
+ CRYSError_t err =
+ CRYS_HMAC(CRYS_HASH_SHA256_mode, (uint8_t *)key, (uint16_t)keyLen, (uint8_t *)data, dataLen, hmacResult);
+ if (err != 0) {
+ LOG_ERROR("EncryptedStorage: CRYS_HMAC failed: 0x%x", err);
+ return false;
+ }
+ memcpy(hmacOut, hmacResult, HMAC_SIZE);
+ return true;
+}
+
+/// Constant-time memory comparison (avoids timing side-channels on HMAC compare).
+static bool constTimeEq(const uint8_t *a, const uint8_t *b, size_t len)
+{
+ uint8_t diff = 0;
+ for (size_t i = 0; i < len; i++)
+ diff |= a[i] ^ b[i];
+ return diff == 0;
+}
+
+// ---------------------------------------------------------------------------
+// Internal helpers: KEK derivation
+// ---------------------------------------------------------------------------
+
+/**
+ * Derive the passphrase-mixed KEK (v2) and store in module-level kek[].
+ * SHA-256("device-efuse-data" || FICR_16 || passphrase || KEK_DOMAIN_V2) → first 16 bytes.
+ * Caller must hold CC310 (nRFCrypto.begin()).
+ */
+static bool deriveKEKv2(const uint8_t *passphrase, size_t passphraseLen)
+{
+ uint8_t efuseData[16];
+ readFICR(efuseData);
+
+ static const char *prefix = "device-efuse-data";
+ uint8_t sha256Result[32];
+
+ nRFCrypto_Hash hash;
+ if (!hash.begin(CRYS_HASH_SHA256_mode)) {
+ LOG_ERROR("EncryptedStorage: SHA-256 init failed (KEK v2)");
+ memset(efuseData, 0, sizeof(efuseData));
+ return false;
+ }
+ hash.update((uint8_t *)prefix, strlen(prefix));
+ hash.update(efuseData, sizeof(efuseData));
+ hash.update((uint8_t *)passphrase, passphraseLen);
+ hash.update((uint8_t *)KEK_DOMAIN_V2, strlen(KEK_DOMAIN_V2));
+ hash.end(sha256Result);
+
+ memcpy(kek, sha256Result, AES_KEY_SIZE);
+ memset(sha256Result, 0, sizeof(sha256Result));
+ memset(efuseData, 0, sizeof(efuseData));
+
+ kekDerived = true;
+ return true;
+}
+
+/**
+ * Derive the legacy FICR-only KEK (v1) into the module-level kek[].
+ * Used only for v1 DEK migration; prefer deriveKEKv2() for all new operations.
+ * Caller must hold CC310.
+ */
+static bool deriveKEKv1()
+{
+ uint8_t efuseData[16];
+ readFICR(efuseData);
+
+ static const char *prefix = "device-efuse-data";
+ uint8_t sha256Result[32];
+
+ nRFCrypto_Hash hash;
+ if (!hash.begin(CRYS_HASH_SHA256_mode)) {
+ LOG_ERROR("EncryptedStorage: SHA-256 init failed (KEK v1)");
+ memset(efuseData, 0, sizeof(efuseData));
+ return false;
+ }
+ hash.update((uint8_t *)prefix, strlen(prefix));
+ hash.update(efuseData, sizeof(efuseData));
+ hash.update((uint8_t *)KEK_DOMAIN_V1, strlen(KEK_DOMAIN_V1));
+ hash.end(sha256Result);
+
+ memcpy(kek, sha256Result, AES_KEY_SIZE);
+ memset(sha256Result, 0, sizeof(sha256Result));
+ memset(efuseData, 0, sizeof(efuseData));
+
+ kekDerived = true;
+ return true;
+}
+
+/**
+ * Derive the ephemeral KEK (FICR-only, separate domain) into ephemeralKek[].
+ * Used only for wrapping/unwrapping the unlock token.
+ * Caller must hold CC310.
+ */
+static bool deriveEphemeralKEK()
+{
+ if (ephemeralKekDerived)
+ return true;
+
+ uint8_t efuseData[16];
+ readFICR(efuseData);
+
+ static const char *prefix = "device-efuse-data";
+ uint8_t sha256Result[32];
+
+ nRFCrypto_Hash hash;
+ if (!hash.begin(CRYS_HASH_SHA256_mode)) {
+ LOG_ERROR("EncryptedStorage: SHA-256 init failed (ephemeral KEK)");
+ memset(efuseData, 0, sizeof(efuseData));
+ return false;
+ }
+ hash.update((uint8_t *)prefix, strlen(prefix));
+ hash.update(efuseData, sizeof(efuseData));
+ hash.update((uint8_t *)EPHEMERAL_KEK_DOMAIN, strlen(EPHEMERAL_KEK_DOMAIN));
+ hash.end(sha256Result);
+
+ memcpy(ephemeralKek, sha256Result, AES_KEY_SIZE);
+ memset(sha256Result, 0, sizeof(sha256Result));
+ memset(efuseData, 0, sizeof(efuseData));
+
+ ephemeralKekDerived = true;
+ return true;
+}
+
+// ---------------------------------------------------------------------------
+// Internal helpers: DEK file I/O
+// ---------------------------------------------------------------------------
+
+/**
+ * Load the DEK from a legacy v1 DEK file using the given KEK (must be in kek[]).
+ * Format: [13B nonce][16B AES-CTR(KEK, nonce, DEK)] — 29 bytes, no HMAC.
+ * Returns true on read success (cannot verify correctness — no HMAC in v1 format).
+ */
+static bool loadDEKv1()
+{
+#ifdef FSCom
+ concurrency::LockGuard g(spiLock);
+ auto f = FSCom.open(DEK_FILENAME, FILE_O_READ);
+ if (!f)
+ return false;
+
+ size_t fileSize = f.size();
+ if (fileSize != DEK_V1_SIZE) {
+ f.close();
+ return false;
+ }
+
+ uint8_t buf[DEK_V1_SIZE];
+ size_t bytesRead = f.read(buf, sizeof(buf));
+ f.close();
+
+ if (bytesRead != DEK_V1_SIZE) {
+ LOG_ERROR("EncryptedStorage: v1 DEK short read");
+ return false;
+ }
+
+ nRFCrypto.begin();
+ bool decOk = aesCtr128(kek, buf, NONCE_SIZE, buf + NONCE_SIZE, AES_KEY_SIZE, dek);
+ nRFCrypto.end();
+
+ if (!decOk) {
+ LOG_ERROR("EncryptedStorage: v1 DEK decrypt failed");
+ memset(dek, 0, sizeof(dek));
+ memset(buf, 0, sizeof(buf));
+ return false;
+ }
+
+ memset(buf, 0, sizeof(buf));
+ LOG_INFO("EncryptedStorage: v1 DEK loaded (migration candidate)");
+ return true;
+#else
+ return false;
+#endif
+}
+
+/**
+ * Load DEK from v2 file using the current kek[].
+ * Verifies HMAC before returning the DEK.
+ */
+static bool loadDEKv2()
+{
+#ifdef FSCom
+ concurrency::LockGuard g(spiLock);
+ auto f = FSCom.open(DEK_FILENAME, FILE_O_READ);
+ if (!f)
+ return false;
+
+ size_t fileSize = f.size();
+ if (fileSize != DEK_V2_SIZE) {
+ f.close();
+ return false;
+ }
+
+ uint8_t buf[DEK_V2_SIZE];
+ size_t bytesRead = f.read(buf, sizeof(buf));
+ f.close();
+
+ if (bytesRead != DEK_V2_SIZE) {
+ LOG_ERROR("EncryptedStorage: v2 DEK short read");
+ return false;
+ }
+
+ // Check magic and version
+ uint32_t magic;
+ memcpy(&magic, buf, 4);
+ if (magic != DEK_MAGIC_V2 || buf[4] != DEK_VERSION_V2) {
+ LOG_ERROR("EncryptedStorage: v2 DEK bad magic/version");
+ return false;
+ }
+
+ uint8_t *nonce = buf + 5;
+ uint8_t *encDek = buf + 5 + NONCE_SIZE;
+
+ // Verify HMAC-SHA256(KEK, DEK_AUTH_LABEL || nonce || encDEK)
+ size_t authLabelLen = strlen(DEK_AUTH_LABEL);
+ size_t hmacInputLen = authLabelLen + NONCE_SIZE + AES_KEY_SIZE;
+ uint8_t *hmacInput = new uint8_t[hmacInputLen];
+ if (!hmacInput) {
+ LOG_ERROR("EncryptedStorage: OOM for DEK HMAC verify");
+ return false;
+ }
+ memcpy(hmacInput, DEK_AUTH_LABEL, authLabelLen);
+ memcpy(hmacInput + authLabelLen, nonce, NONCE_SIZE);
+ memcpy(hmacInput + authLabelLen + NONCE_SIZE, encDek, AES_KEY_SIZE);
+
+ uint8_t expectedHmac[HMAC_SIZE];
+ nRFCrypto.begin();
+ bool hmacOk = computeHMAC(kek, AES_KEY_SIZE, hmacInput, hmacInputLen, expectedHmac);
+ nRFCrypto.end();
+ memset(hmacInput, 0, hmacInputLen);
+ delete[] hmacInput;
+
+ if (!hmacOk) {
+ return false;
+ }
+
+ const uint8_t *storedHmac = buf + DEK_V2_SIZE - HMAC_SIZE;
+ if (!constTimeEq(expectedHmac, storedHmac, HMAC_SIZE)) {
+ LOG_ERROR("EncryptedStorage: v2 DEK HMAC mismatch — wrong passphrase or tampered file");
+ memset(expectedHmac, 0, sizeof(expectedHmac));
+ return false;
+ }
+
+ // Decrypt DEK into a local candidate — only write to global dek[] on success so that
+ // a wrong passphrase attempt does not destroy the live DEK in RAM.
+ uint8_t dekCandidate[AES_KEY_SIZE];
+ nRFCrypto.begin();
+ bool decOk = aesCtr128(kek, nonce, NONCE_SIZE, encDek, AES_KEY_SIZE, dekCandidate);
+ nRFCrypto.end();
+
+ memset(expectedHmac, 0, sizeof(expectedHmac));
+
+ if (!decOk) {
+ LOG_ERROR("EncryptedStorage: v2 DEK decrypt failed");
+ memset(dekCandidate, 0, sizeof(dekCandidate));
+ return false;
+ }
+
+ memcpy(dek, dekCandidate, AES_KEY_SIZE);
+ memset(dekCandidate, 0, sizeof(dekCandidate));
+
+ LOG_INFO("EncryptedStorage: v2 DEK loaded and verified");
+ return true;
+#else
+ return false;
+#endif
+}
+
+/**
+ * Save the current in-RAM dek[] to disk as a v2 DEK file, wrapped with kek[].
+ * Overwrites any existing DEK file.
+ */
+static bool saveDEKv2()
+{
+#ifdef FSCom
+ // Generate random nonce
+ uint8_t nonce[NONCE_SIZE];
+ nRFCrypto.begin();
+ if (!nRFCrypto.Random.generate(nonce, NONCE_SIZE)) {
+ LOG_ERROR("EncryptedStorage: TRNG failed for DEK nonce");
+ nRFCrypto.end();
+ return false;
+ }
+
+ // Encrypt DEK with KEK
+ uint8_t encDek[AES_KEY_SIZE];
+ bool encOk = aesCtr128(kek, nonce, NONCE_SIZE, dek, AES_KEY_SIZE, encDek);
+ if (!encOk) {
+ LOG_ERROR("EncryptedStorage: DEK encrypt failed");
+ nRFCrypto.end();
+ memset(encDek, 0, sizeof(encDek));
+ return false;
+ }
+
+ // Compute HMAC-SHA256(KEK, DEK_AUTH_LABEL || nonce || encDEK)
+ size_t authLabelLen = strlen(DEK_AUTH_LABEL);
+ size_t hmacInputLen = authLabelLen + NONCE_SIZE + AES_KEY_SIZE;
+ uint8_t *hmacInput = new uint8_t[hmacInputLen];
+ if (!hmacInput) {
+ LOG_ERROR("EncryptedStorage: OOM for DEK HMAC");
+ nRFCrypto.end();
+ return false;
+ }
+ memcpy(hmacInput, DEK_AUTH_LABEL, authLabelLen);
+ memcpy(hmacInput + authLabelLen, nonce, NONCE_SIZE);
+ memcpy(hmacInput + authLabelLen + NONCE_SIZE, encDek, AES_KEY_SIZE);
+
+ uint8_t hmac[HMAC_SIZE];
+ bool hmacOk = computeHMAC(kek, AES_KEY_SIZE, hmacInput, hmacInputLen, hmac);
+ nRFCrypto.end();
+ memset(hmacInput, 0, hmacInputLen);
+ delete[] hmacInput;
+
+ if (!hmacOk) {
+ memset(encDek, 0, sizeof(encDek));
+ return false;
+ }
+
+ // Write file: magic(4)+version(1)+nonce(13)+encDEK(16)+hmac(32) = 66 bytes
+ concurrency::LockGuard g(spiLock);
+ FSCom.remove(DEK_FILENAME); // prevent O_APPEND accumulation on nRF52
+ auto f = FSCom.open(DEK_FILENAME, FILE_O_WRITE);
+ if (!f) {
+ LOG_ERROR("EncryptedStorage: Can't write DEK file");
+ memset(encDek, 0, sizeof(encDek));
+ return false;
+ }
+
+ uint32_t magic = DEK_MAGIC_V2;
+ uint8_t ver = DEK_VERSION_V2;
+ f.write((uint8_t *)&magic, 4);
+ f.write(&ver, 1);
+ f.write(nonce, NONCE_SIZE);
+ f.write(encDek, AES_KEY_SIZE);
+ f.write(hmac, HMAC_SIZE);
+ f.flush();
+ f.close();
+
+ memset(nonce, 0, sizeof(nonce));
+ memset(encDek, 0, sizeof(encDek));
+ memset(hmac, 0, sizeof(hmac));
+
+ LOG_INFO("EncryptedStorage: DEK saved (v2 format)");
+ return true;
+#else
+ return false;
+#endif
+}
+
+// ---------------------------------------------------------------------------
+// Internal helpers: unlock token I/O
+// ---------------------------------------------------------------------------
+
+/**
+ * Write a new unlock token to TOKEN_FILENAME.
+ * Wraps the current in-RAM dek[] with ephemeralKek[].
+ * @param bootsRemaining Number of boots this token grants
+ * @param validUntilEpoch Unix timestamp after which token is invalid (0 = no limit)
+ */
+static bool writeUnlockToken(uint8_t bootsRemaining, uint32_t validUntilEpoch)
+{
+#ifdef FSCom
+ uint8_t nonce[NONCE_SIZE];
+ nRFCrypto.begin();
+ if (!nRFCrypto.Random.generate(nonce, NONCE_SIZE)) {
+ LOG_ERROR("EncryptedStorage: TRNG failed for token nonce");
+ nRFCrypto.end();
+ return false;
+ }
+
+ uint8_t encDek[AES_KEY_SIZE];
+ bool encOk = aesCtr128(ephemeralKek, nonce, NONCE_SIZE, dek, AES_KEY_SIZE, encDek);
+ if (!encOk) {
+ LOG_ERROR("EncryptedStorage: Token DEK encrypt failed");
+ nRFCrypto.end();
+ memset(encDek, 0, sizeof(encDek));
+ return false;
+ }
+
+ // Build body for HMAC (everything before the trailing HMAC)
+ uint8_t body[TOKEN_BODY_SIZE];
+ size_t pos = 0;
+ uint32_t magic = TOKEN_MAGIC;
+ memcpy(body + pos, &magic, 4);
+ pos += 4;
+ body[pos++] = TOKEN_VERSION;
+ memcpy(body + pos, nonce, NONCE_SIZE);
+ pos += NONCE_SIZE;
+ memcpy(body + pos, encDek, AES_KEY_SIZE);
+ pos += AES_KEY_SIZE;
+ body[pos++] = bootsRemaining;
+ memcpy(body + pos, &validUntilEpoch, 4);
+ pos += 4;
+
+ uint8_t hmac[HMAC_SIZE];
+ bool hmacOk = computeHMAC(ephemeralKek, AES_KEY_SIZE, body, TOKEN_BODY_SIZE, hmac);
+ nRFCrypto.end();
+
+ memset(encDek, 0, sizeof(encDek));
+
+ if (!hmacOk) {
+ memset(body, 0, sizeof(body));
+ return false;
+ }
+
+ concurrency::LockGuard g(spiLock);
+ FSCom.remove(TOKEN_FILENAME); // prevent O_APPEND accumulation on nRF52
+ auto f = FSCom.open(TOKEN_FILENAME, FILE_O_WRITE);
+ if (!f) {
+ LOG_ERROR("EncryptedStorage: Can't write token file");
+ memset(body, 0, sizeof(body));
+ return false;
+ }
+
+ f.write(body, TOKEN_BODY_SIZE);
+ f.write(hmac, HMAC_SIZE);
+ f.flush();
+ f.close();
+
+ memset(body, 0, sizeof(body));
+ memset(hmac, 0, sizeof(hmac));
+
+ LOG_INFO("EncryptedStorage: Unlock token written (boots=%d, epoch=%u)", bootsRemaining,
+ validUntilEpoch);
+ return true;
+#else
+ return false;
+#endif
+}
+
+/**
+ * Read, validate, and consume the unlock token.
+ * If valid: decrypts DEK into dek[], decrements boot count, rewrites token (or deletes if boots==0).
+ * Returns true if the token was valid and DEK was loaded.
+ */
+static bool readAndConsumeToken()
+{
+#ifdef FSCom
+ // Read the token file
+ uint8_t buf[TOKEN_TOTAL_SIZE];
+ {
+ concurrency::LockGuard g(spiLock);
+ auto f = FSCom.open(TOKEN_FILENAME, FILE_O_READ);
+ if (!f)
+ return false;
+
+ size_t fileSize = f.size();
+ if (fileSize != TOKEN_TOTAL_SIZE) {
+ f.close();
+ LOG_WARN("EncryptedStorage: Token file wrong size (%d), deleting", fileSize);
+ FSCom.remove(TOKEN_FILENAME);
+ lockReason = "token_wrong_size";
+ return false;
+ }
+
+ size_t bytesRead = f.read(buf, TOKEN_TOTAL_SIZE);
+ f.close();
+
+ if (bytesRead != TOKEN_TOTAL_SIZE) {
+ LOG_ERROR("EncryptedStorage: Token short read");
+ FSCom.remove(TOKEN_FILENAME);
+ return false;
+ }
+ }
+
+ // Verify magic and version
+ uint32_t magic;
+ memcpy(&magic, buf, 4);
+ if (magic != TOKEN_MAGIC || buf[4] != TOKEN_VERSION) {
+ LOG_ERROR("EncryptedStorage: Token bad magic/version, deleting");
+ concurrency::LockGuard g(spiLock);
+ FSCom.remove(TOKEN_FILENAME);
+ lockReason = "token_bad_magic";
+ return false;
+ }
+
+ // Verify HMAC-SHA256(ephemeralKek, body)
+ uint8_t computedHmac[HMAC_SIZE];
+ nRFCrypto.begin();
+ bool hmacOk = computeHMAC(ephemeralKek, AES_KEY_SIZE, buf, TOKEN_BODY_SIZE, computedHmac);
+ nRFCrypto.end();
+
+ if (!hmacOk || !constTimeEq(computedHmac, buf + TOKEN_BODY_SIZE, HMAC_SIZE)) {
+ LOG_ERROR("EncryptedStorage: Token HMAC failed — tampered or wrong device, deleting");
+ memset(computedHmac, 0, sizeof(computedHmac));
+ concurrency::LockGuard g(spiLock);
+ FSCom.remove(TOKEN_FILENAME);
+ lockReason = "token_hmac_fail";
+ return false;
+ }
+ memset(computedHmac, 0, sizeof(computedHmac));
+
+ // Parse fields from body
+ size_t pos = 4 + 1; // skip magic + version
+ uint8_t *nonce = buf + pos;
+ pos += NONCE_SIZE;
+ uint8_t *encDek = buf + pos;
+ pos += AES_KEY_SIZE;
+ uint8_t bootsRemaining = buf[pos++];
+ uint32_t validUntilEpoch;
+ memcpy(&validUntilEpoch, buf + pos, 4);
+
+ // Check boot count
+ if (bootsRemaining == 0) {
+ LOG_WARN("EncryptedStorage: Token boot count exhausted, deleting");
+ concurrency::LockGuard g(spiLock);
+ FSCom.remove(TOKEN_FILENAME);
+ lockReason = "token_boots_zero";
+ return false;
+ }
+
+ // Check time expiry (only if we have a valid RTC)
+ if (validUntilEpoch != 0) {
+ uint32_t now = getTime(); // from RTC.h; returns 0 if not set
+ if (now != 0 && now > validUntilEpoch) {
+ LOG_WARN("EncryptedStorage: Token expired (now=%u, until=%u), deleting", now,
+ validUntilEpoch);
+ concurrency::LockGuard g(spiLock);
+ FSCom.remove(TOKEN_FILENAME);
+ lockReason = "token_expired";
+ return false;
+ }
+ }
+
+ // Decrypt DEK from token
+ nRFCrypto.begin();
+ bool decOk = aesCtr128(ephemeralKek, nonce, NONCE_SIZE, encDek, AES_KEY_SIZE, dek);
+ nRFCrypto.end();
+
+ if (!decOk) {
+ LOG_ERROR("EncryptedStorage: Token DEK decrypt failed");
+ memset(dek, 0, sizeof(dek));
+ concurrency::LockGuard g(spiLock);
+ FSCom.remove(TOKEN_FILENAME);
+ lockReason = "token_dek_fail";
+ return false;
+ }
+
+ // Decrement boot count and rewrite (or delete if now zero)
+ uint8_t newBoots = bootsRemaining - 1;
+ if (newBoots == 0) {
+ LOG_INFO("EncryptedStorage: Token last boot consumed, deleting");
+ concurrency::LockGuard g(spiLock);
+ FSCom.remove(TOKEN_FILENAME);
+ } else {
+ writeUnlockToken(newBoots, validUntilEpoch);
+ }
+
+ dekLoaded = true;
+ s_bootsRemaining = newBoots;
+ s_validUntilEpoch = validUntilEpoch;
+ LOG_INFO("EncryptedStorage: Token valid, DEK loaded (%d boots remaining)", newBoots);
+ return true;
+#else
+ return false;
+#endif
+}
+
+// ---------------------------------------------------------------------------
+// Internal helpers: DEK generation
+// ---------------------------------------------------------------------------
+
+static bool generateDEK()
+{
+ nRFCrypto.begin();
+ bool ok = nRFCrypto.Random.generate(dek, AES_KEY_SIZE);
+ nRFCrypto.end();
+
+ if (!ok) {
+ LOG_ERROR("EncryptedStorage: TRNG failed generating DEK");
+ memset(dek, 0, sizeof(dek));
+ return false;
+ }
+ return true;
+}
+
+// ---------------------------------------------------------------------------
+// Public API: passphrase-gated boot
+// ---------------------------------------------------------------------------
+
+void initLocked()
+{
+ nRFCrypto.begin();
+ bool ephOk = deriveEphemeralKEK();
+ nRFCrypto.end();
+
+ if (!ephOk) {
+ LOG_ERROR("EncryptedStorage: Ephemeral KEK derivation failed");
+ return;
+ }
+
+ // Attempt to unlock via stored token
+ if (!isProvisioned()) {
+ lockReason = "not_provisioned";
+ } else {
+#ifdef FSCom
+ {
+ concurrency::LockGuard g(spiLock);
+ if (!FSCom.exists(TOKEN_FILENAME))
+ lockReason = "token_missing";
+ }
+#endif
+ }
+
+ if (readAndConsumeToken()) {
+ lockReason = "ok";
+ LOG_INFO("EncryptedStorage: Unlocked via token");
+ return;
+ }
+
+ // Not unlocked — log the device state for the operator
+ if (isProvisioned()) {
+ LOG_WARN("EncryptedStorage: Device LOCKED — reason: %s", lockReason);
+ } else {
+ LOG_WARN("EncryptedStorage: Device NOT PROVISIONED — operator must set passphrase");
+ }
+}
+
+const char *getLockReason()
+{
+ return lockReason;
+}
+
+uint8_t getBootsRemaining()
+{
+ return s_bootsRemaining;
+}
+
+uint32_t getValidUntilEpoch()
+{
+ return s_validUntilEpoch;
+}
+
+uint32_t getBackoffSecondsRemaining()
+{
+ return s_backoffSecondsRemaining;
+}
+
+bool provisionPassphrase(const uint8_t *passphrase, size_t passphraseLen, uint8_t bootsRemaining,
+ uint32_t validUntilEpoch)
+{
+ if (passphraseLen == 0 || passphraseLen > 64) {
+ LOG_ERROR("EncryptedStorage: Invalid passphrase length %d", passphraseLen);
+ return false;
+ }
+
+ if (!ephemeralKekDerived) {
+ nRFCrypto.begin();
+ bool ok = deriveEphemeralKEK();
+ nRFCrypto.end();
+ if (!ok)
+ return false;
+ }
+
+ // If a v1 DEK file exists, migrate it rather than generating a fresh DEK.
+ bool migrated = false;
+ if (isProvisioned()) {
+ nRFCrypto.begin();
+ bool v1ok = deriveKEKv1();
+ nRFCrypto.end();
+ if (v1ok && loadDEKv1()) {
+ migrated = true;
+ LOG_INFO("EncryptedStorage: Migrating v1 DEK to v2 under passphrase-mixed KEK");
+ } else {
+ LOG_WARN("EncryptedStorage: Could not load v1 DEK for migration, generating fresh DEK");
+ }
+ }
+
+ if (!migrated) {
+ if (!generateDEK())
+ return false;
+ }
+
+ // Derive v2 KEK from passphrase
+ nRFCrypto.begin();
+ bool kekOk = deriveKEKv2(passphrase, passphraseLen);
+ nRFCrypto.end();
+ if (!kekOk) {
+ memset(dek, 0, sizeof(dek));
+ return false;
+ }
+
+ // Save DEK in v2 format
+ if (!saveDEKv2()) {
+ memset(dek, 0, sizeof(dek));
+ kekDerived = false;
+ return false;
+ }
+
+ // Create unlock token (validUntilEpoch is an absolute Unix timestamp from the client; 0 = no limit)
+ if (!writeUnlockToken(bootsRemaining, validUntilEpoch)) {
+ LOG_WARN("EncryptedStorage: Token write failed after provision (continuing unlocked)");
+ }
+
+ dekLoaded = true;
+ s_bootsRemaining = bootsRemaining;
+ s_validUntilEpoch = validUntilEpoch;
+ LOG_INFO("EncryptedStorage: Provisioning complete%s", migrated ? " (v1 migrated)" : "");
+ return true;
+}
+
+bool unlockWithPassphrase(const uint8_t *passphrase, size_t passphraseLen, uint8_t bootsRemaining,
+ uint32_t validUntilEpoch)
+{
+ if (passphraseLen == 0 || passphraseLen > 64) {
+ LOG_ERROR("EncryptedStorage: Invalid passphrase length %d", passphraseLen);
+ return false;
+ }
+
+ // Exponential backoff: reject attempt if not enough time has elapsed since last failure.
+ {
+ uint8_t attempts;
+ uint32_t lastFail;
+ readBackoff(attempts, lastFail);
+ if (attempts > 0) {
+ uint32_t delay = backoffDelay(attempts);
+ uint32_t now = getTime();
+ if (lastFail != 0 && now != 0 && (now - lastFail) < delay) {
+ s_backoffSecondsRemaining = delay - (now - lastFail);
+ LOG_WARN("EncryptedStorage: Passphrase attempt blocked by backoff (%us remaining)", s_backoffSecondsRemaining);
+ return false;
+ }
+ }
+ s_backoffSecondsRemaining = 0;
+ }
+
+ if (!ephemeralKekDerived) {
+ nRFCrypto.begin();
+ bool ok = deriveEphemeralKEK();
+ nRFCrypto.end();
+ if (!ok)
+ return false;
+ }
+
+ // Derive v2 KEK
+ nRFCrypto.begin();
+ bool kekOk = deriveKEKv2(passphrase, passphraseLen);
+ nRFCrypto.end();
+ if (!kekOk)
+ return false;
+
+ // Helper: record a failed attempt for backoff tracking.
+ auto recordFailure = []() {
+ uint8_t attempts;
+ uint32_t lastFail;
+ readBackoff(attempts, lastFail);
+ if (attempts < 255) attempts++;
+ writeBackoff(attempts, getTime());
+ s_backoffSecondsRemaining = backoffDelay(attempts);
+ LOG_WARN("EncryptedStorage: Wrong passphrase (attempt %d, next in %us)", attempts, s_backoffSecondsRemaining);
+ };
+
+ // Try loading the v2 DEK (verifies HMAC — wrong passphrase will fail here)
+ if (!loadDEKv2()) {
+ // Check if this is a v1 DEK file that hasn't been migrated yet.
+ // IMPORTANT: check the file size under lock, then release before calling any
+ // function that takes spiLock (loadDEKv1, saveDEKv2, recordFailure→readBackoff).
+ // spiLock is a binary (non-recursive) semaphore — re-entry deadlocks the task.
+#ifdef FSCom
+ bool isV1 = false;
+ {
+ concurrency::LockGuard g(spiLock);
+ auto f = FSCom.open(DEK_FILENAME, FILE_O_READ);
+ isV1 = f && (f.size() == DEK_V1_SIZE);
+ if (f)
+ f.close();
+ } // spiLock released before any further SPI operations
+
+ if (isV1) {
+ LOG_INFO("EncryptedStorage: Detected v1 DEK, migrating");
+ nRFCrypto.begin();
+ deriveKEKv1();
+ nRFCrypto.end();
+ if (!loadDEKv1()) {
+ LOG_ERROR("EncryptedStorage: v1 DEK load failed during migration");
+ memset(dek, 0, sizeof(dek));
+ kekDerived = false;
+ recordFailure();
+ return false;
+ }
+ // Re-derive v2 KEK (overwritten by deriveKEKv1 above)
+ nRFCrypto.begin();
+ deriveKEKv2(passphrase, passphraseLen);
+ nRFCrypto.end();
+ saveDEKv2();
+ LOG_INFO("EncryptedStorage: v1 DEK migrated to v2");
+ } else {
+ LOG_ERROR("EncryptedStorage: v2 DEK load failed — wrong passphrase?");
+ kekDerived = false;
+ recordFailure();
+ return false;
+ }
+#else
+ kekDerived = false;
+ recordFailure();
+ return false;
+#endif
+ }
+
+ // Passphrase correct — clear backoff state
+ clearBackoff();
+
+ // Create fresh unlock token (validUntilEpoch is an absolute Unix timestamp from the client; 0 = no limit)
+ if (!writeUnlockToken(bootsRemaining, validUntilEpoch)) {
+ LOG_WARN("EncryptedStorage: Token write failed after unlock (continuing unlocked this boot)");
+ }
+
+ dekLoaded = true;
+ s_bootsRemaining = bootsRemaining;
+ s_validUntilEpoch = validUntilEpoch;
+ LOG_INFO("EncryptedStorage: Unlocked with passphrase");
+ return true;
+}
+
+void lockNow()
+{
+#ifdef FSCom
+ {
+ concurrency::LockGuard g(spiLock);
+ FSCom.remove(TOKEN_FILENAME);
+ }
+#endif
+ memset(dek, 0, sizeof(dek));
+ dekLoaded = false;
+ LOG_INFO("EncryptedStorage: Device locked — token deleted, DEK zeroed");
+}
+
+bool isProvisioned()
+{
+#ifdef FSCom
+ concurrency::LockGuard g(spiLock);
+ return FSCom.exists(DEK_FILENAME);
+#else
+ return false;
+#endif
+}
+
+bool isUnlocked()
+{
+ return dekLoaded;
+}
+
+// ---------------------------------------------------------------------------
+// Encrypted file I/O
+// ---------------------------------------------------------------------------
+
+bool isEncrypted(const char *filename)
+{
+#ifdef FSCom
+ concurrency::LockGuard g(spiLock);
+ auto f = FSCom.open(filename, FILE_O_READ);
+ if (!f)
+ return false;
+
+ uint32_t magic = 0;
+ size_t bytesRead = f.read((uint8_t *)&magic, 4);
+ f.close();
+
+ return (bytesRead == 4 && magic == MAGIC);
+#else
+ return false;
+#endif
+}
+
+bool readAndDecrypt(const char *filename, uint8_t *outBuf, size_t outBufSize, size_t &outLen)
+{
+ if (!dekLoaded) {
+ LOG_ERROR("EncryptedStorage: Not unlocked");
+ return false;
+ }
+
+#ifdef FSCom
+ concurrency::LockGuard g(spiLock);
+ auto f = FSCom.open(filename, FILE_O_READ);
+ if (!f) {
+ LOG_ERROR("EncryptedStorage: Can't open %s", filename);
+ return false;
+ }
+
+ size_t fileSize = f.size();
+ if (fileSize < OVERHEAD) {
+ LOG_ERROR("EncryptedStorage: File %s too small (%d bytes)", filename, fileSize);
+ f.close();
+ return false;
+ }
+
+ uint8_t *fileBuf = new uint8_t[fileSize];
+ if (!fileBuf) {
+ LOG_ERROR("EncryptedStorage: OOM reading %s", filename);
+ f.close();
+ return false;
+ }
+
+ size_t bytesRead = f.read(fileBuf, fileSize);
+ f.close();
+
+ if (bytesRead != fileSize) {
+ LOG_ERROR("EncryptedStorage: Short read on %s", filename);
+ memset(fileBuf, 0, fileSize);
+ delete[] fileBuf;
+ return false;
+ }
+
+ // Parse header
+ size_t pos = 0;
+ uint32_t magic;
+ memcpy(&magic, fileBuf + pos, 4);
+ pos += 4;
+ if (magic != MAGIC) {
+ LOG_ERROR("EncryptedStorage: Bad magic in %s", filename);
+ memset(fileBuf, 0, fileSize);
+ delete[] fileBuf;
+ return false;
+ }
+
+ uint8_t version = fileBuf[pos++];
+ if (version != VERSION) {
+ LOG_ERROR("EncryptedStorage: Unsupported version %d in %s", version, filename);
+ memset(fileBuf, 0, fileSize);
+ delete[] fileBuf;
+ return false;
+ }
+
+ uint8_t nonce[NONCE_SIZE];
+ memcpy(nonce, fileBuf + pos, NONCE_SIZE);
+ pos += NONCE_SIZE;
+
+ uint32_t plaintextLen;
+ memcpy(&plaintextLen, fileBuf + pos, 4);
+ pos += 4;
+
+ size_t ciphertextLen = fileSize - HEADER_SIZE - HMAC_SIZE;
+ const uint8_t *ciphertext = fileBuf + pos;
+ const uint8_t *storedHmac = fileBuf + fileSize - HMAC_SIZE;
+
+ // Verify HMAC-SHA256(DEK, nonce || ciphertext)
+ size_t hmacDataLen = NONCE_SIZE + ciphertextLen;
+ uint8_t *hmacData = new uint8_t[hmacDataLen];
+ if (!hmacData) {
+ LOG_ERROR("EncryptedStorage: OOM for HMAC data");
+ delete[] fileBuf;
+ return false;
+ }
+ memcpy(hmacData, nonce, NONCE_SIZE);
+ memcpy(hmacData + NONCE_SIZE, ciphertext, ciphertextLen);
+
+ uint8_t computedHmac[HMAC_SIZE];
+ nRFCrypto.begin();
+ bool hmacOk = computeHMAC(dek, AES_KEY_SIZE, hmacData, hmacDataLen, computedHmac);
+ nRFCrypto.end();
+ memset(hmacData, 0, hmacDataLen);
+ delete[] hmacData;
+
+ if (!hmacOk || !constTimeEq(computedHmac, storedHmac, HMAC_SIZE)) {
+ LOG_ERROR("EncryptedStorage: HMAC verification failed for %s", filename);
+ memset(computedHmac, 0, sizeof(computedHmac));
+ memset(fileBuf, 0, fileSize);
+ delete[] fileBuf;
+ return false;
+ }
+ memset(computedHmac, 0, sizeof(computedHmac));
+
+ // Decrypt
+ if (plaintextLen > outBufSize) {
+ LOG_ERROR("EncryptedStorage: Output buffer too small for %s (%d > %d)", filename, plaintextLen,
+ outBufSize);
+ memset(fileBuf, 0, fileSize);
+ delete[] fileBuf;
+ return false;
+ }
+
+ nRFCrypto.begin();
+ bool decOk = aesCtr128(dek, nonce, NONCE_SIZE, ciphertext, ciphertextLen, outBuf);
+ nRFCrypto.end();
+ memset(fileBuf, 0, fileSize);
+ delete[] fileBuf;
+
+ if (!decOk) {
+ LOG_ERROR("EncryptedStorage: Decrypt failed for %s", filename);
+ return false;
+ }
+
+ outLen = plaintextLen;
+ LOG_INFO("EncryptedStorage: Decrypted %s (%d bytes)", filename, outLen);
+ return true;
+#else
+ return false;
+#endif
+}
+
+bool encryptAndWrite(const char *filename, const uint8_t *plaintext, size_t plaintextLen, bool fullAtomic)
+{
+ if (!dekLoaded) {
+ LOG_ERROR("EncryptedStorage: Not unlocked");
+ return false;
+ }
+
+#ifdef FSCom
+ uint8_t nonce[NONCE_SIZE];
+ nRFCrypto.begin();
+ if (!nRFCrypto.Random.generate(nonce, NONCE_SIZE)) {
+ LOG_ERROR("EncryptedStorage: TRNG failed for file nonce");
+ nRFCrypto.end();
+ return false;
+ }
+
+ size_t ciphertextLen = plaintextLen;
+ uint8_t *ciphertext = new uint8_t[ciphertextLen];
+ if (!ciphertext) {
+ LOG_ERROR("EncryptedStorage: OOM for ciphertext");
+ nRFCrypto.end();
+ return false;
+ }
+
+ bool encOk = aesCtr128(dek, nonce, NONCE_SIZE, plaintext, plaintextLen, ciphertext);
+ if (!encOk) {
+ LOG_ERROR("EncryptedStorage: Encrypt failed for %s", filename);
+ delete[] ciphertext;
+ nRFCrypto.end();
+ return false;
+ }
+
+ size_t hmacDataLen = NONCE_SIZE + ciphertextLen;
+ uint8_t *hmacData = new uint8_t[hmacDataLen];
+ if (!hmacData) {
+ LOG_ERROR("EncryptedStorage: OOM for HMAC data");
+ delete[] ciphertext;
+ nRFCrypto.end();
+ return false;
+ }
+ memcpy(hmacData, nonce, NONCE_SIZE);
+ memcpy(hmacData + NONCE_SIZE, ciphertext, ciphertextLen);
+
+ uint8_t hmac[HMAC_SIZE];
+ bool hmacOk = computeHMAC(dek, AES_KEY_SIZE, hmacData, hmacDataLen, hmac);
+ nRFCrypto.end();
+ memset(hmacData, 0, hmacDataLen);
+ delete[] hmacData;
+
+ if (!hmacOk) {
+ LOG_ERROR("EncryptedStorage: HMAC computation failed for %s", filename);
+ memset(ciphertext, 0, ciphertextLen);
+ delete[] ciphertext;
+ return false;
+ }
+
+ 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);
+ memset(ciphertext, 0, ciphertextLen);
+ delete[] ciphertext;
+ return false;
+ }
+
+ uint32_t magic = MAGIC;
+ f.write((uint8_t *)&magic, 4);
+ uint8_t ver = VERSION;
+ f.write(&ver, 1);
+ f.write(nonce, NONCE_SIZE);
+ uint32_t ptLen = (uint32_t)plaintextLen;
+ f.write((uint8_t *)&ptLen, 4);
+ f.write(ciphertext, ciphertextLen);
+ f.write(hmac, HMAC_SIZE);
+ f.flush();
+ f.close();
+ memset(ciphertext, 0, ciphertextLen);
+ delete[] ciphertext;
+
+ LOG_INFO("EncryptedStorage: Encrypted %s (%d bytes plaintext)", filename, plaintextLen);
+ return true;
+#else
+ return false;
+#endif
+}
+
+bool migrateFile(const char *filename)
+{
+#ifdef FSCom
+ if (isEncrypted(filename)) {
+ LOG_DEBUG("EncryptedStorage: %s already encrypted, skip migration", filename);
+ return true;
+ }
+
+ uint8_t *plaintext = nullptr;
+ size_t fileSize = 0;
+ {
+ concurrency::LockGuard g(spiLock);
+ auto f = FSCom.open(filename, FILE_O_READ);
+ if (!f) {
+ LOG_WARN("EncryptedStorage: %s doesn't exist, skip migration", filename);
+ return false;
+ }
+
+ fileSize = f.size();
+ if (fileSize == 0) {
+ f.close();
+ return false;
+ }
+
+ plaintext = new uint8_t[fileSize];
+ if (!plaintext) {
+ LOG_ERROR("EncryptedStorage: OOM migrating %s", filename);
+ f.close();
+ return false;
+ }
+
+ size_t bytesRead = f.read(plaintext, fileSize);
+ f.close();
+
+ if (bytesRead != fileSize) {
+ LOG_ERROR("EncryptedStorage: Short read migrating %s", filename);
+ memset(plaintext, 0, fileSize);
+ delete[] plaintext;
+ return false;
+ }
+ }
+
+ bool ok = encryptAndWrite(filename, plaintext, fileSize);
+ memset(plaintext, 0, fileSize);
+ delete[] plaintext;
+
+ if (ok) {
+ LOG_INFO("EncryptedStorage: Migrated %s to encrypted format", filename);
+ }
+ return ok;
+#else
+ return false;
+#endif
+}
+
+// ---------------------------------------------------------------------------
+// Legacy init() — FICR-only KEK, no passphrase, no token
+// ---------------------------------------------------------------------------
+
+bool init()
+{
+ nRFCrypto.begin();
+
+ if (!deriveKEKv1()) {
+ LOG_ERROR("EncryptedStorage: KEK derivation failed");
+ nRFCrypto.end();
+ return false;
+ }
+
+ // Also derive ephemeral KEK so token ops are available if needed
+ deriveEphemeralKEK();
+
+ nRFCrypto.end();
+
+ if (!loadDEKv1()) {
+ LOG_INFO("EncryptedStorage: No existing DEK, generating new one");
+ if (!generateDEK())
+ return false;
+ // Save in v1 format (legacy path: nonce + AES-CTR(KEK, nonce, DEK))
+ uint8_t nonce[NONCE_SIZE];
+ nRFCrypto.begin();
+ bool ok = nRFCrypto.Random.generate(nonce, NONCE_SIZE);
+ nRFCrypto.end();
+ if (!ok) {
+ LOG_ERROR("EncryptedStorage: TRNG failed");
+ memset(dek, 0, sizeof(dek));
+ return false;
+ }
+ uint8_t encDek[AES_KEY_SIZE];
+ nRFCrypto.begin();
+ ok = aesCtr128(kek, nonce, NONCE_SIZE, dek, AES_KEY_SIZE, encDek);
+ nRFCrypto.end();
+ if (!ok) {
+ memset(dek, 0, sizeof(dek));
+ return false;
+ }
+ concurrency::LockGuard g(spiLock);
+ auto f = FSCom.open(DEK_FILENAME, FILE_O_WRITE);
+ if (!f) {
+ memset(dek, 0, sizeof(dek));
+ return false;
+ }
+ f.write(nonce, NONCE_SIZE);
+ f.write(encDek, AES_KEY_SIZE);
+ f.flush();
+ f.close();
+ memset(nonce, 0, sizeof(nonce));
+ memset(encDek, 0, sizeof(encDek));
+ }
+
+ dekLoaded = true;
+ LOG_INFO("EncryptedStorage: Initialized (legacy mode)");
+ return true;
+}
+
+} // namespace EncryptedStorage
+
+#else
+// ---------------------------------------------------------------------------
+// Non-NRF52 stub implementations
+// ---------------------------------------------------------------------------
+
+namespace EncryptedStorage {
+
+void initLocked() {}
+bool provisionPassphrase(const uint8_t *, size_t, uint8_t, uint32_t) { return false; }
+bool unlockWithPassphrase(const uint8_t *, size_t, uint8_t, uint32_t) { return false; }
+void lockNow() {}
+bool isProvisioned() { return false; }
+bool isUnlocked() { return false; }
+const char *getLockReason() { return "not_provisioned"; }
+uint8_t getBootsRemaining() { return 0; }
+uint32_t getValidUntilEpoch() { return 0; }
+uint32_t getBackoffSecondsRemaining() { return 0; }
+bool init() { return false; }
+bool isEncrypted(const char *) { return false; }
+bool readAndDecrypt(const char *, uint8_t *, size_t, size_t &) { return false; }
+bool encryptAndWrite(const char *, const uint8_t *, size_t, bool) { return false; }
+bool migrateFile(const char *) { return false; }
+
+} // namespace EncryptedStorage
+
+#endif // ARCH_NRF52
+#endif // MESHTASTIC_ENCRYPTED_STORAGE
diff --git a/src/security/EncryptedStorage.h b/src/security/EncryptedStorage.h
new file mode 100644
index 000000000..3d6ce4746
--- /dev/null
+++ b/src/security/EncryptedStorage.h
@@ -0,0 +1,198 @@
+#pragma once
+
+#ifdef MESHTASTIC_ENCRYPTED_STORAGE
+
+#include
+#include
+
+/**
+ * Encrypted storage layer for TAK secure builds.
+ *
+ * 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)
+ *
+ * Ephemeral KEK (FICR-only, no passphrase) -> wraps DEK in the unlock token only.
+ * Unlock token (/prefs/.unlock_token) — valid for N boots and/or M hours after provisioning.
+ *
+ * Boot flow:
+ * 1. initLocked() — derive ephemeral KEK, try unlock token
+ * 2a. Token valid → UNLOCKED (DEK in RAM, all encrypted files accessible)
+ * 2b. No token, no DEK file → NOT PROVISIONED (operator must call provisionPassphrase)
+ * 2c. No token, DEK file exists → LOCKED (operator must call unlockWithPassphrase)
+ * 3. provisionPassphrase() / unlockWithPassphrase() complete the unlock
+ * 4. lockNow() immediately invalidates the token and zeroes the DEK from RAM
+ *
+ * Encrypted proto file format ("MENC"):
+ * [4B] Magic 0x4D454E43 ("MENC")
+ * [1B] Version 0x01
+ * [13B] Nonce (random per write)
+ * [4B] Original plaintext length (LE uint32)
+ * [NB] AES-128-CTR ciphertext
+ * [32B] HMAC-SHA256(DEK, nonce || ciphertext)
+ * Total overhead: 54 bytes per file.
+ *
+ * DEK file format v2 ("MDEK"):
+ * [4B] Magic 0x4D44454B ("MDEK")
+ * [1B] Version 0x02
+ * [13B] Nonce (random per write)
+ * [16B] AES-128-CTR(KEK_v2, nonce, DEK)
+ * [32B] HMAC-SHA256(KEK_v2, "mdek-auth" || nonce || encrypted_DEK)
+ * Total: 66 bytes.
+ * Legacy v1 DEK file is 29 bytes (no magic, no HMAC) and is migrated on first passphrase set.
+ *
+ * Unlock token format ("UTOK"):
+ * [4B] Magic 0x55544F4B ("UTOK")
+ * [1B] Version 0x01
+ * [13B] Nonce (random per write)
+ * [16B] AES-128-CTR(ephemeralKEK, nonce, DEK)
+ * [1B] boots_remaining
+ * [4B] valid_until_epoch (LE uint32, 0 = no time limit)
+ * [32B] HMAC-SHA256(ephemeralKEK, all above fields)
+ * Total: 71 bytes.
+ */
+
+namespace EncryptedStorage {
+
+// ---------------------------------------------------------------------------
+// File format constants
+// ---------------------------------------------------------------------------
+
+static constexpr uint32_t MAGIC = 0x4D454E43; // "MENC" — encrypted proto files
+static constexpr uint8_t VERSION = 0x01;
+static constexpr size_t NONCE_SIZE = 13;
+static constexpr size_t HMAC_SIZE = 32;
+static constexpr size_t HEADER_SIZE = 4 + 1 + NONCE_SIZE + 4; // magic+version+nonce+plaintext_len
+static constexpr size_t OVERHEAD = HEADER_SIZE + HMAC_SIZE; // 54 bytes
+static constexpr size_t AES_KEY_SIZE = 16;
+static constexpr size_t AES_BLOCK_SIZE = 16;
+
+static constexpr uint32_t DEK_MAGIC_V2 = 0x4D44454B; // "MDEK"
+static constexpr uint8_t DEK_VERSION_V2 = 0x02;
+static constexpr size_t DEK_V1_SIZE = NONCE_SIZE + AES_KEY_SIZE; // 29 bytes
+static constexpr size_t DEK_V2_SIZE = 4 + 1 + NONCE_SIZE + AES_KEY_SIZE + HMAC_SIZE; // 66 bytes
+
+static constexpr uint32_t TOKEN_MAGIC = 0x55544F4B; // "UTOK"
+static constexpr uint8_t TOKEN_VERSION = 0x01;
+// Body = magic(4)+ver(1)+nonce(13)+encDEK(16)+boots(1)+epoch(4) = 39 bytes
+static constexpr size_t TOKEN_BODY_SIZE = 4 + 1 + NONCE_SIZE + AES_KEY_SIZE + 1 + 4;
+static constexpr size_t TOKEN_TOTAL_SIZE = TOKEN_BODY_SIZE + HMAC_SIZE; // 71 bytes
+
+static constexpr uint8_t TOKEN_DEFAULT_BOOTS = 50;
+
+// ---------------------------------------------------------------------------
+// Passphrase-gated boot API
+// ---------------------------------------------------------------------------
+
+/**
+ * Boot-time init: derive ephemeral KEK and attempt to unlock via the stored token.
+ * Sets isUnlocked()=true if the token is present and valid.
+ * Must be called after fsInit(), before loadFromDisk().
+ */
+void initLocked();
+
+/**
+ * First-time provisioning: set the device passphrase, generate a fresh DEK,
+ * save it wrapped with the passphrase-mixed KEK, and create an unlock token.
+ *
+ * If a legacy v1 DEK file already exists, it is migrated rather than replaced,
+ * preserving all previously encrypted config files.
+ *
+ * @param passphrase Raw passphrase bytes (need not be NUL-terminated)
+ * @param passphraseLen Length in bytes (1–64)
+ * @param bootsRemaining Token valid for this many boots (default TOKEN_DEFAULT_BOOTS)
+ * @param validUntilEpoch Absolute Unix timestamp after which token expires (0 = no time limit)
+ * @return true on success
+ */
+bool provisionPassphrase(const uint8_t *passphrase, size_t passphraseLen,
+ uint8_t bootsRemaining = TOKEN_DEFAULT_BOOTS, uint32_t validUntilEpoch = 0);
+
+/**
+ * Unlock after token expiry (or after lockNow()): derive KEK from passphrase,
+ * unwrap the stored DEK, and create a fresh unlock token.
+ *
+ * @param passphrase Raw passphrase bytes
+ * @param passphraseLen Length in bytes (1–64)
+ * @param bootsRemaining New token valid for this many boots
+ * @param validUntilEpoch Absolute Unix timestamp after which token expires (0 = no time limit)
+ * @return true if passphrase was correct and DEK is now loaded
+ */
+bool unlockWithPassphrase(const uint8_t *passphrase, size_t passphraseLen,
+ uint8_t bootsRemaining = TOKEN_DEFAULT_BOOTS, uint32_t validUntilEpoch = 0);
+
+/**
+ * Immediately lock: delete the unlock token and zero the DEK from RAM.
+ * The device will require the passphrase on the next boot (or connection).
+ */
+void lockNow();
+
+/** Returns true if the DEK file exists (device has been provisioned). */
+bool isProvisioned();
+
+/** Returns true if the DEK is loaded in RAM (device is unlocked). */
+bool isUnlocked();
+
+/**
+ * Returns a short string describing why the device is locked (set during initLocked()).
+ * Useful for client-side diagnostics. Examples:
+ * "token_missing" — no unlock token file found
+ * "token_wrong_size" — token file exists but is corrupt
+ * "token_bad_magic" — wrong magic/version bytes
+ * "token_hmac_fail" — HMAC mismatch (tampered or wrong device)
+ * "token_boots_zero" — boot count exhausted
+ * "token_expired" — TTL expired
+ * "token_dek_fail" — DEK decrypt failed
+ * "not_provisioned" — no DEK file; needs first provisioning
+ * "ok" — unlocked successfully via token
+ */
+const char *getLockReason();
+
+/** Boots remaining in the current unlock token (0 if not unlocked or last boot consumed). */
+uint8_t getBootsRemaining();
+
+/** Unix epoch at which the current unlock token expires (0 = no time limit or not unlocked). */
+uint32_t getValidUntilEpoch();
+
+/** Seconds remaining before next passphrase attempt is allowed (0 = can attempt now). */
+uint32_t getBackoffSecondsRemaining();
+
+// ---------------------------------------------------------------------------
+// Encrypted file I/O (require isUnlocked())
+// ---------------------------------------------------------------------------
+
+/** Returns true if the file starts with the MENC magic bytes. */
+bool isEncrypted(const char *filename);
+
+/**
+ * Read and decrypt a file into outBuf.
+ * Returns true on success; sets outLen to the plaintext byte count.
+ */
+bool readAndDecrypt(const char *filename, uint8_t *outBuf, size_t outBufSize, size_t &outLen);
+
+/**
+ * Encrypt plaintext and write to filename.
+ * Returns true on success.
+ */
+bool encryptAndWrite(const char *filename, const uint8_t *plaintext, size_t plaintextLen,
+ bool fullAtomic = false);
+
+/**
+ * Migrate a plaintext proto file to encrypted format in-place.
+ * Returns true on success or if already encrypted.
+ */
+bool migrateFile(const char *filename);
+
+// ---------------------------------------------------------------------------
+// Legacy init (FICR-only KEK, no passphrase, no token)
+// ---------------------------------------------------------------------------
+
+/**
+ * Original init(): derives KEK from FICR only, loads or generates DEK.
+ * Retained for non-TAK builds and unit tests. Prefer initLocked() for TAK builds.
+ */
+bool init();
+
+} // namespace EncryptedStorage
+
+#endif // MESHTASTIC_ENCRYPTED_STORAGE
diff --git a/variants/nrf52840/rak4631_lockdown/platformio.ini b/variants/nrf52840/rak4631_lockdown/platformio.ini
new file mode 100644
index 000000000..e51dd234e
--- /dev/null
+++ b/variants/nrf52840/rak4631_lockdown/platformio.ini
@@ -0,0 +1,61 @@
+; RAK4631 LOCKDOWN Build - Hardened variant with encrypted storage and access control
+; Enable with -DMESHTASTIC_LOCKDOWN=1 (all sub-features derived from that single flag)
+[env:rak4631_lockdown]
+custom_meshtastic_hw_model = 9
+custom_meshtastic_hw_model_slug = RAK4631
+custom_meshtastic_architecture = nrf52840
+custom_meshtastic_actively_supported = false
+custom_meshtastic_support_level = 2
+custom_meshtastic_display_name = RAK WisBlock 4631 LOCKDOWN
+custom_meshtastic_tags = RAK, LOCKDOWN
+
+extends = nrf52840_base
+board = wiscore_rak4631
+board_level = extra
+board_check = true
+build_type = release
+build_flags = ${nrf52840_base.build_flags}
+ -I variants/nrf52840/rak4631_lockdown
+ -I variants/nrf52840/rak4631
+ -D RAK_4631
+ -DMESHTASTIC_USE_EINK_UI=0
+ -DRADIOLIB_EXCLUDE_SX128X=1
+ -DRADIOLIB_EXCLUDE_SX127X=1
+ -DRADIOLIB_EXCLUDE_LR11X0=1
+ ; Hardened build — all LOCKDOWN features derived from this single flag
+ -DMESHTASTIC_LOCKDOWN=1
+ ; Suppress all UART/USB-CDC log output — prevents info leakage via physical USB access
+ -DDEBUG_MUTE
+ ; Module exclusions - strip unnecessary attack surface
+ -DMESHTASTIC_EXCLUDE_DETECTIONSENSOR=1
+ -DMESHTASTIC_EXCLUDE_ENVIRONMENTAL_SENSOR=1
+ -DMESHTASTIC_EXCLUDE_AIR_QUALITY=1
+ -DMESHTASTIC_EXCLUDE_HEALTH_TELEMETRY=1
+ -DMESHTASTIC_EXCLUDE_POWER_TELEMETRY=1
+ -DMESHTASTIC_EXCLUDE_RANGETEST=1
+ -DMESHTASTIC_EXCLUDE_REMOTEHARDWARE=1
+ -DMESHTASTIC_EXCLUDE_STOREFORWARD=1
+ -DMESHTASTIC_EXCLUDE_CANNEDMESSAGES=1
+ -DMESHTASTIC_EXCLUDE_SERIAL=1
+ -DMESHTASTIC_EXCLUDE_POWERSTRESS=1
+ -DMESHTASTIC_EXCLUDE_DROPZONE=1
+ -DMESHTASTIC_EXCLUDE_REPLYBOT=1
+ -DMESHTASTIC_EXCLUDE_PAXCOUNTER=1
+ -DMESHTASTIC_EXCLUDE_AUDIO=1
+ -DMESHTASTIC_EXCLUDE_STATUS=1
+ -DMESHTASTIC_EXCLUDE_GENERIC_THREAD_MODULE=1
+ -DMESHTASTIC_EXCLUDE_POWERMON=1
+ -DMESHTASTIC_EXCLUDE_EXTERNALNOTIFICATION=1
+
+build_src_filter = ${nrf52_base.build_src_filter}
+ +<../variants/nrf52840/rak4631>
+ +<../variants/nrf52840/rak4631_lockdown>
+ +
+ -
+ -
+ -
+
+lib_deps =
+ ${nrf52840_base.lib_deps}
+ melopero/Melopero RV3028@1.2.0
+ rakwireless/RAKwireless NCP5623 RGB LED library@1.0.3
diff --git a/variants/nrf52840/rak4631_lockdown/variant.h b/variants/nrf52840/rak4631_lockdown/variant.h
new file mode 100644
index 000000000..45ee2e649
--- /dev/null
+++ b/variants/nrf52840/rak4631_lockdown/variant.h
@@ -0,0 +1,18 @@
+/*
+ * RAK4631 LOCKDOWN Build variant header
+ * Includes the parent RAK4631 variant and adds LOCKDOWN-specific overrides.
+ */
+
+#ifndef _VARIANT_RAK4631_LOCKDOWN_
+#define _VARIANT_RAK4631_LOCKDOWN_
+
+// Include the parent RAK4631 variant - all pin definitions, radio config, etc.
+#include "../rak4631/variant.h"
+
+// Disable ethernet — reduces attack surface and avoids W5100S dependency
+#ifdef HAS_ETHERNET
+#undef HAS_ETHERNET
+#endif
+#define HAS_ETHERNET 0
+
+#endif // _VARIANT_RAK4631_LOCKDOWN_