diff --git a/lib/KOReaderSync/KOReaderCredentialStore.cpp b/lib/KOReaderSync/KOReaderCredentialStore.cpp index d59afbce..574f2b8e 100644 --- a/lib/KOReaderSync/KOReaderCredentialStore.cpp +++ b/lib/KOReaderSync/KOReaderCredentialStore.cpp @@ -3,73 +3,81 @@ #include #include #include +#include #include +#include "../../src/JsonSettingsIO.h" + // Initialize the static instance KOReaderCredentialStore KOReaderCredentialStore::instance; namespace { -// File format version +// File format version (for binary migration) constexpr uint8_t KOREADER_FILE_VERSION = 1; -// KOReader credentials file path -constexpr char KOREADER_FILE[] = "/.crosspoint/koreader.bin"; +// File paths +constexpr char KOREADER_FILE_BIN[] = "/.crosspoint/koreader.bin"; +constexpr char KOREADER_FILE_JSON[] = "/.crosspoint/koreader.json"; +constexpr char KOREADER_FILE_BAK[] = "/.crosspoint/koreader.bin.bak"; // Default sync server URL constexpr char DEFAULT_SERVER_URL[] = "https://sync.koreader.rocks:443"; -// Obfuscation key - "KOReader" in ASCII -// This is NOT cryptographic security, just prevents casual file reading -constexpr uint8_t OBFUSCATION_KEY[] = {0x4B, 0x4F, 0x52, 0x65, 0x61, 0x64, 0x65, 0x72}; -constexpr size_t KEY_LENGTH = sizeof(OBFUSCATION_KEY); -} // namespace +// Legacy obfuscation key - "KOReader" in ASCII (only used for binary migration) +constexpr uint8_t LEGACY_OBFUSCATION_KEY[] = {0x4B, 0x4F, 0x52, 0x65, 0x61, 0x64, 0x65, 0x72}; +constexpr size_t LEGACY_KEY_LENGTH = sizeof(LEGACY_OBFUSCATION_KEY); -void KOReaderCredentialStore::obfuscate(std::string& data) const { +void legacyDeobfuscate(std::string& data) { for (size_t i = 0; i < data.size(); i++) { - data[i] ^= OBFUSCATION_KEY[i % KEY_LENGTH]; + data[i] ^= LEGACY_OBFUSCATION_KEY[i % LEGACY_KEY_LENGTH]; } } +} // namespace bool KOReaderCredentialStore::saveToFile() const { - // Make sure the directory exists Storage.mkdir("/.crosspoint"); - - FsFile file; - if (!Storage.openFileForWrite("KRS", KOREADER_FILE, file)) { - return false; - } - - // Write header - serialization::writePod(file, KOREADER_FILE_VERSION); - - // Write username (plaintext - not particularly sensitive) - serialization::writeString(file, username); - LOG_DBG("KRS", "Saving username: %s", username.c_str()); - - // Write password (obfuscated) - std::string obfuscatedPwd = password; - obfuscate(obfuscatedPwd); - serialization::writeString(file, obfuscatedPwd); - - // Write server URL - serialization::writeString(file, serverUrl); - - // Write match method - serialization::writePod(file, static_cast(matchMethod)); - - file.close(); - LOG_DBG("KRS", "Saved KOReader credentials to file"); - return true; + return JsonSettingsIO::saveKOReader(*this, KOREADER_FILE_JSON); } bool KOReaderCredentialStore::loadFromFile() { + // Try JSON first + if (Storage.exists(KOREADER_FILE_JSON)) { + String json = Storage.readFile(KOREADER_FILE_JSON); + if (!json.isEmpty()) { + bool resave = false; + bool result = JsonSettingsIO::loadKOReader(*this, json.c_str(), &resave); + if (result && resave) { + saveToFile(); + LOG_DBG("KRS", "Resaved KOReader credentials to update format"); + } + return result; + } + } + + // Fall back to binary migration + if (Storage.exists(KOREADER_FILE_BIN)) { + if (loadFromBinaryFile()) { + if (saveToFile()) { + Storage.rename(KOREADER_FILE_BIN, KOREADER_FILE_BAK); + LOG_DBG("KRS", "Migrated koreader.bin to koreader.json"); + return true; + } else { + LOG_ERR("KRS", "Failed to save KOReader credentials during migration"); + return false; + } + } + } + + LOG_DBG("KRS", "No credentials file found"); + return false; +} + +bool KOReaderCredentialStore::loadFromBinaryFile() { FsFile file; - if (!Storage.openFileForRead("KRS", KOREADER_FILE, file)) { - LOG_DBG("KRS", "No credentials file found"); + if (!Storage.openFileForRead("KRS", KOREADER_FILE_BIN, file)) { return false; } - // Read and verify version uint8_t version; serialization::readPod(file, version); if (version != KOREADER_FILE_VERSION) { @@ -78,29 +86,25 @@ bool KOReaderCredentialStore::loadFromFile() { return false; } - // Read username if (file.available()) { serialization::readString(file, username); } else { username.clear(); } - // Read and deobfuscate password if (file.available()) { serialization::readString(file, password); - obfuscate(password); // XOR is symmetric, so same function deobfuscates + legacyDeobfuscate(password); } else { password.clear(); } - // Read server URL if (file.available()) { serialization::readString(file, serverUrl); } else { serverUrl.clear(); } - // Read match method if (file.available()) { uint8_t method; serialization::readPod(file, method); @@ -110,7 +114,7 @@ bool KOReaderCredentialStore::loadFromFile() { } file.close(); - LOG_DBG("KRS", "Loaded KOReader credentials for user: %s", username.c_str()); + LOG_DBG("KRS", "Loaded KOReader credentials from binary for user: %s", username.c_str()); return true; } diff --git a/lib/KOReaderSync/KOReaderCredentialStore.h b/lib/KOReaderSync/KOReaderCredentialStore.h index 998101a2..89e008d0 100644 --- a/lib/KOReaderSync/KOReaderCredentialStore.h +++ b/lib/KOReaderSync/KOReaderCredentialStore.h @@ -8,10 +8,17 @@ enum class DocumentMatchMethod : uint8_t { BINARY = 1, // Match by partial MD5 of file content (more accurate, but files must be identical) }; +class KOReaderCredentialStore; +namespace JsonSettingsIO { +bool saveKOReader(const KOReaderCredentialStore& store, const char* path); +bool loadKOReader(KOReaderCredentialStore& store, const char* json, bool* needsResave); +} // namespace JsonSettingsIO + /** * Singleton class for storing KOReader sync credentials on the SD card. - * Credentials are stored in /sd/.crosspoint/koreader.bin with basic - * XOR obfuscation to prevent casual reading (not cryptographically secure). + * Passwords are XOR-obfuscated with the device's unique hardware MAC address + * and base64-encoded before writing to JSON (not cryptographically secure, + * but prevents casual reading and ties credentials to the specific device). */ class KOReaderCredentialStore { private: @@ -24,8 +31,10 @@ class KOReaderCredentialStore { // Private constructor for singleton KOReaderCredentialStore() = default; - // XOR obfuscation (symmetric - same for encode/decode) - void obfuscate(std::string& data) const; + bool loadFromBinaryFile(); + + friend bool JsonSettingsIO::saveKOReader(const KOReaderCredentialStore&, const char*); + friend bool JsonSettingsIO::loadKOReader(KOReaderCredentialStore&, const char*, bool*); public: // Delete copy constructor and assignment diff --git a/lib/Serialization/ObfuscationUtils.cpp b/lib/Serialization/ObfuscationUtils.cpp new file mode 100644 index 00000000..544b5c5e --- /dev/null +++ b/lib/Serialization/ObfuscationUtils.cpp @@ -0,0 +1,98 @@ +#include "ObfuscationUtils.h" + +#include +#include +#include +#include + +#include + +namespace obfuscation { + +namespace { +constexpr size_t HW_KEY_LEN = 6; + +// Simple lazy init — no thread-safety concern on single-core ESP32-C3. +const uint8_t* getHwKey() { + static uint8_t key[HW_KEY_LEN] = {}; + static bool initialized = false; + if (!initialized) { + esp_efuse_mac_get_default(key); + initialized = true; + } + return key; +} +} // namespace + +void xorTransform(std::string& data) { + const uint8_t* key = getHwKey(); + for (size_t i = 0; i < data.size(); i++) { + data[i] ^= key[i % HW_KEY_LEN]; + } +} + +void xorTransform(std::string& data, const uint8_t* key, size_t keyLen) { + if (keyLen == 0 || key == nullptr) return; + for (size_t i = 0; i < data.size(); i++) { + data[i] ^= key[i % keyLen]; + } +} + +String obfuscateToBase64(const std::string& plaintext) { + if (plaintext.empty()) return ""; + std::string temp = plaintext; + xorTransform(temp); + return base64::encode(reinterpret_cast(temp.data()), temp.size()); +} + +std::string deobfuscateFromBase64(const char* encoded, bool* ok) { + if (encoded == nullptr || encoded[0] == '\0') { + if (ok) *ok = false; + return ""; + } + if (ok) *ok = true; + size_t encodedLen = strlen(encoded); + // First call: get required output buffer size + size_t decodedLen = 0; + int ret = mbedtls_base64_decode(nullptr, 0, &decodedLen, reinterpret_cast(encoded), encodedLen); + if (ret != 0 && ret != MBEDTLS_ERR_BASE64_BUFFER_TOO_SMALL) { + LOG_ERR("OBF", "Base64 decode size query failed (ret=%d)", ret); + if (ok) *ok = false; + return ""; + } + std::string result(decodedLen, '\0'); + ret = mbedtls_base64_decode(reinterpret_cast(&result[0]), decodedLen, &decodedLen, + reinterpret_cast(encoded), encodedLen); + if (ret != 0) { + LOG_ERR("OBF", "Base64 decode failed (ret=%d)", ret); + if (ok) *ok = false; + return ""; + } + result.resize(decodedLen); + xorTransform(result); + return result; +} + +void selfTest() { + const char* testInputs[] = {"", "hello", "WiFi P@ssw0rd!", "a"}; + bool allPassed = true; + for (const char* input : testInputs) { + String encoded = obfuscateToBase64(std::string(input)); + std::string decoded = deobfuscateFromBase64(encoded.c_str()); + if (decoded != input) { + LOG_ERR("OBF", "FAIL: \"%s\" -> \"%s\" -> \"%s\"", input, encoded.c_str(), decoded.c_str()); + allPassed = false; + } + } + // Verify obfuscated form differs from plaintext + String enc = obfuscateToBase64("test123"); + if (enc == "test123") { + LOG_ERR("OBF", "FAIL: obfuscated output identical to plaintext"); + allPassed = false; + } + if (allPassed) { + LOG_DBG("OBF", "Obfuscation self-test PASSED"); + } +} + +} // namespace obfuscation diff --git a/lib/Serialization/ObfuscationUtils.h b/lib/Serialization/ObfuscationUtils.h new file mode 100644 index 00000000..718d522f --- /dev/null +++ b/lib/Serialization/ObfuscationUtils.h @@ -0,0 +1,35 @@ +#pragma once + +#include + +#include +#include +#include + +/** + * Credential obfuscation utilities using the ESP32's unique hardware MAC address. + * + * XOR-based obfuscation with the 6-byte eFuse MAC as key. Not cryptographically + * secure, but prevents casual reading of credentials on the SD card and ties + * obfuscated data to the specific device (cannot be decoded on another chip or PC). + * + */ +namespace obfuscation { + +// XOR obfuscate/deobfuscate in-place using hardware MAC key (symmetric operation) +void xorTransform(std::string& data); + +// Legacy overload for binary migration (uses the old per-store hardcoded keys) +void xorTransform(std::string& data, const uint8_t* key, size_t keyLen); + +// Obfuscate a plaintext string: XOR with hardware key, then base64-encode for JSON storage +String obfuscateToBase64(const std::string& plaintext); + +// Decode base64 and de-obfuscate back to plaintext. +// Returns empty string on invalid base64 input; sets *ok to false if decode fails. +std::string deobfuscateFromBase64(const char* encoded, bool* ok = nullptr); + +// Self-test: verifies round-trip obfuscation with hardware key. Logs PASS/FAIL. +void selfTest(); + +} // namespace obfuscation diff --git a/lib/hal/HalStorage.cpp b/lib/hal/HalStorage.cpp index fa5d7f41..a2ef8a54 100644 --- a/lib/hal/HalStorage.cpp +++ b/lib/hal/HalStorage.cpp @@ -36,6 +36,8 @@ bool HalStorage::exists(const char* path) { return SDCard.exists(path); } bool HalStorage::remove(const char* path) { return SDCard.remove(path); } +bool HalStorage::rename(const char* oldPath, const char* newPath) { return SDCard.rename(oldPath, newPath); } + bool HalStorage::rmdir(const char* path) { return SDCard.rmdir(path); } bool HalStorage::openFileForRead(const char* moduleName, const char* path, FsFile& file) { diff --git a/lib/hal/HalStorage.h b/lib/hal/HalStorage.h index 02f8eb44..d3fbc754 100644 --- a/lib/hal/HalStorage.h +++ b/lib/hal/HalStorage.h @@ -28,6 +28,7 @@ class HalStorage { bool mkdir(const char* path, const bool pFlag = true); bool exists(const char* path); bool remove(const char* path); + bool rename(const char* oldPath, const char* newPath); bool rmdir(const char* path); bool openFileForRead(const char* moduleName, const char* path, FsFile& file); diff --git a/src/CrossPointSettings.cpp b/src/CrossPointSettings.cpp index 1404709d..0b9bbb25 100644 --- a/src/CrossPointSettings.cpp +++ b/src/CrossPointSettings.cpp @@ -1,6 +1,7 @@ #include "CrossPointSettings.h" #include +#include #include #include @@ -22,28 +23,9 @@ void readAndValidate(FsFile& file, uint8_t& member, const uint8_t maxValue) { namespace { constexpr uint8_t SETTINGS_FILE_VERSION = 1; -// SETTINGS_COUNT is now calculated automatically in saveToFile -constexpr char SETTINGS_FILE[] = "/.crosspoint/settings.bin"; - -// Validate front button mapping to ensure each hardware button is unique. -// If duplicates are detected, reset to the default physical order to prevent invalid mappings. -void validateFrontButtonMapping(CrossPointSettings& settings) { - // Snapshot the logical->hardware mapping so we can compare for duplicates. - const uint8_t mapping[] = {settings.frontButtonBack, settings.frontButtonConfirm, settings.frontButtonLeft, - settings.frontButtonRight}; - for (size_t i = 0; i < 4; i++) { - for (size_t j = i + 1; j < 4; j++) { - if (mapping[i] == mapping[j]) { - // Duplicate detected: restore the default physical order (Back, Confirm, Left, Right). - settings.frontButtonBack = CrossPointSettings::FRONT_HW_BACK; - settings.frontButtonConfirm = CrossPointSettings::FRONT_HW_CONFIRM; - settings.frontButtonLeft = CrossPointSettings::FRONT_HW_LEFT; - settings.frontButtonRight = CrossPointSettings::FRONT_HW_RIGHT; - return; - } - } - } -} +constexpr char SETTINGS_FILE_BIN[] = "/.crosspoint/settings.bin"; +constexpr char SETTINGS_FILE_JSON[] = "/.crosspoint/settings.json"; +constexpr char SETTINGS_FILE_BAK[] = "/.crosspoint/settings.bin.bak"; // Convert legacy front button layout into explicit logical->hardware mapping. void applyLegacyFrontButtonLayout(CrossPointSettings& settings) { @@ -77,95 +59,65 @@ void applyLegacyFrontButtonLayout(CrossPointSettings& settings) { } } // namespace -class SettingsWriter { - public: - bool is_counting = false; - uint8_t item_count = 0; - template - - void writeItem(FsFile& file, const T& value) { - if (is_counting) { - item_count++; - } else { - serialization::writePod(file, value); +void CrossPointSettings::validateFrontButtonMapping(CrossPointSettings& settings) { + const uint8_t mapping[] = {settings.frontButtonBack, settings.frontButtonConfirm, settings.frontButtonLeft, + settings.frontButtonRight}; + for (size_t i = 0; i < 4; i++) { + for (size_t j = i + 1; j < 4; j++) { + if (mapping[i] == mapping[j]) { + settings.frontButtonBack = FRONT_HW_BACK; + settings.frontButtonConfirm = FRONT_HW_CONFIRM; + settings.frontButtonLeft = FRONT_HW_LEFT; + settings.frontButtonRight = FRONT_HW_RIGHT; + return; + } } } - - void writeItemString(FsFile& file, const char* value) { - if (is_counting) { - item_count++; - } else { - serialization::writeString(file, std::string(value)); - } - } -}; - -uint8_t CrossPointSettings::writeSettings(FsFile& file, bool count_only) const { - SettingsWriter writer; - writer.is_counting = count_only; - - writer.writeItem(file, sleepScreen); - writer.writeItem(file, extraParagraphSpacing); - writer.writeItem(file, shortPwrBtn); - writer.writeItem(file, statusBar); - writer.writeItem(file, orientation); - writer.writeItem(file, frontButtonLayout); // legacy - writer.writeItem(file, sideButtonLayout); - writer.writeItem(file, fontFamily); - writer.writeItem(file, fontSize); - writer.writeItem(file, lineSpacing); - writer.writeItem(file, paragraphAlignment); - writer.writeItem(file, sleepTimeout); - writer.writeItem(file, refreshFrequency); - writer.writeItem(file, screenMargin); - writer.writeItem(file, sleepScreenCoverMode); - writer.writeItemString(file, opdsServerUrl); - writer.writeItem(file, textAntiAliasing); - writer.writeItem(file, hideBatteryPercentage); - writer.writeItem(file, longPressChapterSkip); - writer.writeItem(file, hyphenationEnabled); - writer.writeItemString(file, opdsUsername); - writer.writeItemString(file, opdsPassword); - writer.writeItem(file, sleepScreenCoverFilter); - writer.writeItem(file, uiTheme); - writer.writeItem(file, frontButtonBack); - writer.writeItem(file, frontButtonConfirm); - writer.writeItem(file, frontButtonLeft); - writer.writeItem(file, frontButtonRight); - writer.writeItem(file, fadingFix); - writer.writeItem(file, embeddedStyle); - // New fields need to be added at end for backward compatibility - - return writer.item_count; } bool CrossPointSettings::saveToFile() const { - // Make sure the directory exists Storage.mkdir("/.crosspoint"); - - FsFile outputFile; - if (!Storage.openFileForWrite("CPS", SETTINGS_FILE, outputFile)) { - return false; - } - - // First pass: count the items - uint8_t item_count = writeSettings(outputFile, true); // This will just count, not write - - // Write header - serialization::writePod(outputFile, SETTINGS_FILE_VERSION); - serialization::writePod(outputFile, static_cast(item_count)); - // Second pass: actually write the settings - writeSettings(outputFile); // This will write the actual data - - outputFile.close(); - - LOG_DBG("CPS", "Settings saved to file"); - return true; + return JsonSettingsIO::saveSettings(*this, SETTINGS_FILE_JSON); } bool CrossPointSettings::loadFromFile() { + // Try JSON first + if (Storage.exists(SETTINGS_FILE_JSON)) { + String json = Storage.readFile(SETTINGS_FILE_JSON); + if (!json.isEmpty()) { + bool resave = false; + bool result = JsonSettingsIO::loadSettings(*this, json.c_str(), &resave); + if (result && resave) { + if (saveToFile()) { + LOG_DBG("CPS", "Resaved settings to update format"); + } else { + LOG_ERR("CPS", "Failed to resave settings after format update"); + } + } + return result; + } + } + + // Fall back to binary migration + if (Storage.exists(SETTINGS_FILE_BIN)) { + if (loadFromBinaryFile()) { + if (saveToFile()) { + Storage.rename(SETTINGS_FILE_BIN, SETTINGS_FILE_BAK); + LOG_DBG("CPS", "Migrated settings.bin to settings.json"); + return true; + } else { + LOG_ERR("CPS", "Failed to save migrated settings to JSON"); + return false; + } + } + } + + return false; +} + +bool CrossPointSettings::loadFromBinaryFile() { FsFile inputFile; - if (!Storage.openFileForRead("CPS", SETTINGS_FILE, inputFile)) { + if (!Storage.openFileForRead("CPS", SETTINGS_FILE_BIN, inputFile)) { return false; } @@ -180,9 +132,7 @@ bool CrossPointSettings::loadFromFile() { uint8_t fileSettingsCount = 0; serialization::readPod(inputFile, fileSettingsCount); - // load settings that exist (support older files with fewer fields) uint8_t settingsRead = 0; - // Track whether remap fields were present in the settings file. bool frontButtonMappingRead = false; do { readAndValidate(inputFile, sleepScreen, SLEEP_SCREEN_MODE_COUNT); @@ -195,7 +145,7 @@ bool CrossPointSettings::loadFromFile() { if (++settingsRead >= fileSettingsCount) break; readAndValidate(inputFile, orientation, ORIENTATION_COUNT); if (++settingsRead >= fileSettingsCount) break; - readAndValidate(inputFile, frontButtonLayout, FRONT_BUTTON_LAYOUT_COUNT); // legacy + readAndValidate(inputFile, frontButtonLayout, FRONT_BUTTON_LAYOUT_COUNT); if (++settingsRead >= fileSettingsCount) break; readAndValidate(inputFile, sideButtonLayout, SIDE_BUTTON_LAYOUT_COUNT); if (++settingsRead >= fileSettingsCount) break; @@ -261,17 +211,16 @@ bool CrossPointSettings::loadFromFile() { if (++settingsRead >= fileSettingsCount) break; serialization::readPod(inputFile, embeddedStyle); if (++settingsRead >= fileSettingsCount) break; - // New fields added at end for backward compatibility } while (false); if (frontButtonMappingRead) { - validateFrontButtonMapping(*this); + CrossPointSettings::validateFrontButtonMapping(*this); } else { applyLegacyFrontButtonLayout(*this); } inputFile.close(); - LOG_DBG("CPS", "Settings loaded from file"); + LOG_DBG("CPS", "Settings loaded from binary file"); return true; } diff --git a/src/CrossPointSettings.h b/src/CrossPointSettings.h index 1984f416..8d341b0c 100644 --- a/src/CrossPointSettings.h +++ b/src/CrossPointSettings.h @@ -191,6 +191,12 @@ class CrossPointSettings { bool saveToFile() const; bool loadFromFile(); + static void validateFrontButtonMapping(CrossPointSettings& settings); + + private: + bool loadFromBinaryFile(); + + public: float getReaderLineCompression() const; unsigned long getSleepTimeoutMs() const; int getRefreshFrequency() const; diff --git a/src/CrossPointState.cpp b/src/CrossPointState.cpp index ad263248..232860cd 100644 --- a/src/CrossPointState.cpp +++ b/src/CrossPointState.cpp @@ -1,34 +1,53 @@ #include "CrossPointState.h" #include +#include #include #include namespace { constexpr uint8_t STATE_FILE_VERSION = 4; -constexpr char STATE_FILE[] = "/.crosspoint/state.bin"; +constexpr char STATE_FILE_BIN[] = "/.crosspoint/state.bin"; +constexpr char STATE_FILE_JSON[] = "/.crosspoint/state.json"; +constexpr char STATE_FILE_BAK[] = "/.crosspoint/state.bin.bak"; } // namespace CrossPointState CrossPointState::instance; bool CrossPointState::saveToFile() const { - FsFile outputFile; - if (!Storage.openFileForWrite("CPS", STATE_FILE, outputFile)) { - return false; - } - - serialization::writePod(outputFile, STATE_FILE_VERSION); - serialization::writeString(outputFile, openEpubPath); - serialization::writePod(outputFile, lastSleepImage); - serialization::writePod(outputFile, readerActivityLoadCount); - serialization::writePod(outputFile, lastSleepFromReader); - outputFile.close(); - return true; + Storage.mkdir("/.crosspoint"); + return JsonSettingsIO::saveState(*this, STATE_FILE_JSON); } bool CrossPointState::loadFromFile() { + // Try JSON first + if (Storage.exists(STATE_FILE_JSON)) { + String json = Storage.readFile(STATE_FILE_JSON); + if (!json.isEmpty()) { + return JsonSettingsIO::loadState(*this, json.c_str()); + } + } + + // Fall back to binary migration + if (Storage.exists(STATE_FILE_BIN)) { + if (loadFromBinaryFile()) { + if (saveToFile()) { + Storage.rename(STATE_FILE_BIN, STATE_FILE_BAK); + LOG_DBG("CPS", "Migrated state.bin to state.json"); + return true; + } else { + LOG_ERR("CPS", "Failed to save state during migration"); + return false; + } + } + } + + return false; +} + +bool CrossPointState::loadFromBinaryFile() { FsFile inputFile; - if (!Storage.openFileForRead("CPS", STATE_FILE, inputFile)) { + if (!Storage.openFileForRead("CPS", STATE_FILE_BIN, inputFile)) { return false; } diff --git a/src/CrossPointState.h b/src/CrossPointState.h index 68fa1d68..35a9a871 100644 --- a/src/CrossPointState.h +++ b/src/CrossPointState.h @@ -19,6 +19,9 @@ class CrossPointState { bool saveToFile() const; bool loadFromFile(); + + private: + bool loadFromBinaryFile(); }; // Helper macro to access settings diff --git a/src/JsonSettingsIO.cpp b/src/JsonSettingsIO.cpp new file mode 100644 index 00000000..827952f1 --- /dev/null +++ b/src/JsonSettingsIO.cpp @@ -0,0 +1,281 @@ +#include "JsonSettingsIO.h" + +#include +#include +#include +#include + +#include + +#include "CrossPointSettings.h" +#include "CrossPointState.h" +#include "KOReaderCredentialStore.h" +#include "RecentBooksStore.h" +#include "WifiCredentialStore.h" + +// ---- CrossPointState ---- + +bool JsonSettingsIO::saveState(const CrossPointState& s, const char* path) { + JsonDocument doc; + doc["openEpubPath"] = s.openEpubPath; + doc["lastSleepImage"] = s.lastSleepImage; + doc["readerActivityLoadCount"] = s.readerActivityLoadCount; + doc["lastSleepFromReader"] = s.lastSleepFromReader; + + String json; + serializeJson(doc, json); + return Storage.writeFile(path, json); +} + +bool JsonSettingsIO::loadState(CrossPointState& s, const char* json) { + JsonDocument doc; + auto error = deserializeJson(doc, json); + if (error) { + LOG_ERR("CPS", "JSON parse error: %s", error.c_str()); + return false; + } + + s.openEpubPath = doc["openEpubPath"] | std::string(""); + s.lastSleepImage = doc["lastSleepImage"] | (uint8_t)0; + s.readerActivityLoadCount = doc["readerActivityLoadCount"] | (uint8_t)0; + s.lastSleepFromReader = doc["lastSleepFromReader"] | false; + return true; +} + +// ---- CrossPointSettings ---- + +bool JsonSettingsIO::saveSettings(const CrossPointSettings& s, const char* path) { + JsonDocument doc; + + doc["sleepScreen"] = s.sleepScreen; + doc["sleepScreenCoverMode"] = s.sleepScreenCoverMode; + doc["sleepScreenCoverFilter"] = s.sleepScreenCoverFilter; + doc["statusBar"] = s.statusBar; + doc["extraParagraphSpacing"] = s.extraParagraphSpacing; + doc["textAntiAliasing"] = s.textAntiAliasing; + doc["shortPwrBtn"] = s.shortPwrBtn; + doc["orientation"] = s.orientation; + doc["sideButtonLayout"] = s.sideButtonLayout; + doc["frontButtonBack"] = s.frontButtonBack; + doc["frontButtonConfirm"] = s.frontButtonConfirm; + doc["frontButtonLeft"] = s.frontButtonLeft; + doc["frontButtonRight"] = s.frontButtonRight; + doc["fontFamily"] = s.fontFamily; + doc["fontSize"] = s.fontSize; + doc["lineSpacing"] = s.lineSpacing; + doc["paragraphAlignment"] = s.paragraphAlignment; + doc["sleepTimeout"] = s.sleepTimeout; + doc["refreshFrequency"] = s.refreshFrequency; + doc["screenMargin"] = s.screenMargin; + doc["opdsServerUrl"] = s.opdsServerUrl; + doc["opdsUsername"] = s.opdsUsername; + doc["opdsPassword_obf"] = obfuscation::obfuscateToBase64(s.opdsPassword); + doc["hideBatteryPercentage"] = s.hideBatteryPercentage; + doc["longPressChapterSkip"] = s.longPressChapterSkip; + doc["hyphenationEnabled"] = s.hyphenationEnabled; + doc["uiTheme"] = s.uiTheme; + doc["fadingFix"] = s.fadingFix; + doc["embeddedStyle"] = s.embeddedStyle; + + String json; + serializeJson(doc, json); + return Storage.writeFile(path, json); +} + +bool JsonSettingsIO::loadSettings(CrossPointSettings& s, const char* json, bool* needsResave) { + if (needsResave) *needsResave = false; + JsonDocument doc; + auto error = deserializeJson(doc, json); + if (error) { + LOG_ERR("CPS", "JSON parse error: %s", error.c_str()); + return false; + } + + using S = CrossPointSettings; + auto clamp = [](uint8_t val, uint8_t maxVal, uint8_t def) -> uint8_t { return val < maxVal ? val : def; }; + + s.sleepScreen = clamp(doc["sleepScreen"] | (uint8_t)S::DARK, S::SLEEP_SCREEN_MODE_COUNT, S::DARK); + s.sleepScreenCoverMode = + clamp(doc["sleepScreenCoverMode"] | (uint8_t)S::FIT, S::SLEEP_SCREEN_COVER_MODE_COUNT, S::FIT); + s.sleepScreenCoverFilter = + clamp(doc["sleepScreenCoverFilter"] | (uint8_t)S::NO_FILTER, S::SLEEP_SCREEN_COVER_FILTER_COUNT, S::NO_FILTER); + s.statusBar = clamp(doc["statusBar"] | (uint8_t)S::FULL, S::STATUS_BAR_MODE_COUNT, S::FULL); + s.extraParagraphSpacing = doc["extraParagraphSpacing"] | (uint8_t)1; + s.textAntiAliasing = doc["textAntiAliasing"] | (uint8_t)1; + s.shortPwrBtn = clamp(doc["shortPwrBtn"] | (uint8_t)S::IGNORE, S::SHORT_PWRBTN_COUNT, S::IGNORE); + s.orientation = clamp(doc["orientation"] | (uint8_t)S::PORTRAIT, S::ORIENTATION_COUNT, S::PORTRAIT); + s.sideButtonLayout = + clamp(doc["sideButtonLayout"] | (uint8_t)S::PREV_NEXT, S::SIDE_BUTTON_LAYOUT_COUNT, S::PREV_NEXT); + s.frontButtonBack = + clamp(doc["frontButtonBack"] | (uint8_t)S::FRONT_HW_BACK, S::FRONT_BUTTON_HARDWARE_COUNT, S::FRONT_HW_BACK); + s.frontButtonConfirm = clamp(doc["frontButtonConfirm"] | (uint8_t)S::FRONT_HW_CONFIRM, S::FRONT_BUTTON_HARDWARE_COUNT, + S::FRONT_HW_CONFIRM); + s.frontButtonLeft = + clamp(doc["frontButtonLeft"] | (uint8_t)S::FRONT_HW_LEFT, S::FRONT_BUTTON_HARDWARE_COUNT, S::FRONT_HW_LEFT); + s.frontButtonRight = + clamp(doc["frontButtonRight"] | (uint8_t)S::FRONT_HW_RIGHT, S::FRONT_BUTTON_HARDWARE_COUNT, S::FRONT_HW_RIGHT); + CrossPointSettings::validateFrontButtonMapping(s); + s.fontFamily = clamp(doc["fontFamily"] | (uint8_t)S::BOOKERLY, S::FONT_FAMILY_COUNT, S::BOOKERLY); + s.fontSize = clamp(doc["fontSize"] | (uint8_t)S::MEDIUM, S::FONT_SIZE_COUNT, S::MEDIUM); + s.lineSpacing = clamp(doc["lineSpacing"] | (uint8_t)S::NORMAL, S::LINE_COMPRESSION_COUNT, S::NORMAL); + s.paragraphAlignment = + clamp(doc["paragraphAlignment"] | (uint8_t)S::JUSTIFIED, S::PARAGRAPH_ALIGNMENT_COUNT, S::JUSTIFIED); + s.sleepTimeout = clamp(doc["sleepTimeout"] | (uint8_t)S::SLEEP_10_MIN, S::SLEEP_TIMEOUT_COUNT, S::SLEEP_10_MIN); + s.refreshFrequency = + clamp(doc["refreshFrequency"] | (uint8_t)S::REFRESH_15, S::REFRESH_FREQUENCY_COUNT, S::REFRESH_15); + s.screenMargin = doc["screenMargin"] | (uint8_t)5; + s.hideBatteryPercentage = + clamp(doc["hideBatteryPercentage"] | (uint8_t)S::HIDE_NEVER, S::HIDE_BATTERY_PERCENTAGE_COUNT, S::HIDE_NEVER); + s.longPressChapterSkip = doc["longPressChapterSkip"] | (uint8_t)1; + s.hyphenationEnabled = doc["hyphenationEnabled"] | (uint8_t)0; + s.uiTheme = doc["uiTheme"] | (uint8_t)S::LYRA; + s.fadingFix = doc["fadingFix"] | (uint8_t)0; + s.embeddedStyle = doc["embeddedStyle"] | (uint8_t)1; + + const char* url = doc["opdsServerUrl"] | ""; + strncpy(s.opdsServerUrl, url, sizeof(s.opdsServerUrl) - 1); + s.opdsServerUrl[sizeof(s.opdsServerUrl) - 1] = '\0'; + + const char* user = doc["opdsUsername"] | ""; + strncpy(s.opdsUsername, user, sizeof(s.opdsUsername) - 1); + s.opdsUsername[sizeof(s.opdsUsername) - 1] = '\0'; + + bool passOk = false; + std::string pass = obfuscation::deobfuscateFromBase64(doc["opdsPassword_obf"] | "", &passOk); + if (!passOk || pass.empty()) { + pass = doc["opdsPassword"] | ""; + if (!pass.empty() && needsResave) *needsResave = true; + } + strncpy(s.opdsPassword, pass.c_str(), sizeof(s.opdsPassword) - 1); + s.opdsPassword[sizeof(s.opdsPassword) - 1] = '\0'; + LOG_DBG("CPS", "Settings loaded from file"); + return true; +} + +// ---- KOReaderCredentialStore ---- + +bool JsonSettingsIO::saveKOReader(const KOReaderCredentialStore& store, const char* path) { + JsonDocument doc; + doc["username"] = store.getUsername(); + doc["password_obf"] = obfuscation::obfuscateToBase64(store.getPassword()); + doc["serverUrl"] = store.getServerUrl(); + doc["matchMethod"] = static_cast(store.getMatchMethod()); + + String json; + serializeJson(doc, json); + return Storage.writeFile(path, json); +} + +bool JsonSettingsIO::loadKOReader(KOReaderCredentialStore& store, const char* json, bool* needsResave) { + if (needsResave) *needsResave = false; + JsonDocument doc; + auto error = deserializeJson(doc, json); + if (error) { + LOG_ERR("KRS", "JSON parse error: %s", error.c_str()); + return false; + } + + store.username = doc["username"] | std::string(""); + bool ok = false; + store.password = obfuscation::deobfuscateFromBase64(doc["password_obf"] | "", &ok); + if (!ok || store.password.empty()) { + store.password = doc["password"] | std::string(""); + if (!store.password.empty() && needsResave) *needsResave = true; + } + store.serverUrl = doc["serverUrl"] | std::string(""); + uint8_t method = doc["matchMethod"] | (uint8_t)0; + store.matchMethod = static_cast(method); + + LOG_DBG("KRS", "Loaded KOReader credentials for user: %s", store.username.c_str()); + return true; +} + +// ---- WifiCredentialStore ---- + +bool JsonSettingsIO::saveWifi(const WifiCredentialStore& store, const char* path) { + JsonDocument doc; + doc["lastConnectedSsid"] = store.getLastConnectedSsid(); + + JsonArray arr = doc["credentials"].to(); + for (const auto& cred : store.getCredentials()) { + JsonObject obj = arr.add(); + obj["ssid"] = cred.ssid; + obj["password_obf"] = obfuscation::obfuscateToBase64(cred.password); + } + + String json; + serializeJson(doc, json); + return Storage.writeFile(path, json); +} + +bool JsonSettingsIO::loadWifi(WifiCredentialStore& store, const char* json, bool* needsResave) { + if (needsResave) *needsResave = false; + JsonDocument doc; + auto error = deserializeJson(doc, json); + if (error) { + LOG_ERR("WCS", "JSON parse error: %s", error.c_str()); + return false; + } + + store.lastConnectedSsid = doc["lastConnectedSsid"] | std::string(""); + + store.credentials.clear(); + JsonArray arr = doc["credentials"].as(); + for (JsonObject obj : arr) { + if (store.credentials.size() >= store.MAX_NETWORKS) break; + WifiCredential cred; + cred.ssid = obj["ssid"] | std::string(""); + bool ok = false; + cred.password = obfuscation::deobfuscateFromBase64(obj["password_obf"] | "", &ok); + if (!ok || cred.password.empty()) { + cred.password = obj["password"] | std::string(""); + if (!cred.password.empty() && needsResave) *needsResave = true; + } + store.credentials.push_back(cred); + } + + LOG_DBG("WCS", "Loaded %zu WiFi credentials from file", store.credentials.size()); + return true; +} + +// ---- RecentBooksStore ---- + +bool JsonSettingsIO::saveRecentBooks(const RecentBooksStore& store, const char* path) { + JsonDocument doc; + JsonArray arr = doc["books"].to(); + for (const auto& book : store.getBooks()) { + JsonObject obj = arr.add(); + obj["path"] = book.path; + obj["title"] = book.title; + obj["author"] = book.author; + obj["coverBmpPath"] = book.coverBmpPath; + } + + String json; + serializeJson(doc, json); + return Storage.writeFile(path, json); +} + +bool JsonSettingsIO::loadRecentBooks(RecentBooksStore& store, const char* json) { + JsonDocument doc; + auto error = deserializeJson(doc, json); + if (error) { + LOG_ERR("RBS", "JSON parse error: %s", error.c_str()); + return false; + } + + store.recentBooks.clear(); + JsonArray arr = doc["books"].as(); + for (JsonObject obj : arr) { + if (store.getCount() >= 10) break; + RecentBook book; + book.path = obj["path"] | std::string(""); + book.title = obj["title"] | std::string(""); + book.author = obj["author"] | std::string(""); + book.coverBmpPath = obj["coverBmpPath"] | std::string(""); + store.recentBooks.push_back(book); + } + + LOG_DBG("RBS", "Recent books loaded from file (%d entries)", store.getCount()); + return true; +} diff --git a/src/JsonSettingsIO.h b/src/JsonSettingsIO.h new file mode 100644 index 00000000..f1528565 --- /dev/null +++ b/src/JsonSettingsIO.h @@ -0,0 +1,31 @@ +#pragma once + +class CrossPointSettings; +class CrossPointState; +class WifiCredentialStore; +class KOReaderCredentialStore; +class RecentBooksStore; + +namespace JsonSettingsIO { + +// CrossPointSettings +bool saveSettings(const CrossPointSettings& s, const char* path); +bool loadSettings(CrossPointSettings& s, const char* json, bool* needsResave = nullptr); + +// CrossPointState +bool saveState(const CrossPointState& s, const char* path); +bool loadState(CrossPointState& s, const char* json); + +// WifiCredentialStore +bool saveWifi(const WifiCredentialStore& store, const char* path); +bool loadWifi(WifiCredentialStore& store, const char* json, bool* needsResave = nullptr); + +// KOReaderCredentialStore +bool saveKOReader(const KOReaderCredentialStore& store, const char* path); +bool loadKOReader(KOReaderCredentialStore& store, const char* json, bool* needsResave = nullptr); + +// RecentBooksStore +bool saveRecentBooks(const RecentBooksStore& store, const char* path); +bool loadRecentBooks(RecentBooksStore& store, const char* json); + +} // namespace JsonSettingsIO diff --git a/src/RecentBooksStore.cpp b/src/RecentBooksStore.cpp index b36afc52..e903aa8f 100644 --- a/src/RecentBooksStore.cpp +++ b/src/RecentBooksStore.cpp @@ -2,6 +2,7 @@ #include #include +#include #include #include #include @@ -12,7 +13,9 @@ namespace { constexpr uint8_t RECENT_BOOKS_FILE_VERSION = 3; -constexpr char RECENT_BOOKS_FILE[] = "/.crosspoint/recent.bin"; +constexpr char RECENT_BOOKS_FILE_BIN[] = "/.crosspoint/recent.bin"; +constexpr char RECENT_BOOKS_FILE_JSON[] = "/.crosspoint/recent.json"; +constexpr char RECENT_BOOKS_FILE_BAK[] = "/.crosspoint/recent.bin.bak"; constexpr int MAX_RECENT_BOOKS = 10; } // namespace @@ -52,28 +55,8 @@ void RecentBooksStore::updateBook(const std::string& path, const std::string& ti } bool RecentBooksStore::saveToFile() const { - // Make sure the directory exists Storage.mkdir("/.crosspoint"); - - FsFile outputFile; - if (!Storage.openFileForWrite("RBS", RECENT_BOOKS_FILE, outputFile)) { - return false; - } - - serialization::writePod(outputFile, RECENT_BOOKS_FILE_VERSION); - const uint8_t count = static_cast(recentBooks.size()); - serialization::writePod(outputFile, count); - - for (const auto& book : recentBooks) { - serialization::writeString(outputFile, book.path); - serialization::writeString(outputFile, book.title); - serialization::writeString(outputFile, book.author); - serialization::writeString(outputFile, book.coverBmpPath); - } - - outputFile.close(); - LOG_DBG("RBS", "Recent books saved to file (%d entries)", count); - return true; + return JsonSettingsIO::saveRecentBooks(*this, RECENT_BOOKS_FILE_JSON); } RecentBook RecentBooksStore::getDataFromBook(std::string path) const { @@ -107,8 +90,30 @@ RecentBook RecentBooksStore::getDataFromBook(std::string path) const { } bool RecentBooksStore::loadFromFile() { + // Try JSON first + if (Storage.exists(RECENT_BOOKS_FILE_JSON)) { + String json = Storage.readFile(RECENT_BOOKS_FILE_JSON); + if (!json.isEmpty()) { + return JsonSettingsIO::loadRecentBooks(*this, json.c_str()); + } + } + + // Fall back to binary migration + if (Storage.exists(RECENT_BOOKS_FILE_BIN)) { + if (loadFromBinaryFile()) { + saveToFile(); + Storage.rename(RECENT_BOOKS_FILE_BIN, RECENT_BOOKS_FILE_BAK); + LOG_DBG("RBS", "Migrated recent.bin to recent.json"); + return true; + } + } + + return false; +} + +bool RecentBooksStore::loadFromBinaryFile() { FsFile inputFile; - if (!Storage.openFileForRead("RBS", RECENT_BOOKS_FILE, inputFile)) { + if (!Storage.openFileForRead("RBS", RECENT_BOOKS_FILE_BIN, inputFile)) { return false; } @@ -173,6 +178,6 @@ bool RecentBooksStore::loadFromFile() { } inputFile.close(); - LOG_DBG("RBS", "Recent books loaded from file (%d entries)", recentBooks.size()); + LOG_DBG("RBS", "Recent books loaded from binary file (%d entries)", static_cast(recentBooks.size())); return true; } diff --git a/src/RecentBooksStore.h b/src/RecentBooksStore.h index 8dbf0813..5d98ce83 100644 --- a/src/RecentBooksStore.h +++ b/src/RecentBooksStore.h @@ -11,12 +11,19 @@ struct RecentBook { bool operator==(const RecentBook& other) const { return path == other.path; } }; +class RecentBooksStore; +namespace JsonSettingsIO { +bool loadRecentBooks(RecentBooksStore& store, const char* json); +} // namespace JsonSettingsIO + class RecentBooksStore { // Static instance static RecentBooksStore instance; std::vector recentBooks; + friend bool JsonSettingsIO::loadRecentBooks(RecentBooksStore&, const char*); + public: ~RecentBooksStore() = default; @@ -40,6 +47,9 @@ class RecentBooksStore { bool loadFromFile(); RecentBook getDataFromBook(std::string path) const; + + private: + bool loadFromBinaryFile(); }; // Helper macro to access recent books store diff --git a/src/WifiCredentialStore.cpp b/src/WifiCredentialStore.cpp index 5af239ad..a95be314 100644 --- a/src/WifiCredentialStore.cpp +++ b/src/WifiCredentialStore.cpp @@ -1,70 +1,77 @@ #include "WifiCredentialStore.h" #include +#include #include +#include #include // Initialize the static instance WifiCredentialStore WifiCredentialStore::instance; namespace { -// File format version -constexpr uint8_t WIFI_FILE_VERSION = 2; // Increased version +// File format version (for binary migration) +constexpr uint8_t WIFI_FILE_VERSION = 2; -// WiFi credentials file path -constexpr char WIFI_FILE[] = "/.crosspoint/wifi.bin"; +// File paths +constexpr char WIFI_FILE_BIN[] = "/.crosspoint/wifi.bin"; +constexpr char WIFI_FILE_JSON[] = "/.crosspoint/wifi.json"; +constexpr char WIFI_FILE_BAK[] = "/.crosspoint/wifi.bin.bak"; -// Obfuscation key - "CrossPoint" in ASCII -// This is NOT cryptographic security, just prevents casual file reading -constexpr uint8_t OBFUSCATION_KEY[] = {0x43, 0x72, 0x6F, 0x73, 0x73, 0x50, 0x6F, 0x69, 0x6E, 0x74}; -constexpr size_t KEY_LENGTH = sizeof(OBFUSCATION_KEY); -} // namespace +// Legacy obfuscation key - "CrossPoint" in ASCII (only used for binary migration) +constexpr uint8_t LEGACY_OBFUSCATION_KEY[] = {0x43, 0x72, 0x6F, 0x73, 0x73, 0x50, 0x6F, 0x69, 0x6E, 0x74}; +constexpr size_t LEGACY_KEY_LENGTH = sizeof(LEGACY_OBFUSCATION_KEY); -void WifiCredentialStore::obfuscate(std::string& data) const { - LOG_DBG("WCS", "Obfuscating/deobfuscating %zu bytes", data.size()); +void legacyDeobfuscate(std::string& data) { for (size_t i = 0; i < data.size(); i++) { - data[i] ^= OBFUSCATION_KEY[i % KEY_LENGTH]; + data[i] ^= LEGACY_OBFUSCATION_KEY[i % LEGACY_KEY_LENGTH]; } } +} // namespace bool WifiCredentialStore::saveToFile() const { - // Make sure the directory exists Storage.mkdir("/.crosspoint"); - - FsFile file; - if (!Storage.openFileForWrite("WCS", WIFI_FILE, file)) { - return false; - } - - // Write header - serialization::writePod(file, WIFI_FILE_VERSION); - serialization::writeString(file, lastConnectedSsid); // Save last connected SSID - serialization::writePod(file, static_cast(credentials.size())); - - // Write each credential - for (const auto& cred : credentials) { - // Write SSID (plaintext - not sensitive) - serialization::writeString(file, cred.ssid); - LOG_DBG("WCS", "Saving SSID: %s, password length: %zu", cred.ssid.c_str(), cred.password.size()); - - // Write password (obfuscated) - std::string obfuscatedPwd = cred.password; - obfuscate(obfuscatedPwd); - serialization::writeString(file, obfuscatedPwd); - } - - file.close(); - LOG_DBG("WCS", "Saved %zu WiFi credentials to file", credentials.size()); - return true; + return JsonSettingsIO::saveWifi(*this, WIFI_FILE_JSON); } bool WifiCredentialStore::loadFromFile() { + // Try JSON first + if (Storage.exists(WIFI_FILE_JSON)) { + String json = Storage.readFile(WIFI_FILE_JSON); + if (!json.isEmpty()) { + bool resave = false; + bool result = JsonSettingsIO::loadWifi(*this, json.c_str(), &resave); + if (result && resave) { + LOG_DBG("WCS", "Resaving JSON with obfuscated passwords"); + saveToFile(); + } + return result; + } + } + + // Fall back to binary migration + if (Storage.exists(WIFI_FILE_BIN)) { + if (loadFromBinaryFile()) { + if (saveToFile()) { + Storage.rename(WIFI_FILE_BIN, WIFI_FILE_BAK); + LOG_DBG("WCS", "Migrated wifi.bin to wifi.json"); + return true; + } else { + LOG_ERR("WCS", "Failed to save wifi during migration"); + return false; + } + } + } + + return false; +} + +bool WifiCredentialStore::loadFromBinaryFile() { FsFile file; - if (!Storage.openFileForRead("WCS", WIFI_FILE, file)) { + if (!Storage.openFileForRead("WCS", WIFI_FILE_BIN, file)) { return false; } - // Read and verify version uint8_t version; serialization::readPod(file, version); if (version > WIFI_FILE_VERSION) { @@ -79,29 +86,20 @@ bool WifiCredentialStore::loadFromFile() { lastConnectedSsid.clear(); } - // Read credential count uint8_t count; serialization::readPod(file, count); - // Read credentials credentials.clear(); for (uint8_t i = 0; i < count && i < MAX_NETWORKS; i++) { WifiCredential cred; - - // Read SSID serialization::readString(file, cred.ssid); - - // Read and deobfuscate password serialization::readString(file, cred.password); - LOG_DBG("WCS", "Loaded SSID: %s, obfuscated password length: %zu", cred.ssid.c_str(), cred.password.size()); - obfuscate(cred.password); // XOR is symmetric, so same function deobfuscates - LOG_DBG("WCS", "After deobfuscation, password length: %zu", cred.password.size()); - + legacyDeobfuscate(cred.password); credentials.push_back(cred); } file.close(); - LOG_DBG("WCS", "Loaded %zu WiFi credentials from file", credentials.size()); + // LOG_DBG("WCS", "Loaded %zu WiFi credentials from binary file", credentials.size()); return true; } diff --git a/src/WifiCredentialStore.h b/src/WifiCredentialStore.h index 2e2fd6ba..46650954 100644 --- a/src/WifiCredentialStore.h +++ b/src/WifiCredentialStore.h @@ -4,13 +4,20 @@ struct WifiCredential { std::string ssid; - std::string password; // Stored obfuscated in file + std::string password; // Plaintext in memory; obfuscated with hardware key on disk }; +class WifiCredentialStore; +namespace JsonSettingsIO { +bool saveWifi(const WifiCredentialStore& store, const char* path); +bool loadWifi(WifiCredentialStore& store, const char* json, bool* needsResave); +} // namespace JsonSettingsIO + /** * Singleton class for storing WiFi credentials on the SD card. - * Credentials are stored in /sd/.crosspoint/wifi.bin with basic - * XOR obfuscation to prevent casual reading (not cryptographically secure). + * Passwords are XOR-obfuscated with the device's unique hardware MAC address + * and base64-encoded before writing to JSON (not cryptographically secure, + * but prevents casual reading and ties credentials to the specific device). */ class WifiCredentialStore { private: @@ -23,8 +30,10 @@ class WifiCredentialStore { // Private constructor for singleton WifiCredentialStore() = default; - // XOR obfuscation (symmetric - same for encode/decode) - void obfuscate(std::string& data) const; + bool loadFromBinaryFile(); + + friend bool JsonSettingsIO::saveWifi(const WifiCredentialStore&, const char*); + friend bool JsonSettingsIO::loadWifi(WifiCredentialStore&, const char*, bool*); public: // Delete copy constructor and assignment