From fd7fc40255164d5150be43f27746e179faf43443 Mon Sep 17 00:00:00 2001 From: niccellular <79813408+niccellular@users.noreply.github.com> Date: Thu, 26 Feb 2026 22:14:00 -0500 Subject: [PATCH] Add rak4631_lockdown hardened build variant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/PowerFSM.cpp | 4 + src/configuration.h | 16 + src/main.cpp | 22 + src/mesh/NodeDB.cpp | 148 +- src/mesh/NodeDB.h | 6 + src/mesh/PhoneAPI.cpp | 183 ++- src/mesh/PhoneAPI.h | 18 + src/modules/AdminModule.cpp | 135 +- src/security/APProtect.cpp | 43 + src/security/APProtect.h | 16 + src/security/EncryptedStorage.cpp | 1442 +++++++++++++++++ src/security/EncryptedStorage.h | 198 +++ .../nrf52840/rak4631_lockdown/platformio.ini | 61 + variants/nrf52840/rak4631_lockdown/variant.h | 18 + 14 files changed, 2294 insertions(+), 16 deletions(-) create mode 100644 src/security/APProtect.cpp create mode 100644 src/security/APProtect.h create mode 100644 src/security/EncryptedStorage.cpp create mode 100644 src/security/EncryptedStorage.h create mode 100644 variants/nrf52840/rak4631_lockdown/platformio.ini create mode 100644 variants/nrf52840/rak4631_lockdown/variant.h 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_