diff --git a/.gitignore b/.gitignore index e3b915e..34165f2 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,14 @@ *.bin *.hex *.map +/release/ + +# Device backups and private MeshCore state +/device-spiffs-extracted/ +/device-spiffs*/ +/device-*.bin +/settings.dat +*.mcb # IDE .idea/ diff --git a/create-settings-backup.ps1 b/create-settings-backup.ps1 new file mode 100644 index 0000000..c41199e --- /dev/null +++ b/create-settings-backup.ps1 @@ -0,0 +1,64 @@ +param( + [Parameter(Mandatory = $true)] + [string]$Source, + + [Parameter(Mandatory = $true)] + [string]$Destination +) + +$ErrorActionPreference = 'Stop' + +Add-Type -TypeDefinition @' +public static class MeshCoreCrc32 +{ + public static uint Compute(byte[] data) + { + uint crc = 0xFFFFFFFFu; + foreach (byte value in data) + { + crc ^= value; + for (int bit = 0; bit < 8; bit++) + { + crc = (crc >> 1) ^ (0xEDB88320u & (uint)-(int)(crc & 1u)); + } + } + return ~crc; + } +} +'@ + +$sourcePath = (Resolve-Path -LiteralPath $Source).Path +$payload = [System.IO.File]::ReadAllBytes($sourcePath) +if ($payload.Length -lt 1 -or $payload.Length -gt 512) { + throw "Settings payload must contain between 1 and 512 bytes; found $($payload.Length)." +} + +$destinationPath = [System.IO.Path]::GetFullPath($Destination) +if ([System.IO.File]::Exists($destinationPath)) { + throw "Refusing to overwrite existing backup: $destinationPath" +} + +$stream = [System.IO.File]::Open($destinationPath, [System.IO.FileMode]::CreateNew, [System.IO.FileAccess]::Write) +try { + $magic = [byte[]](0x4D, 0x43, 0x50, 0x53) # MCPS + $version = [System.BitConverter]::GetBytes([uint16]1) + $length = [System.BitConverter]::GetBytes([uint16]$payload.Length) + $crc = [System.BitConverter]::GetBytes([MeshCoreCrc32]::Compute($payload)) + + $stream.Write($magic, 0, $magic.Length) + $stream.Write($version, 0, $version.Length) + $stream.Write($length, 0, $length.Length) + $stream.Write($crc, 0, $crc.Length) + $stream.Write($payload, 0, $payload.Length) + $stream.Flush($true) +} +finally { + $stream.Dispose() +} + +[pscustomobject]@{ + SourceBytes = $payload.Length + BackupBytes = (Get-Item -LiteralPath $destinationPath).Length + Crc32 = ('{0:X8}' -f [MeshCoreCrc32]::Compute($payload)) + Destination = $destinationPath +} diff --git a/docs/encrypted-sd-backup.md b/docs/encrypted-sd-backup.md new file mode 100644 index 0000000..7fa0fa7 --- /dev/null +++ b/docs/encrypted-sd-backup.md @@ -0,0 +1,54 @@ +# Encrypted SD backup and restore + +The Cardputer ADV companion firmware can save an authenticated, encrypted +snapshot of its complete SPIFFS partition to a microSD card. The snapshot +includes node and radio settings, channels and channel secrets, contacts, +messages, the node identity/private key, and other SPIFFS-managed state. UI +brightness and theme preferences are included separately inside the same +encrypted archive. + +## Create a backup + +1. Insert a FAT-formatted microSD card. +2. Open **Settings > SD Backup > Encrypted backup**. +3. Enter a passphrase of at least eight characters. +4. Enter the same passphrase again to confirm it. + +The firmware writes the archive to `/meshcore/full-backup.mcb`. An existing +archive is replaced only after the new temporary file has been written +successfully. + +Keep both the archive and passphrase secure. The passphrase cannot be recovered, +and the archive contains the node identity and channel secrets needed to assume +the backed-up node's identity. + +## Restore a backup + +1. Insert the microSD card containing `/meshcore/full-backup.mcb`. +2. Open **Settings > SD Backup > Full restore**. +3. Enter the backup passphrase. + +The firmware authenticates the complete archive and checks that its recorded +SPIFFS partition size matches the device before changing flash. A wrong +passphrase, modified archive, or incompatible partition layout is rejected. +After a successful restore, the device restarts automatically. + +Restore replaces the complete SPIFFS snapshot. Use an archive created with the +same partition layout and keep a separate copy of important backups. + +## Archive protection + +- AES-256-GCM provides encryption and tamper detection. +- PBKDF2-HMAC-SHA-256 derives the key from the passphrase using 100,000 + iterations and a random 16-byte salt. +- Each archive uses a random 12-byte nonce and a 16-byte authentication tag. +- The passphrase and derived-key buffers are cleared after use. + +The identity/private key is encrypted, not hashed. A one-way hash could verify +data but could not restore the identity. + +## M5Launcher installation + +When installing a firmware update through M5Launcher, do not install or replace +SPIFFS if you want to preserve the live settings already on the device. The +backup archive itself remains on the microSD card. diff --git a/examples/companion_radio/DataStore.cpp b/examples/companion_radio/DataStore.cpp index fb04bc4..6668982 100644 --- a/examples/companion_radio/DataStore.cpp +++ b/examples/companion_radio/DataStore.cpp @@ -65,6 +65,14 @@ void DataStore::begin() { #if defined(ESP32) #include +#if defined(M5STACK_CARDPUTER) + #include + #include + #include + #include + #include + #include +#endif #elif defined(RP2040_PLATFORM) #include #elif defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) @@ -265,6 +273,450 @@ void DataStore::savePrefs(const NodePrefs& _prefs, double node_lat, double node_ } } +#if defined(ESP32) +namespace { +constexpr size_t MAX_PREFS_FILE_SIZE = 512; +constexpr uint8_t PREFS_BACKUP_MAGIC[4] = {'M', 'C', 'P', 'S'}; +constexpr uint16_t PREFS_BACKUP_VERSION = 1; + +uint32_t prefsCrc32(const uint8_t* data, size_t length) { + uint32_t crc = 0xFFFFFFFFu; + for (size_t i = 0; i < length; ++i) { + crc ^= data[i]; + for (uint8_t bit = 0; bit < 8; ++bit) { + crc = (crc >> 1) ^ (0xEDB88320u & (0u - (crc & 1u))); + } + } + return ~crc; +} + +bool readExact(File& file, uint8_t* buffer, size_t length) { + return file.read(buffer, length) == length; +} +} + +DataStore::PrefsBackupResult DataStore::backupPrefs(FILESYSTEM& destination, const char* filename) { + File source = openRead(_fs, "/new_prefs"); + if (!source) return PrefsBackupResult::SOURCE_NOT_FOUND; + const size_t payload_size = source.size(); + if (payload_size == 0 || payload_size > MAX_PREFS_FILE_SIZE) { + source.close(); + return PrefsBackupResult::READ_FAILED; + } + + uint8_t payload[MAX_PREFS_FILE_SIZE]; + if (!readExact(source, payload, payload_size)) { + source.close(); + return PrefsBackupResult::READ_FAILED; + } + source.close(); + + File backup = destination.open(filename, "w"); + if (!backup) return PrefsBackupResult::WRITE_FAILED; + + const uint16_t payload_length = payload_size; + const uint32_t crc = prefsCrc32(payload, payload_size); + bool success = backup.write(PREFS_BACKUP_MAGIC, sizeof(PREFS_BACKUP_MAGIC)) == sizeof(PREFS_BACKUP_MAGIC); + success = success && backup.write(reinterpret_cast(&PREFS_BACKUP_VERSION), sizeof(PREFS_BACKUP_VERSION)) == sizeof(PREFS_BACKUP_VERSION); + success = success && backup.write(reinterpret_cast(&payload_length), sizeof(payload_length)) == sizeof(payload_length); + success = success && backup.write(reinterpret_cast(&crc), sizeof(crc)) == sizeof(crc); + success = success && backup.write(payload, payload_size) == payload_size; + backup.flush(); + backup.close(); + + if (!success) { + destination.remove(filename); + return PrefsBackupResult::WRITE_FAILED; + } + return PrefsBackupResult::OK; +} + +DataStore::PrefsBackupResult DataStore::restorePrefs(FILESYSTEM& source, const char* filename) { + File backup = source.open(filename, "r"); + if (!backup) return PrefsBackupResult::SOURCE_NOT_FOUND; + + uint8_t magic[sizeof(PREFS_BACKUP_MAGIC)]; + uint16_t version = 0; + uint16_t payload_length = 0; + uint32_t stored_crc = 0; + uint8_t payload[MAX_PREFS_FILE_SIZE]; + + bool success = readExact(backup, magic, sizeof(magic)); + success = success && readExact(backup, reinterpret_cast(&version), sizeof(version)); + success = success && readExact(backup, reinterpret_cast(&payload_length), sizeof(payload_length)); + success = success && readExact(backup, reinterpret_cast(&stored_crc), sizeof(stored_crc)); + if (!success || memcmp(magic, PREFS_BACKUP_MAGIC, sizeof(magic)) != 0 || + version != PREFS_BACKUP_VERSION || payload_length == 0 || payload_length > sizeof(payload) || + backup.size() != sizeof(magic) + sizeof(version) + sizeof(payload_length) + sizeof(stored_crc) + payload_length) { + backup.close(); + return PrefsBackupResult::INVALID_BACKUP; + } + success = readExact(backup, payload, payload_length); + backup.close(); + if (!success || prefsCrc32(payload, payload_length) != stored_crc) { + return PrefsBackupResult::INVALID_BACKUP; + } + + const char* temporary = "/new_prefs.tmp"; + const char* previous = "/new_prefs.bak"; + if (_fs->exists(temporary)) _fs->remove(temporary); + File restored = openWrite(_fs, temporary); + if (!restored) return PrefsBackupResult::WRITE_FAILED; + success = restored.write(payload, payload_length) == payload_length; + restored.flush(); + restored.close(); + if (!success) { + if (_fs->exists(temporary)) _fs->remove(temporary); + return PrefsBackupResult::WRITE_FAILED; + } + + if (_fs->exists(previous)) _fs->remove(previous); + bool had_current = _fs->exists("/new_prefs"); + if (had_current && !_fs->rename("/new_prefs", previous)) { + if (_fs->exists(temporary)) _fs->remove(temporary); + return PrefsBackupResult::WRITE_FAILED; + } + if (!_fs->rename(temporary, "/new_prefs")) { + if (had_current) _fs->rename(previous, "/new_prefs"); + if (_fs->exists(temporary)) _fs->remove(temporary); + return PrefsBackupResult::WRITE_FAILED; + } + if (_fs->exists(previous)) _fs->remove(previous); + return PrefsBackupResult::OK; +} + +#if defined(M5STACK_CARDPUTER) +namespace { +constexpr uint8_t FULL_BACKUP_MAGIC[4] = {'M', 'C', 'F', 'B'}; +constexpr uint16_t FULL_BACKUP_VERSION = 1; +constexpr uint32_t FULL_BACKUP_PBKDF2_ITERATIONS = 100000; +constexpr size_t FULL_BACKUP_CHUNK = 1024; +constexpr size_t FULL_BACKUP_TAG_SIZE = 16; + +#pragma pack(push, 1) +struct FullBackupHeader { + uint8_t magic[4]; + uint16_t version; + uint16_t header_size; + uint32_t iterations; + uint32_t partition_size; + uint32_t plaintext_size; + uint8_t salt[16]; + uint8_t nonce[12]; +}; + +struct FullBackupPrefix { + uint8_t magic[4]; + uint8_t version; + uint8_t brightness; + uint8_t main_color; + uint8_t secondary_color; + uint8_t reserved[8]; +}; +#pragma pack(pop) + +static_assert(sizeof(FullBackupHeader) == 48, "Unexpected backup header size"); +static_assert(sizeof(FullBackupPrefix) == 16, "Unexpected backup prefix size"); + +void secureClear(void* data, size_t length) { + volatile uint8_t* bytes = static_cast(data); + while (length--) *bytes++ = 0; +} + +bool constantTimeEqual(const uint8_t* left, const uint8_t* right, size_t length) { + uint8_t difference = 0; + for (size_t i = 0; i < length; ++i) difference |= left[i] ^ right[i]; + return difference == 0; +} + +bool deriveBackupKey(const char* passphrase, const FullBackupHeader& header, uint8_t key[32]) { + if (!passphrase || !passphrase[0]) return false; + const mbedtls_md_info_t* info = mbedtls_md_info_from_type(MBEDTLS_MD_SHA256); + if (!info) return false; + mbedtls_md_context_t md; + mbedtls_md_init(&md); + int result = mbedtls_md_setup(&md, info, 1); + if (result == 0) { + result = mbedtls_pkcs5_pbkdf2_hmac( + &md, reinterpret_cast(passphrase), strlen(passphrase), + header.salt, sizeof(header.salt), header.iterations, 32, key); + } + mbedtls_md_free(&md); + return result == 0; +} + +bool readUIBackupPrefix(FullBackupPrefix& prefix) { + memcpy(prefix.magic, "MCUI", 4); + prefix.version = 1; + prefix.brightness = 128; + prefix.main_color = 0; + prefix.secondary_color = 1; + memset(prefix.reserved, 0, sizeof(prefix.reserved)); + Preferences preferences; + // A fresh device may not have created this optional namespace yet. In that + // case, preserve the same defaults used by UITask::loadSettings(). + if (!preferences.begin("ui_settings", true)) return true; + prefix.brightness = preferences.getUChar("brightness", 128); + prefix.main_color = preferences.getUChar("main_color", 0); + prefix.secondary_color = preferences.getUChar("sec_color", 1); + preferences.end(); + return true; +} + +bool writeUIBackupPrefix(const FullBackupPrefix& prefix) { + Preferences preferences; + if (!preferences.begin("ui_settings", false)) return false; + bool success = preferences.putUChar("brightness", prefix.brightness) == 1; + success = success && preferences.putUChar("main_color", prefix.main_color) == 1; + success = success && preferences.putUChar("sec_color", prefix.secondary_color) == 1; + preferences.end(); + return success; +} + +const esp_partition_t* findSPIFFSPartition() { + return esp_partition_find_first(ESP_PARTITION_TYPE_DATA, ESP_PARTITION_SUBTYPE_DATA_SPIFFS, nullptr); +} + +bool validFullBackupHeader(const FullBackupHeader& header, size_t file_size, uint32_t partition_size) { + return memcmp(header.magic, FULL_BACKUP_MAGIC, sizeof(header.magic)) == 0 && + header.version == FULL_BACKUP_VERSION && header.header_size == sizeof(header) && + header.iterations >= 10000 && header.iterations <= 1000000 && + header.partition_size == partition_size && + header.plaintext_size == sizeof(FullBackupPrefix) + partition_size && + file_size == sizeof(header) + header.plaintext_size + FULL_BACKUP_TAG_SIZE; +} + +DataStore::FullBackupResult authenticateFullBackup( + FILESYSTEM& source, const char* filename, const esp_partition_t* partition, + const char* passphrase, FullBackupHeader& header, FullBackupPrefix& prefix, + uint8_t key[32]) { + File input = source.open(filename, "r"); + if (!input) return DataStore::FullBackupResult::SOURCE_NOT_FOUND; + if (!readExact(input, reinterpret_cast(&header), sizeof(header))) { + input.close(); + return DataStore::FullBackupResult::READ_FAILED; + } + if (memcmp(header.magic, FULL_BACKUP_MAGIC, sizeof(header.magic)) != 0 || + header.version != FULL_BACKUP_VERSION || header.header_size != sizeof(header)) { + input.close(); + return DataStore::FullBackupResult::INVALID_BACKUP; + } + if (header.partition_size != partition->size) { + input.close(); + return DataStore::FullBackupResult::INCOMPATIBLE_BACKUP; + } + if (!validFullBackupHeader(header, input.size(), partition->size)) { + input.close(); + return DataStore::FullBackupResult::INVALID_BACKUP; + } + if (!deriveBackupKey(passphrase, header, key)) { + input.close(); + return DataStore::FullBackupResult::AUTH_FAILED; + } + + mbedtls_gcm_context gcm; + mbedtls_gcm_init(&gcm); + int crypto_result = mbedtls_gcm_setkey(&gcm, MBEDTLS_CIPHER_ID_AES, key, 256); + if (crypto_result == 0) { + crypto_result = mbedtls_gcm_starts(&gcm, MBEDTLS_GCM_DECRYPT, header.nonce, + sizeof(header.nonce), reinterpret_cast(&header), sizeof(header)); + } + + uint8_t encrypted[FULL_BACKUP_CHUNK]; + uint8_t plaintext[FULL_BACKUP_CHUNK]; + uint32_t remaining = header.plaintext_size; + bool got_prefix = false; + while (crypto_result == 0 && remaining > 0) { + size_t length = min(static_cast(sizeof(encrypted)), remaining); + if (!readExact(input, encrypted, length)) { + crypto_result = -1; + break; + } + crypto_result = mbedtls_gcm_update(&gcm, length, encrypted, plaintext); + if (!got_prefix && crypto_result == 0) { + memcpy(&prefix, plaintext, sizeof(prefix)); + got_prefix = true; + } + remaining -= length; + } + uint8_t calculated_tag[FULL_BACKUP_TAG_SIZE]; + uint8_t stored_tag[FULL_BACKUP_TAG_SIZE]; + if (crypto_result == 0) crypto_result = mbedtls_gcm_finish(&gcm, calculated_tag, sizeof(calculated_tag)); + bool tag_read = readExact(input, stored_tag, sizeof(stored_tag)); + input.close(); + mbedtls_gcm_free(&gcm); + secureClear(encrypted, sizeof(encrypted)); + secureClear(plaintext, sizeof(plaintext)); + + if (crypto_result != 0 || !tag_read || !constantTimeEqual(calculated_tag, stored_tag, sizeof(stored_tag))) { + secureClear(calculated_tag, sizeof(calculated_tag)); + secureClear(stored_tag, sizeof(stored_tag)); + secureClear(key, 32); + return DataStore::FullBackupResult::AUTH_FAILED; + } + secureClear(calculated_tag, sizeof(calculated_tag)); + secureClear(stored_tag, sizeof(stored_tag)); + if (!got_prefix || memcmp(prefix.magic, "MCUI", 4) != 0 || prefix.version != 1) { + secureClear(key, 32); + return DataStore::FullBackupResult::INVALID_BACKUP; + } + return DataStore::FullBackupResult::OK; +} +} + +DataStore::FullBackupResult DataStore::backupFullEncrypted( + FILESYSTEM& destination, const char* filename, const char* passphrase) { + const esp_partition_t* partition = findSPIFFSPartition(); + if (!partition) return FullBackupResult::READ_FAILED; + + FullBackupHeader header{}; + memcpy(header.magic, FULL_BACKUP_MAGIC, sizeof(header.magic)); + header.version = FULL_BACKUP_VERSION; + header.header_size = sizeof(header); + header.iterations = FULL_BACKUP_PBKDF2_ITERATIONS; + header.partition_size = partition->size; + header.plaintext_size = sizeof(FullBackupPrefix) + partition->size; + esp_fill_random(header.salt, sizeof(header.salt)); + esp_fill_random(header.nonce, sizeof(header.nonce)); + + uint8_t key[32]; + if (!deriveBackupKey(passphrase, header, key)) return FullBackupResult::WRITE_FAILED; + FullBackupPrefix prefix{}; + if (!readUIBackupPrefix(prefix)) { + secureClear(key, sizeof(key)); + return FullBackupResult::READ_FAILED; + } + + String temporary = String(filename) + ".tmp"; + String previous = String(filename) + ".bak"; + if (destination.exists(temporary)) destination.remove(temporary); + File output = destination.open(temporary, "w"); + if (!output) { + secureClear(key, sizeof(key)); + return FullBackupResult::WRITE_FAILED; + } + + bool success = output.write(reinterpret_cast(&header), sizeof(header)) == sizeof(header); + mbedtls_gcm_context gcm; + mbedtls_gcm_init(&gcm); + int crypto_result = mbedtls_gcm_setkey(&gcm, MBEDTLS_CIPHER_ID_AES, key, 256); + if (crypto_result == 0) { + crypto_result = mbedtls_gcm_starts(&gcm, MBEDTLS_GCM_ENCRYPT, header.nonce, + sizeof(header.nonce), reinterpret_cast(&header), sizeof(header)); + } + + uint8_t encrypted[FULL_BACKUP_CHUNK]; + crypto_result = crypto_result == 0 + ? mbedtls_gcm_update(&gcm, sizeof(prefix), reinterpret_cast(&prefix), encrypted) + : crypto_result; + success = success && crypto_result == 0 && output.write(encrypted, sizeof(prefix)) == sizeof(prefix); + + uint8_t plaintext[FULL_BACKUP_CHUNK]; + for (uint32_t offset = 0; success && offset < partition->size; offset += sizeof(plaintext)) { + size_t length = min(static_cast(sizeof(plaintext)), partition->size - offset); + if (esp_partition_read(partition, offset, plaintext, length) != ESP_OK || + mbedtls_gcm_update(&gcm, length, plaintext, encrypted) != 0 || + output.write(encrypted, length) != length) { + success = false; + } + } + uint8_t tag[FULL_BACKUP_TAG_SIZE]; + if (success && mbedtls_gcm_finish(&gcm, tag, sizeof(tag)) == 0) { + success = output.write(tag, sizeof(tag)) == sizeof(tag); + } else { + success = false; + } + output.flush(); + output.close(); + mbedtls_gcm_free(&gcm); + secureClear(key, sizeof(key)); + secureClear(plaintext, sizeof(plaintext)); + secureClear(encrypted, sizeof(encrypted)); + secureClear(tag, sizeof(tag)); + + if (!success) { + if (destination.exists(temporary)) destination.remove(temporary); + return FullBackupResult::WRITE_FAILED; + } + if (destination.exists(previous)) destination.remove(previous); + bool had_current = destination.exists(filename); + if (had_current && !destination.rename(filename, previous)) { + destination.remove(temporary); + return FullBackupResult::WRITE_FAILED; + } + if (!destination.rename(temporary, filename)) { + if (had_current) destination.rename(previous, filename); + destination.remove(temporary); + return FullBackupResult::WRITE_FAILED; + } + if (destination.exists(previous)) destination.remove(previous); + return FullBackupResult::OK; +} + +DataStore::FullBackupResult DataStore::restoreFullEncrypted( + FILESYSTEM& source, const char* filename, const char* passphrase) { + const esp_partition_t* partition = findSPIFFSPartition(); + if (!partition) return FullBackupResult::WRITE_FAILED; + + FullBackupHeader header{}; + FullBackupPrefix prefix{}; + uint8_t key[32]; + FullBackupResult authenticated = authenticateFullBackup( + source, filename, partition, passphrase, header, prefix, key); + if (authenticated != FullBackupResult::OK) return authenticated; + + File input = source.open(filename, "r"); + if (!input || !input.seek(sizeof(header))) { + if (input) input.close(); + secureClear(key, sizeof(key)); + return FullBackupResult::READ_FAILED; + } + mbedtls_gcm_context gcm; + mbedtls_gcm_init(&gcm); + int crypto_result = mbedtls_gcm_setkey(&gcm, MBEDTLS_CIPHER_ID_AES, key, 256); + if (crypto_result == 0) { + crypto_result = mbedtls_gcm_starts(&gcm, MBEDTLS_GCM_DECRYPT, header.nonce, + sizeof(header.nonce), reinterpret_cast(&header), sizeof(header)); + } + + uint8_t encrypted[FULL_BACKUP_CHUNK]; + uint8_t plaintext[FULL_BACKUP_CHUNK]; + if (!readExact(input, encrypted, sizeof(prefix)) || + mbedtls_gcm_update(&gcm, sizeof(prefix), encrypted, plaintext) != 0) { + crypto_result = -1; + } + + // Authentication above completed before this destructive point. + SPIFFS.end(); + if (crypto_result == 0 && esp_partition_erase_range(partition, 0, partition->size) != ESP_OK) { + crypto_result = -1; + } + for (uint32_t offset = 0; crypto_result == 0 && offset < partition->size; offset += sizeof(plaintext)) { + size_t length = min(static_cast(sizeof(plaintext)), partition->size - offset); + if (!readExact(input, encrypted, length) || + mbedtls_gcm_update(&gcm, length, encrypted, plaintext) != 0 || + esp_partition_write(partition, offset, plaintext, length) != ESP_OK) { + crypto_result = -1; + } + } + uint8_t calculated_tag[FULL_BACKUP_TAG_SIZE]; + uint8_t stored_tag[FULL_BACKUP_TAG_SIZE]; + if (crypto_result == 0) crypto_result = mbedtls_gcm_finish(&gcm, calculated_tag, sizeof(calculated_tag)); + bool tag_read = readExact(input, stored_tag, sizeof(stored_tag)); + input.close(); + mbedtls_gcm_free(&gcm); + bool success = crypto_result == 0 && tag_read && + constantTimeEqual(calculated_tag, stored_tag, sizeof(stored_tag)) && writeUIBackupPrefix(prefix); + secureClear(key, sizeof(key)); + secureClear(plaintext, sizeof(plaintext)); + secureClear(encrypted, sizeof(encrypted)); + secureClear(calculated_tag, sizeof(calculated_tag)); + secureClear(stored_tag, sizeof(stored_tag)); + return success ? FullBackupResult::OK : FullBackupResult::WRITE_FAILED; +} +#endif +#endif + void DataStore::loadContacts(DataStoreHost* host) { File file = openRead(_getContactsChannelsFS(), "/contacts3"); if (file) { diff --git a/examples/companion_radio/DataStore.h b/examples/companion_radio/DataStore.h index 6258094..17faaea 100644 --- a/examples/companion_radio/DataStore.h +++ b/examples/companion_radio/DataStore.h @@ -25,6 +25,24 @@ class DataStore { #endif public: + enum class PrefsBackupResult { + OK, + SOURCE_NOT_FOUND, + INVALID_BACKUP, + READ_FAILED, + WRITE_FAILED + }; + + enum class FullBackupResult { + OK, + SOURCE_NOT_FOUND, + INVALID_BACKUP, + AUTH_FAILED, + INCOMPATIBLE_BACKUP, + READ_FAILED, + WRITE_FAILED + }; + DataStore(FILESYSTEM& fs, mesh::RTCClock& clock); DataStore(FILESYSTEM& fs, FILESYSTEM& fsExtra, mesh::RTCClock& clock); void begin(); @@ -35,6 +53,14 @@ class DataStore { bool saveMainIdentity(const mesh::LocalIdentity &identity); void loadPrefs(NodePrefs& prefs, double& node_lat, double& node_lon); void savePrefs(const NodePrefs& prefs, double node_lat, double node_lon); +#if defined(ESP32) + PrefsBackupResult backupPrefs(FILESYSTEM& destination, const char* filename); + PrefsBackupResult restorePrefs(FILESYSTEM& source, const char* filename); +#if defined(M5STACK_CARDPUTER) + FullBackupResult backupFullEncrypted(FILESYSTEM& destination, const char* filename, const char* passphrase); + FullBackupResult restoreFullEncrypted(FILESYSTEM& source, const char* filename, const char* passphrase); +#endif +#endif void loadContacts(DataStoreHost* host); void saveContacts(DataStoreHost* host); void loadChannels(DataStoreHost* host); diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index 0984a63..05b2872 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -3,6 +3,103 @@ #include // needed for PlatformIO #include +#if defined(M5STACK_CARDPUTER) + #include + + #ifndef P_SDCARD_CS + #define P_SDCARD_CS 12 + #endif +#endif + +#if defined(M5STACK_CARDPUTER) +namespace { +constexpr const char* SD_PREFS_DIRECTORY = "/meshcore"; +constexpr const char* SD_PREFS_FILENAME = "/meshcore/settings.dat"; +constexpr const char* SD_FULL_BACKUP_FILENAME = "/meshcore/full-backup.mcb"; + +MyMesh::SDPrefsResult mapPrefsResult(DataStore::PrefsBackupResult result) { + switch (result) { + case DataStore::PrefsBackupResult::OK: + return MyMesh::SDPrefsResult::OK; + case DataStore::PrefsBackupResult::SOURCE_NOT_FOUND: + return MyMesh::SDPrefsResult::BACKUP_NOT_FOUND; + case DataStore::PrefsBackupResult::INVALID_BACKUP: + return MyMesh::SDPrefsResult::INVALID_BACKUP; + default: + return MyMesh::SDPrefsResult::IO_ERROR; + } +} + +MyMesh::SDPrefsResult mapFullBackupResult(DataStore::FullBackupResult result) { + switch (result) { + case DataStore::FullBackupResult::OK: + return MyMesh::SDPrefsResult::OK; + case DataStore::FullBackupResult::SOURCE_NOT_FOUND: + return MyMesh::SDPrefsResult::BACKUP_NOT_FOUND; + case DataStore::FullBackupResult::INVALID_BACKUP: + return MyMesh::SDPrefsResult::INVALID_BACKUP; + case DataStore::FullBackupResult::AUTH_FAILED: + return MyMesh::SDPrefsResult::AUTH_FAILED; + case DataStore::FullBackupResult::INCOMPATIBLE_BACKUP: + return MyMesh::SDPrefsResult::INCOMPATIBLE_BACKUP; + case DataStore::FullBackupResult::READ_FAILED: + return MyMesh::SDPrefsResult::READ_ERROR; + case DataStore::FullBackupResult::WRITE_FAILED: + return MyMesh::SDPrefsResult::WRITE_ERROR; + default: + return MyMesh::SDPrefsResult::IO_ERROR; + } +} +} + +MyMesh::SDPrefsResult MyMesh::backupPrefsToSD() { + if (!SD.begin(P_SDCARD_CS, spi, 10000000)) { + return SDPrefsResult::CARD_UNAVAILABLE; + } + if (!SD.exists(SD_PREFS_DIRECTORY) && !SD.mkdir(SD_PREFS_DIRECTORY)) { + SD.end(); + return SDPrefsResult::IO_ERROR; + } + SDPrefsResult result = mapPrefsResult(_store->backupPrefs(SD, SD_PREFS_FILENAME)); + SD.end(); + return result; +} + +MyMesh::SDPrefsResult MyMesh::restorePrefsFromSD() { + if (!SD.begin(P_SDCARD_CS, spi, 10000000)) { + return SDPrefsResult::CARD_UNAVAILABLE; + } + SDPrefsResult result = mapPrefsResult(_store->restorePrefs(SD, SD_PREFS_FILENAME)); + SD.end(); + return result; +} + + +MyMesh::SDPrefsResult MyMesh::backupFullToSD(const char* passphrase) { + if (!SD.begin(P_SDCARD_CS, spi, 10000000)) { + return SDPrefsResult::CARD_UNAVAILABLE; + } + if (!SD.exists(SD_PREFS_DIRECTORY) && !SD.mkdir(SD_PREFS_DIRECTORY)) { + SD.end(); + return SDPrefsResult::IO_ERROR; + } + SDPrefsResult result = mapFullBackupResult( + _store->backupFullEncrypted(SD, SD_FULL_BACKUP_FILENAME, passphrase)); + SD.end(); + return result; +} + +MyMesh::SDPrefsResult MyMesh::restoreFullFromSD(const char* passphrase) { + if (!SD.begin(P_SDCARD_CS, spi, 10000000)) { + return SDPrefsResult::CARD_UNAVAILABLE; + } + SDPrefsResult result = mapFullBackupResult( + _store->restoreFullEncrypted(SD, SD_FULL_BACKUP_FILENAME, passphrase)); + SD.end(); + return result; +} +#endif + #define CMD_APP_START 1 #define CMD_SEND_TXT_MSG 2 #define CMD_SEND_CHANNEL_TXT_MSG 3 @@ -1940,4 +2037,4 @@ bool MyMesh::advert() { } else { return false; } -} \ No newline at end of file +} diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index facde03..2990dd8 100644 --- a/examples/companion_radio/MyMesh.h +++ b/examples/companion_radio/MyMesh.h @@ -156,7 +156,25 @@ class MyMesh : public BaseChatMesh, public DataStoreHost { } public: + enum class SDPrefsResult { + OK, + CARD_UNAVAILABLE, + BACKUP_NOT_FOUND, + INVALID_BACKUP, + AUTH_FAILED, + INCOMPATIBLE_BACKUP, + READ_ERROR, + WRITE_ERROR, + IO_ERROR + }; + void savePrefs() { _store->savePrefs(_prefs, sensors.node_lat, sensors.node_lon); } +#if defined(M5STACK_CARDPUTER) + SDPrefsResult backupPrefsToSD(); + SDPrefsResult restorePrefsFromSD(); + SDPrefsResult backupFullToSD(const char* passphrase); + SDPrefsResult restoreFullFromSD(const char* passphrase); +#endif void factoryReset() { _store->formatFileSystem(); } void saveContacts() { _store->saveContacts(this); } void saveChannels() { _store->saveChannels(this); } diff --git a/examples/companion_radio/ui-keyboard/UITask.cpp b/examples/companion_radio/ui-keyboard/UITask.cpp index b3f047a..1ded792 100644 --- a/examples/companion_radio/ui-keyboard/UITask.cpp +++ b/examples/companion_radio/ui-keyboard/UITask.cpp @@ -46,6 +46,11 @@ class QRcode_M5GFX : public QRcodeDisplay { extern MyMesh the_mesh; +static void clearSensitiveText(char* text, size_t length) { + volatile char* cursor = text; + while (length--) *cursor++ = 0; +} + UITask::UITask(mesh::MainBoard* board, BaseSerialInterface* serial_interface) : AbstractUITask(board, serial_interface), _display(nullptr), _menu_state(MenuScreen::CONTACTS), _next_refresh(0), _auto_off(0), @@ -57,6 +62,7 @@ UITask::UITask(mesh::MainBoard* board, BaseSerialInterface* serial_interface) _last_backspace_delete(0), _delete_processed(false), _settings_selected(false), _settings_category(SettingsCategory::MAIN_MENU), _settings_menu_idx(0), _settings_item_idx(0), _settings_scroll_pos(0), _public_info_scroll_pos(0), _radio_preset_scroll_pos(0), _radio_setup_scroll_pos(0), _editing_name(false), _show_qr_code(false), _edit_buffer_length(0), + _backup_password_mode(BackupPasswordMode::NONE), _editing_frequency(false), _editing_bandwidth(false), _editing_spreading_factor(false), _editing_coding_rate(false), _editing_tx_power(false), _manual_setup_step(-1), _show_factory_reset_confirm(false), _brightness(128), _main_color_idx(0), _secondary_color_idx(1) { @@ -64,6 +70,7 @@ UITask::UITask(mesh::MainBoard* board, BaseSerialInterface* serial_interface) _input_buffer[0] = '\0'; _search_filter[0] = '\0'; _edit_buffer[0] = '\0'; + _backup_passphrase[0] = '\0'; _notification_from[0] = '\0'; _notification_text[0] = '\0'; _last_read_channel[0] = '\0'; @@ -219,6 +226,11 @@ void UITask::loop() { _edit_buffer_length--; _edit_buffer[_edit_buffer_length] = '\0'; _need_refresh = true; + } else if (_menu_state == MenuScreen::SETTINGS && + _backup_password_mode != BackupPasswordMode::NONE && _edit_buffer_length > 0) { + _edit_buffer_length--; + _edit_buffer[_edit_buffer_length] = '\0'; + _need_refresh = true; } else if (_menu_state == MenuScreen::SETTINGS && (_editing_frequency || _editing_bandwidth || _editing_spreading_factor || _editing_coding_rate || _editing_tx_power) && _edit_buffer_length > 0) { _edit_buffer_length--; _edit_buffer[_edit_buffer_length] = '\0'; @@ -959,6 +971,31 @@ void UITask::renderSettingsMenu() { // Clear screen background (reduces flicker vs clearing every frame) _display->setColor(DisplayDriver::DARK); _display->fillRect(0, 0, 240, 135); + + if (_backup_password_mode != BackupPasswordMode::NONE) { + const char* title = _backup_password_mode == BackupPasswordMode::BACKUP_CONFIRM + ? "Confirm password" : "Backup password"; + const char* action = _backup_password_mode == BackupPasswordMode::RESTORE_ENTER + ? "Decrypt and restore" : "Encrypt full backup"; + _display->setColor(DisplayDriver::LIGHT); + _display->setTextSize(2); + _display->setCursor(8, 8); + _display->print(title); + _display->setTextSize(1); + _display->setCursor(8, 34); + _display->print(action); + _display->drawRect(8, 51, 224, 31); + _display->setTextSize(2); + _display->setCursor(14, 59); + int shown = min(_edit_buffer_length, 24); + for (int i = 0; i < shown; ++i) _display->print("*"); + _display->setTextSize(1); + _display->setCursor(8, 91); + _display->print("Minimum 8 characters"); + _display->setCursor(8, 119); + _display->print("Enter: continue Opt: cancel"); + return; + } // Header bar (0, 0, 240, 28) - similar to main menu _display->setColor(DisplayDriver::LIGHT); @@ -975,8 +1012,8 @@ void UITask::renderSettingsMenu() { _display->print("Settings"); // Show categories list (max 3 visible at once, like contact/channel lists) - const char* categories[] = {"Public Info", "Radio Setup", "Theme", "Other", "Device Info"}; - int num_categories = 5; + const char* categories[] = {"Public Info", "Radio Setup", "Theme", "Other", "Device Info", "SD Backup"}; + int num_categories = 6; // Render 3 category items (y: 27, 54, 81) int y_positions[3] = {27, 54, 81}; @@ -1326,6 +1363,27 @@ void UITask::renderSettingsMenu() { _display->print(RADIO_PRESETS[preset_idx].name); } + } else if (_settings_category == SettingsCategory::SD_BACKUP) { + _display->setCursor(72, 7); + _display->print("SD Backup"); + + const char* options[] = {"Encrypted backup", "Full restore"}; + int y_positions[2] = {35, 70}; + for (int i = 0; i < 2; ++i) { + int y = y_positions[i]; + _display->setColor(DisplayDriver::LIGHT); + _display->drawRect(0, y, 240, 29); + if (_settings_item_idx == i && _settings_menu_idx != 1) { + _display->fillRect(0, y, 240, 29); + _display->setColor(DisplayDriver::DARK); + _display->setCursor(2, y + 7); + _display->setTextSize(2); + _display->print(">"); + } + _display->setTextSize(2); + _display->setCursor(16, y + 7); + _display->print(options[i]); + } } else if (_settings_category == SettingsCategory::DEVICE_INFO) { _display->setCursor(67, 7); _display->print("Device Info"); @@ -2060,6 +2118,134 @@ void UITask::renderNotification() { _display->print(hint); } void UITask::handleKeyPress(Keyboard_Class::KeysState& status) { + if (_menu_state == MenuScreen::SETTINGS && _backup_password_mode != BackupPasswordMode::NONE) { + auto finishPasswordEntry = [this]() { + clearSensitiveText(_backup_passphrase, sizeof(_backup_passphrase)); + clearSensitiveText(_edit_buffer, sizeof(_edit_buffer)); + _edit_buffer_length = 0; + _backup_password_mode = BackupPasswordMode::NONE; + _backspace_hold_start = 0; + _backspace_was_held = false; + }; + auto showBackupMessage = [this](const char* message) { + strncpy(_notification_from, "Encrypted backup", sizeof(_notification_from) - 1); + _notification_from[sizeof(_notification_from) - 1] = '\0'; + strncpy(_notification_text, message, sizeof(_notification_text) - 1); + _notification_text[sizeof(_notification_text) - 1] = '\0'; + _notification_expiry = millis() + 2500; + _has_notification = true; + }; + + if (status.opt) { + finishPasswordEntry(); + return; + } + if (status.del) { + if (_backspace_hold_start == 0) { + _backspace_hold_start = millis(); + _backspace_was_held = false; + } + if (_edit_buffer_length > 0) { + _edit_buffer[--_edit_buffer_length] = '\0'; + } + return; + } + if (status.enter) { + if (_edit_buffer_length < 8) { + showBackupMessage("Use at least 8 characters"); + return; + } + if (_backup_password_mode == BackupPasswordMode::BACKUP_ENTER) { + memcpy(_backup_passphrase, _edit_buffer, _edit_buffer_length + 1); + clearSensitiveText(_edit_buffer, sizeof(_edit_buffer)); + _edit_buffer_length = 0; + _backup_password_mode = BackupPasswordMode::BACKUP_CONFIRM; + return; + } + if (_backup_password_mode == BackupPasswordMode::BACKUP_CONFIRM && + strcmp(_backup_passphrase, _edit_buffer) != 0) { + clearSensitiveText(_edit_buffer, sizeof(_edit_buffer)); + _edit_buffer_length = 0; + _backup_password_mode = BackupPasswordMode::BACKUP_ENTER; + showBackupMessage("Passwords differ - retry"); + return; + } + + bool restoring = _backup_password_mode == BackupPasswordMode::RESTORE_ENTER; + const char* password = restoring ? _edit_buffer : _backup_passphrase; + _display->startFrame(); + _display->setColor(DisplayDriver::DARK); + _display->fillRect(0, 0, 240, 135); + _display->setColor(DisplayDriver::LIGHT); + _display->setTextSize(2); + _display->setCursor(45, 52); + _display->print(restoring ? "Restoring..." : "Backing up..."); + _display->endFrame(); + + MyMesh::SDPrefsResult result = restoring + ? the_mesh.restoreFullFromSD(password) + : the_mesh.backupFullToSD(password); + finishPasswordEntry(); + + const char* message = "Storage error"; + switch (result) { + case MyMesh::SDPrefsResult::OK: + message = restoring ? "Full backup restored" : "Full backup saved"; + break; + case MyMesh::SDPrefsResult::CARD_UNAVAILABLE: + message = "Card not found"; + break; + case MyMesh::SDPrefsResult::BACKUP_NOT_FOUND: + message = "Full backup not found"; + break; + case MyMesh::SDPrefsResult::INVALID_BACKUP: + message = "Invalid backup file"; + break; + case MyMesh::SDPrefsResult::AUTH_FAILED: + message = "Wrong password or damaged"; + break; + case MyMesh::SDPrefsResult::INCOMPATIBLE_BACKUP: + message = "Partition size differs"; + break; + case MyMesh::SDPrefsResult::READ_ERROR: + message = "Backup source read error"; + break; + case MyMesh::SDPrefsResult::WRITE_ERROR: + message = restoring ? "Flash write error" : "SD card write error"; + break; + default: + break; + } + showBackupMessage(message); + Serial.printf("[SD] Full %s result: %d\n", restoring ? "restore" : "backup", static_cast(result)); + + if (restoring && (result == MyMesh::SDPrefsResult::OK || + result == MyMesh::SDPrefsResult::WRITE_ERROR || + result == MyMesh::SDPrefsResult::IO_ERROR)) { + _display->startFrame(); + renderNotification(); + _display->endFrame(); + delay(2500); + ESP.restart(); + } + return; + } + + _backspace_hold_start = 0; + _backspace_was_held = false; + if (status.space && _edit_buffer_length < static_cast(sizeof(_edit_buffer) - 1)) { + _edit_buffer[_edit_buffer_length++] = ' '; + _edit_buffer[_edit_buffer_length] = '\0'; + } + for (auto key : status.word) { + if (_edit_buffer_length < static_cast(sizeof(_edit_buffer) - 1)) { + _edit_buffer[_edit_buffer_length++] = key; + _edit_buffer[_edit_buffer_length] = '\0'; + } + } + return; + } + // In chat mode with input active if (_menu_state == MenuScreen::CHAT && _input_mode) { if (status.enter) { @@ -3153,8 +3339,8 @@ void UITask::handleNavigation(Keyboard_Class::KeysState& status) { case MenuScreen::SETTINGS: { if (_settings_category == SettingsCategory::MAIN_MENU) { - // Main menu navigation with scrolling (5 categories, 3 visible) - int num_categories = 5; + // Main menu navigation with scrolling (3 visible at a time) + int num_categories = 6; if (up || down) { if (_settings_item_idx == -1) { @@ -3194,6 +3380,7 @@ void UITask::handleNavigation(Keyboard_Class::KeysState& status) { case 2: _settings_category = SettingsCategory::THEME; break; case 3: _settings_category = SettingsCategory::OTHER; break; case 4: _settings_category = SettingsCategory::DEVICE_INFO; break; + case 5: _settings_category = SettingsCategory::SD_BACKUP; break; } _settings_item_idx = 0; _settings_menu_idx = 0; @@ -3502,6 +3689,33 @@ void UITask::handleNavigation(Keyboard_Class::KeysState& status) { } } + } else if (_settings_category == SettingsCategory::SD_BACKUP) { + if (up || down) { + if (_settings_menu_idx == 1) { + _settings_menu_idx = 0; + _settings_item_idx = 1; + } else if (up && _settings_item_idx > 0) { + _settings_item_idx--; + } else if (down && _settings_item_idx < 1) { + _settings_item_idx++; + } else if (down && _settings_item_idx == 1) { + _settings_menu_idx = 1; + } + } else if (select) { + if (_settings_menu_idx == 1) { + _settings_category = SettingsCategory::MAIN_MENU; + _settings_item_idx = 5; + _settings_menu_idx = 0; + } else { + clearSensitiveText(_edit_buffer, sizeof(_edit_buffer)); + clearSensitiveText(_backup_passphrase, sizeof(_backup_passphrase)); + _edit_buffer_length = 0; + _backup_password_mode = _settings_item_idx == 0 + ? BackupPasswordMode::BACKUP_ENTER + : BackupPasswordMode::RESTORE_ENTER; + } + } + } else if (_settings_category == SettingsCategory::OTHER) { // Other settings navigation (3 options: Sleep timeout, Factory Reset, Support) const uint16_t timeout_values[] = {10, 30, 60, 120, 300, 0}; // 10s, 30s, 1min, 2min, 5min, Never diff --git a/examples/companion_radio/ui-keyboard/UITask.h b/examples/companion_radio/ui-keyboard/UITask.h index d8db8db..99480c5 100644 --- a/examples/companion_radio/ui-keyboard/UITask.h +++ b/examples/companion_radio/ui-keyboard/UITask.h @@ -28,7 +28,15 @@ enum class SettingsCategory { RADIO_SETUP, // Radio configuration OTHER, // Other settings DEVICE_INFO, // Device information - RADIO_PRESET // Radio preset selection + RADIO_PRESET, // Radio preset selection + SD_BACKUP // Save/restore SPIFFS-backed settings on microSD +}; + +enum class BackupPasswordMode { + NONE, + BACKUP_ENTER, + BACKUP_CONFIRM, + RESTORE_ENTER }; // Radio preset structure @@ -154,6 +162,8 @@ class UITask : public AbstractUITask { bool _show_qr_code; char _edit_buffer[64]; // For editing name/radio params int _edit_buffer_length; + BackupPasswordMode _backup_password_mode; + char _backup_passphrase[64]; // Radio parameter editing state bool _editing_frequency; diff --git a/variants/m5stack_cardputer/platformio.ini b/variants/m5stack_cardputer/platformio.ini index 1be4a8b..cb1062e 100644 --- a/variants/m5stack_cardputer/platformio.ini +++ b/variants/m5stack_cardputer/platformio.ini @@ -21,6 +21,7 @@ build_flags = -D P_LORA_MOSI=14 ; MOSI -D P_LORA_MISO=39 ; MISO -D P_LORA_SCLK=40 ; SCK + -D P_SDCARD_CS=12 ; Built-in microSD (shares the LoRa SPI bus) ; RXEN/TXEN pins are module-specific (defined in derived configs) ; SX1262 radio configuration for EU_868 -D LORA_FREQ=868.0 ; EU 868 MHz diff --git a/variants/m5stack_cardputer/target.cpp b/variants/m5stack_cardputer/target.cpp index 86ec54a..873d2d0 100644 --- a/variants/m5stack_cardputer/target.cpp +++ b/variants/m5stack_cardputer/target.cpp @@ -3,7 +3,7 @@ M5CardputerBoard board; -static SPIClass spi; +SPIClass spi; RADIO_CLASS radio = new Module(P_LORA_NSS, P_LORA_DIO_1, P_LORA_RESET, P_LORA_BUSY, spi, SPISettings()); // RF switch control pins (only for modules with external RF switch like DX-LR30) diff --git a/variants/m5stack_cardputer/target.h b/variants/m5stack_cardputer/target.h index 5884797..e020a83 100644 --- a/variants/m5stack_cardputer/target.h +++ b/variants/m5stack_cardputer/target.h @@ -37,6 +37,7 @@ class CardputerSensorManager : public SensorManager { #endif extern M5CardputerBoard board; +extern SPIClass spi; extern WRAPPER_CLASS radio_driver; extern AutoDiscoverRTCClock rtc_clock; #ifdef HAS_GPS