Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
38c4001f52 | ||
|
|
067d6834c7 | ||
|
|
3f9d76f054 | ||
|
|
545ae99ff8 | ||
|
|
fd7fc40255 |
@@ -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);
|
||||
}
|
||||
|
||||
@@ -528,5 +528,69 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
#define HAS_SCREEN 0
|
||||
#endif
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// MESHTASTIC_LOCKDOWN — combined hardened build flag
|
||||
//
|
||||
// All targets:
|
||||
// DEBUG_MUTE — suppress serial/USB log output
|
||||
// MESHTASTIC_EXCLUDE_* — strip non-essential modules
|
||||
// MESHTASTIC_PHONEAPI_ACCESS_CONTROL — redact keys from unauthenticated BLE/USB/TCP clients
|
||||
// HARDENED_DEFAULTS — set is_managed, RANDOM_PIN, TAK role at first boot
|
||||
//
|
||||
// nRF52 (CC310 hardware crypto):
|
||||
// MESHTASTIC_ENCRYPTED_STORAGE — AES-128-CTR + HMAC-SHA256 at-rest encryption
|
||||
// MESHTASTIC_ENABLE_APPROTECT — write UICR APPROTECT to block debug probe
|
||||
//
|
||||
// ESP32 (stub — mbedTLS crypto TODO):
|
||||
// MESHTASTIC_ENCRYPTED_STORAGE — same API, plaintext pass-through until ported
|
||||
// APPROTECT equivalent — burn JTAG_DISABLE + flash encryption via espsecure.py
|
||||
// -----------------------------------------------------------------------------
|
||||
#ifdef MESHTASTIC_LOCKDOWN
|
||||
|
||||
// Suppress all serial/USB-CDC log output — prevents info leakage via physical access
|
||||
#define DEBUG_MUTE
|
||||
|
||||
// Strip non-essential modules to reduce attack surface
|
||||
#define MESHTASTIC_EXCLUDE_DETECTIONSENSOR 1
|
||||
#define MESHTASTIC_EXCLUDE_ENVIRONMENTAL_SENSOR 1
|
||||
#define MESHTASTIC_EXCLUDE_AIR_QUALITY 1
|
||||
#define MESHTASTIC_EXCLUDE_HEALTH_TELEMETRY 1
|
||||
#define MESHTASTIC_EXCLUDE_POWER_TELEMETRY 1
|
||||
#define MESHTASTIC_EXCLUDE_RANGETEST 1
|
||||
#define MESHTASTIC_EXCLUDE_REMOTEHARDWARE 1
|
||||
#define MESHTASTIC_EXCLUDE_STOREFORWARD 1
|
||||
#define MESHTASTIC_EXCLUDE_CANNEDMESSAGES 1
|
||||
#define MESHTASTIC_EXCLUDE_SERIAL 1
|
||||
#define MESHTASTIC_EXCLUDE_POWERSTRESS 1
|
||||
#define MESHTASTIC_EXCLUDE_DROPZONE 1
|
||||
#define MESHTASTIC_EXCLUDE_REPLYBOT 1
|
||||
#define MESHTASTIC_EXCLUDE_PAXCOUNTER 1
|
||||
#define MESHTASTIC_EXCLUDE_AUDIO 1
|
||||
#define MESHTASTIC_EXCLUDE_STATUS 1
|
||||
#define MESHTASTIC_EXCLUDE_GENERIC_THREAD_MODULE 1
|
||||
#define MESHTASTIC_EXCLUDE_POWERMON 1
|
||||
#define MESHTASTIC_EXCLUDE_EXTERNALNOTIFICATION 1
|
||||
|
||||
// Access control and hardened defaults are pure C++ — no platform-specific deps.
|
||||
#define MESHTASTIC_PHONEAPI_ACCESS_CONTROL 1
|
||||
#define HARDENED_DEFAULTS 1
|
||||
|
||||
// Platform-specific security features:
|
||||
#if defined(ARCH_NRF52)
|
||||
// Full encrypted storage via CC310 hardware AES-128-CTR + HMAC-SHA256.
|
||||
#define MESHTASTIC_ENCRYPTED_STORAGE 1
|
||||
// Hardware JTAG/SWD lockout: firmware writes NRF_UICR->APPROTECT = 0x00 once.
|
||||
#define MESHTASTIC_ENABLE_APPROTECT 1
|
||||
#elif defined(ARCH_ESP32)
|
||||
// Encrypted storage stub: plaintext pass-through, same API, crypto TODO (mbedTLS).
|
||||
// APPROTECT equivalent: burn JTAG_DISABLE eFuse and enable flash encryption
|
||||
// via espsecure.py at provisioning time — not firmware-controlled.
|
||||
#define MESHTASTIC_ENCRYPTED_STORAGE 1
|
||||
#else
|
||||
#warning "MESHTASTIC_LOCKDOWN: encrypted storage not available on this platform; access control and hardened defaults still active"
|
||||
#endif
|
||||
|
||||
#endif // MESHTASTIC_LOCKDOWN
|
||||
|
||||
#include "DebugConfiguration.h"
|
||||
#include "RF95Configuration.h"
|
||||
|
||||
@@ -59,6 +59,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);
|
||||
|
||||
+147
-1
@@ -30,6 +30,10 @@
|
||||
#include <power/PowerHAL.h>
|
||||
#include <vector>
|
||||
|
||||
#ifdef MESHTASTIC_ENCRYPTED_STORAGE
|
||||
#include "security/EncryptedStorage.h"
|
||||
#endif
|
||||
|
||||
#ifdef ARCH_ESP32
|
||||
#if HAS_WIFI
|
||||
#include "mesh/wifi/WiFiAPClient.h"
|
||||
@@ -645,6 +649,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
|
||||
@@ -710,7 +728,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
|
||||
@@ -1148,6 +1168,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);
|
||||
|
||||
@@ -1224,6 +1275,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) {
|
||||
@@ -1385,6 +1451,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);
|
||||
@@ -1420,8 +1515,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)
|
||||
@@ -1434,6 +1542,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);
|
||||
@@ -1526,6 +1662,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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
+175
-14
@@ -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,16 @@ void PhoneAPI::handleStartConfig()
|
||||
observe(&service->fromNumChanged);
|
||||
#ifdef FSCom
|
||||
observe(&xModem.packetReady);
|
||||
#endif
|
||||
#ifdef MESHTASTIC_PHONEAPI_ACCESS_CONTROL
|
||||
// New physical connection: reset per-connection PKC auth.
|
||||
// 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;
|
||||
// MED-2: s_localAdminAuthorized (device-level passphrase auth) is intentionally
|
||||
// NOT reset here. It persists until an explicit Lock Now (revokeAllAuth()).
|
||||
// Resetting it on every new connection would cause an auth DoS.
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -127,6 +140,13 @@ void PhoneAPI::close()
|
||||
config_state = 0;
|
||||
pauseBluetoothLogging = false;
|
||||
heartbeatReceived = false;
|
||||
#ifdef MESHTASTIC_PHONEAPI_ACCESS_CONTROL
|
||||
isAdminAuthorized = false;
|
||||
// MED-2: do NOT reset s_localAdminAuthorized on disconnect.
|
||||
// It represents device-level passphrase auth and must persist across connections
|
||||
// until an explicit Lock Now (revokeAllAuth()). Resetting it here would revoke
|
||||
// any passphrase-authenticated session the moment a second client connects.
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
@@ -155,6 +175,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 +201,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 +327,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 +351,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 +394,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 +421,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 +543,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 +639,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 +721,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 +981,25 @@ 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
|
||||
// HIGH-3: static atomic so BLE-task writes and main-loop reads are race-free.
|
||||
std::atomic<bool> PhoneAPI::s_localAdminAuthorized{false};
|
||||
|
||||
void PhoneAPI::authorizeLocalAdmin()
|
||||
{
|
||||
s_localAdminAuthorized.store(true);
|
||||
LOG_INFO("TAK: Local admin authorized via PKC");
|
||||
}
|
||||
|
||||
bool PhoneAPI::isLocalAdminAuthorized()
|
||||
{
|
||||
return s_localAdminAuthorized.load();
|
||||
}
|
||||
|
||||
void PhoneAPI::revokeAllAuth()
|
||||
{
|
||||
s_localAdminAuthorized.store(false);
|
||||
LOG_INFO("TAK: All connection auth revoked (Lock Now)");
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
#include "concurrency/Lock.h"
|
||||
#include "mesh-pb-constants.h"
|
||||
#include "meshtastic/portnums.pb.h"
|
||||
#include <atomic>
|
||||
#include <deque>
|
||||
#include <iterator>
|
||||
#include <string>
|
||||
@@ -138,6 +139,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 +193,13 @@ class PhoneAPI
|
||||
|
||||
APIType api_type = TYPE_NONE;
|
||||
|
||||
#ifdef MESHTASTIC_PHONEAPI_ACCESS_CONTROL
|
||||
// HIGH-3: both flags are accessed from multiple tasks (main loop + NimBLE BLE callback
|
||||
// task), so they must be atomic to prevent data races on read-modify-write sequences.
|
||||
std::atomic<bool> isAdminAuthorized{false};
|
||||
static std::atomic<bool> s_localAdminAuthorized;
|
||||
#endif
|
||||
|
||||
private:
|
||||
void releasePhonePacket();
|
||||
|
||||
|
||||
+151
-1
@@ -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,39 @@ 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.
|
||||
// Only the passphrase delivery command itself is whitelisted — it is its own auth gate.
|
||||
// Destructive commands (factory reset, nodedb reset) require an already-authorized
|
||||
// connection (passphrase verified or PKC admin key).
|
||||
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);
|
||||
#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 +136,15 @@ 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
|
||||
// Only authorize local PhoneAPI connections when the PKC admin message
|
||||
// came from a local client (from=0). A remote PKC admin (from!=0) has no
|
||||
// business unlocking local BLE/USB config dumps.
|
||||
if (mp.from == 0) {
|
||||
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 +896,113 @@ 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;
|
||||
|
||||
// MED-4: helper to zero the passphrase bytes in the decoded proto scratch buffer
|
||||
// before returning. Uses volatile to prevent the compiler from eliding the wipe.
|
||||
// const_cast is safe here: the underlying buffer is the mutable toRadioScratch member.
|
||||
auto zeroPassphrase = [&]() {
|
||||
volatile uint8_t *ppVol = const_cast<volatile uint8_t *>(pp);
|
||||
for (size_t zi = 0; zi < ppLen; zi++)
|
||||
ppVol[zi] = 0;
|
||||
};
|
||||
|
||||
// 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");
|
||||
zeroPassphrase(); // MED-4
|
||||
reboot(DEFAULT_REBOOT_SECONDS);
|
||||
return; // No config changed; skip saveChanges entirely
|
||||
}
|
||||
|
||||
// MED-8: cap to 32 bytes to match the proto private_key field size
|
||||
if (ppLen >= 1 && ppLen <= 32) {
|
||||
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");
|
||||
}
|
||||
zeroPassphrase(); // MED-4: wipe passphrase from scratch buffer before returning
|
||||
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)
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
#include "configuration.h"
|
||||
|
||||
#ifdef MESHTASTIC_ENABLE_APPROTECT
|
||||
#ifdef ARCH_NRF52
|
||||
|
||||
#include "APProtect.h"
|
||||
#include <nrf.h>
|
||||
|
||||
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
|
||||
@@ -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
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,198 @@
|
||||
#pragma once
|
||||
|
||||
#ifdef MESHTASTIC_ENCRYPTED_STORAGE
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
|
||||
/**
|
||||
* 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–32; matches the proto private_key field size)
|
||||
* @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–32; matches the proto private_key field size)
|
||||
* @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
|
||||
@@ -0,0 +1,40 @@
|
||||
; 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 — DEBUG_MUTE and module exclusions are derived automatically
|
||||
; from this single flag via configuration.h
|
||||
-DMESHTASTIC_LOCKDOWN=1
|
||||
|
||||
build_src_filter = ${nrf52_base.build_src_filter}
|
||||
+<../variants/nrf52840/rak4631>
|
||||
+<../variants/nrf52840/rak4631_lockdown>
|
||||
+<security/>
|
||||
-<graphics/EInkDisplay2.cpp>
|
||||
-<graphics/EInkDynamicDisplay.cpp>
|
||||
-<graphics/fonts/EinkDisplayFonts.cpp>
|
||||
|
||||
lib_deps =
|
||||
${nrf52840_base.lib_deps}
|
||||
melopero/Melopero RV3028@1.2.0
|
||||
rakwireless/RAKwireless NCP5623 RGB LED library@1.0.3
|
||||
@@ -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_
|
||||
Reference in New Issue
Block a user