diff --git a/CMakeLists.txt b/CMakeLists.txt index b8c196a1..410017ff 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -78,7 +78,10 @@ if(ENABLE_DOCTESTS) ${CMAKE_CURRENT_SOURCE_DIR}/locale ${CMAKE_CURRENT_SOURCE_DIR}/portduino ${CMAKE_CURRENT_SOURCE_DIR}/generated/${GENERATED_VIEW} + ${GENERATED_FILES_DIR} + ${CMAKE_CURRENT_BINARY_DIR} + ${nanopb_SOURCE_DIR} ) set_target_properties(tests PROPERTIES RUNTIME_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/bin) add_test(NAME tests COMMAND tests) -endif() \ No newline at end of file +endif() diff --git a/include/graphics/common/ChannelShareURL.h b/include/graphics/common/ChannelShareURL.h new file mode 100644 index 00000000..c0099737 --- /dev/null +++ b/include/graphics/common/ChannelShareURL.h @@ -0,0 +1,17 @@ +#pragma once + +#include "mesh-pb-constants.h" +#include +#include + +enum class ChannelShareMode { + Replace, + Add, +}; + +namespace ChannelShareURL { + +std::string make(const meshtastic_ChannelSettings *settings, size_t settingCount, + const meshtastic_Config_LoRaConfig &lora, ChannelShareMode mode); + +} // namespace ChannelShareURL diff --git a/include/graphics/common/MeshtasticView.h b/include/graphics/common/MeshtasticView.h index 1cabd974..6608c040 100644 --- a/include/graphics/common/MeshtasticView.h +++ b/include/graphics/common/MeshtasticView.h @@ -164,7 +164,7 @@ class MeshtasticView : public DeviceGUI bool lastHeardToString(uint32_t lastHeard, char *buf); const char *deviceRoleToString(enum eRole role); std::string pskToBase64(uint8_t *bytes, uint32_t size); - bool base64ToPsk(const std::string &base64, uint8_t *bytes, uint16_t &size); + bool base64ToPsk(const std::string &base64, uint8_t *bytes, uint16_t &size, uint16_t capacity); ViewController *controller; ResponseHandler requests; diff --git a/include/graphics/view/TFT/TFTView_320x240.h b/include/graphics/view/TFT/TFTView_320x240.h index c2787374..8f89fc0b 100644 --- a/include/graphics/view/TFT/TFTView_320x240.h +++ b/include/graphics/view/TFT/TFTView_320x240.h @@ -5,6 +5,7 @@ #include class MapPanel; +enum class ChannelShareMode; /** * @brief GUI view for e.g. T-Deck @@ -217,6 +218,8 @@ class TFTView_320x240 : public MeshtasticView void showKeyboard(lv_obj_t *textArea); void hideKeyboard(lv_obj_t *panel); lv_obj_t *showQrCode(lv_obj_t *parent, const char *data); + void showChannelShareModePicker(void); + void showChannelShareQRCode(ChannelShareMode mode); void enablePanel(lv_obj_t *panel); void disablePanel(lv_obj_t *panel); @@ -335,6 +338,8 @@ class TFTView_320x240 : public MeshtasticView static void ui_event_modify_channel(lv_event_t *e); static void ui_event_delete_channel(lv_event_t *e); static void ui_event_generate_psk(lv_event_t *e); + static void ui_event_channel_share_mode(lv_event_t *e); + static void ui_event_channel_share_cancel(lv_event_t *e); static void ui_event_qr_code(lv_event_t *e); static void ui_event_screen_timeout_slider(lv_event_t *e); @@ -428,6 +433,7 @@ class TFTView_320x240 : public MeshtasticView std::array ch_label; // indexable label list for settings meshtastic_Channel *channel_scratch; // temporary scratch copy of channel db lv_obj_t *qr; // qr code + lv_obj_t *channelShareModePanel = nullptr; // Replace/Add channel QR choice MapPanel *map = nullptr; // map std::unordered_map nodeObjects; // nodeObjects displayed on map // extended default device profile struct with additional required data @@ -444,4 +450,4 @@ class TFTView_320x240 : public MeshtasticView }; meshtastic_DeviceProfile_full db{}; // full copy of the node's configuration db (except nodeinfos) plus ui data -}; \ No newline at end of file +}; diff --git a/source/graphics/TFT/TFTView_320x240.cpp b/source/graphics/TFT/TFTView_320x240.cpp index 72f662e8..dc22d89d 100644 --- a/source/graphics/TFT/TFTView_320x240.cpp +++ b/source/graphics/TFT/TFTView_320x240.cpp @@ -3,6 +3,7 @@ #include "graphics/view/TFT/TFTView_320x240.h" #include "Arduino.h" #include "graphics/common/BatteryLevel.h" +#include "graphics/common/ChannelShareURL.h" #include "graphics/common/LoRaPresets.h" #include "graphics/common/Ringtones.h" #include "graphics/common/ViewController.h" @@ -2178,18 +2179,101 @@ void TFTView_320x240::ui_event_generate_psk(lv_event_t *e) lv_textarea_set_text(objects.settings_modify_channel_psk_textarea, base64.c_str()); } - std::string base64Https = base64; - for (char &c : base64Https) { - if (c == '+') - c = '-'; - else if (c == '/') - c = '_'; - else if (c == '=') - c = '\0'; // remove paddings at the end of the url + THIS->showChannelShareModePicker(); +} + +void TFTView_320x240::showChannelShareModePicker(void) +{ + if (channelShareModePanel) { + lv_obj_delete(channelShareModePanel); + } + + channelShareModePanel = lv_obj_create(objects.settings_modify_channel_panel); + lv_obj_set_size(channelShareModePanel, LV_PCT(92), 115); + lv_obj_center(channelShareModePanel); + lv_obj_clear_flag(channelShareModePanel, LV_OBJ_FLAG_SCROLLABLE); + + lv_obj_t *title = lv_label_create(channelShareModePanel); + lv_label_set_text(title, _("Share channel")); + lv_obj_align(title, LV_ALIGN_TOP_MID, 0, 0); + + lv_obj_t *description = lv_label_create(channelShareModePanel); + lv_label_set_text(description, _("Replace sends channel and radio. Add keeps receiver channels and radio.")); + lv_label_set_long_mode(description, LV_LABEL_LONG_WRAP); + lv_obj_set_width(description, LV_PCT(100)); + lv_obj_align(description, LV_ALIGN_TOP_MID, 0, 24); + lv_obj_set_style_text_align(description, LV_TEXT_ALIGN_CENTER, LV_PART_MAIN | LV_STATE_DEFAULT); + + lv_obj_t *replaceButton = lv_btn_create(channelShareModePanel); + lv_obj_set_size(replaceButton, 76, 30); + lv_obj_align(replaceButton, LV_ALIGN_BOTTOM_LEFT, 0, 0); + lv_obj_add_event_cb(replaceButton, ui_event_channel_share_mode, LV_EVENT_CLICKED, nullptr); + lv_obj_t *replaceLabel = lv_label_create(replaceButton); + lv_label_set_text(replaceLabel, _("Replace")); + lv_obj_center(replaceLabel); + + lv_obj_t *addButton = lv_btn_create(channelShareModePanel); + lv_obj_set_size(addButton, 58, 30); + lv_obj_align(addButton, LV_ALIGN_BOTTOM_MID, 0, 0); + lv_obj_add_event_cb(addButton, ui_event_channel_share_mode, LV_EVENT_CLICKED, (void *)1); + lv_obj_t *addLabel = lv_label_create(addButton); + lv_label_set_text(addLabel, _("Add")); + lv_obj_center(addLabel); + + lv_obj_t *cancelButton = lv_btn_create(channelShareModePanel); + lv_obj_set_size(cancelButton, 58, 30); + lv_obj_align(cancelButton, LV_ALIGN_BOTTOM_RIGHT, 0, 0); + lv_obj_add_event_cb(cancelButton, ui_event_channel_share_cancel, LV_EVENT_CLICKED, nullptr); + lv_obj_t *cancelLabel = lv_label_create(cancelButton); + lv_label_set_text(cancelLabel, _("Cancel")); + lv_obj_center(cancelLabel); + + lv_group_focus_obj(replaceButton); +} + +void TFTView_320x240::ui_event_channel_share_mode(lv_event_t *e) +{ + if (THIS->channelShareModePanel) { + lv_obj_delete(THIS->channelShareModePanel); + THIS->channelShareModePanel = nullptr; + } + const auto mode = lv_event_get_user_data(e) ? ChannelShareMode::Add : ChannelShareMode::Replace; + THIS->showChannelShareQRCode(mode); +} + +void TFTView_320x240::ui_event_channel_share_cancel(lv_event_t *e) +{ + if (THIS->channelShareModePanel) { + lv_obj_delete(THIS->channelShareModePanel); + THIS->channelShareModePanel = nullptr; + } +} + +void TFTView_320x240::showChannelShareQRCode(ChannelShareMode mode) +{ + const uint8_t buttonId = (unsigned long)objects.settings_modify_channel_name_textarea->user_data; + const int8_t channelIndex = (signed long)ch_label[buttonId]->user_data; + if (channelIndex < 0 || !channel_scratch) { + messageAlert(_("Unable to create channel QR"), true); + return; + } + + meshtastic_ChannelSettings settings = channel_scratch[channelIndex].settings; + snprintf(settings.name, sizeof(settings.name), "%s", lv_textarea_get_text(objects.settings_modify_channel_name_textarea)); + if (!base64ToPsk(lv_textarea_get_text(objects.settings_modify_channel_psk_textarea), settings.psk.bytes, settings.psk.size, + sizeof(settings.psk.bytes))) { + messageAlert(_("Invalid channel key"), true); + return; } - std::string qr = "https://meshtastic.org/e/#" + base64Https; + + const std::string url = ChannelShareURL::make(&settings, 1, db.config.lora, mode); + if (url.empty()) { + messageAlert(_("Unable to create channel QR"), true); + return; + } + lv_obj_remove_flag(objects.settings_modify_channel_qr_panel, LV_OBJ_FLAG_HIDDEN); - THIS->qr = THIS->showQrCode(objects.settings_modify_channel_qr_panel, qr.c_str()); + qr = showQrCode(objects.settings_modify_channel_qr_panel, url.c_str()); lv_obj_add_state(objects.keyboard_button_3, LV_STATE_DISABLED); lv_obj_add_state(objects.keyboard_button_4, LV_STATE_DISABLED); } @@ -4204,7 +4288,8 @@ void TFTView_320x240::ui_event_ok(lv_event_t *e) lv_textarea_add_text(objects.settings_modify_channel_psk_textarea, "="); } - if (THIS->base64ToPsk(lv_textarea_get_text(objects.settings_modify_channel_psk_textarea), psk.bytes, psk.size)) { + if (THIS->base64ToPsk(lv_textarea_get_text(objects.settings_modify_channel_psk_textarea), psk.bytes, psk.size, + sizeof(psk.bytes))) { if (strlen(name) || psk.size) { // TODO: fill temp storage -> user data lv_label_set_text(THIS->ch_label[btn_id], name); @@ -6179,8 +6264,8 @@ void TFTView_320x240::restore(uint32_t option) String b64pub = pubKey.substring(pubKey.lastIndexOf(":") + 1); b64priv.trim(); b64pub.trim(); - if (base64ToPsk(b64priv.c_str(), privkey.bytes, privkey.size) && - base64ToPsk(b64pub.c_str(), pubkey.bytes, pubkey.size) && + if (base64ToPsk(b64priv.c_str(), privkey.bytes, privkey.size, sizeof(privkey.bytes)) && + base64ToPsk(b64pub.c_str(), pubkey.bytes, pubkey.size, sizeof(pubkey.bytes)) && controller->sendConfig(meshtastic_Config_SecurityConfig{db.config.security})) { ILOG_INFO("restore pub/priv keys sent to radio"); } else { diff --git a/source/graphics/common/ChannelShareURL.cpp b/source/graphics/common/ChannelShareURL.cpp new file mode 100644 index 00000000..018c40d7 --- /dev/null +++ b/source/graphics/common/ChannelShareURL.cpp @@ -0,0 +1,49 @@ +#include "graphics/common/ChannelShareURL.h" +#include "mesh/generated/meshtastic/apponly.pb.h" +#include "util/macaron_Base64.h" +#include +#include + +namespace { + +std::string base64UrlEncode(const uint8_t *bytes, size_t size) +{ + std::string encoded = macaron::Base64::Encode(bytes, size); + for (char &character : encoded) { + if (character == '+') + character = '-'; + else if (character == '/') + character = '_'; + } + while (!encoded.empty() && encoded.back() == '=') { + encoded.pop_back(); + } + return encoded; +} + +} // namespace + +std::string ChannelShareURL::make(const meshtastic_ChannelSettings *settings, size_t settingCount, + const meshtastic_Config_LoRaConfig &lora, ChannelShareMode mode) +{ + if (!settings || settingCount == 0 || settingCount > 8) { + return {}; + } + + meshtastic_ChannelSet channelSet = meshtastic_ChannelSet_init_zero; + channelSet.settings_count = static_cast(settingCount); + std::copy_n(settings, settingCount, channelSet.settings); + if (mode == ChannelShareMode::Replace) { + channelSet.has_lora_config = true; + channelSet.lora_config = lora; + } + + uint8_t payload[meshtastic_ChannelSet_size]; + pb_ostream_t stream = pb_ostream_from_buffer(payload, sizeof(payload)); + if (!pb_encode(&stream, &meshtastic_ChannelSet_msg, &channelSet)) { + return {}; + } + + const char *prefix = mode == ChannelShareMode::Add ? "https://meshtastic.org/e/?add=true#" : "https://meshtastic.org/e/#"; + return std::string(prefix) + base64UrlEncode(payload, stream.bytes_written); +} diff --git a/source/graphics/common/MeshtasticView.cpp b/source/graphics/common/MeshtasticView.cpp index 92a584c2..25468b0f 100644 --- a/source/graphics/common/MeshtasticView.cpp +++ b/source/graphics/common/MeshtasticView.cpp @@ -209,16 +209,21 @@ std::string MeshtasticView::pskToBase64(uint8_t *bytes, uint32_t size) } } -bool MeshtasticView::base64ToPsk(const std::string &base64, uint8_t *bytes, uint16_t &size) +bool MeshtasticView::base64ToPsk(const std::string &base64, uint8_t *bytes, uint16_t &size, uint16_t capacity) { std::string out; auto error = macaron::Base64::Decode(base64, out); if (!error.empty()) { ILOG_ERROR("Cannot decode '%s'", base64); return false; - } else { - memcpy((char *)bytes, out.data(), out.size()); - size = out.size(); } + + if (out.size() > capacity) { + ILOG_ERROR("Decoded key is too large: %u", static_cast(out.size())); + return false; + } + + memcpy(bytes, out.data(), out.size()); + size = out.size(); return true; } diff --git a/tests/test_ChannelShareURL.cpp b/tests/test_ChannelShareURL.cpp new file mode 100644 index 00000000..ac00d419 --- /dev/null +++ b/tests/test_ChannelShareURL.cpp @@ -0,0 +1,94 @@ +#include "graphics/common/ChannelShareURL.h" +#include "mesh/generated/meshtastic/apponly.pb.h" +#include "util/macaron_Base64.h" +#include +#include +#include +#include + +namespace { + +meshtastic_ChannelSettings channelSettings(const char *name, uint8_t seed) +{ + meshtastic_ChannelSettings settings = meshtastic_ChannelSettings_init_zero; + snprintf(settings.name, sizeof(settings.name), "%s", name); + settings.psk.size = 16; + for (uint8_t i = 0; i < settings.psk.size; i++) { + settings.psk.bytes[i] = seed + i; + } + return settings; +} + +bool decodeChannelSet(const std::string &url, meshtastic_ChannelSet &result) +{ + size_t payloadStart = url.find('#'); + if (payloadStart == std::string::npos) { + return false; + } + + std::string payload = url.substr(payloadStart + 1); + for (char &character : payload) { + if (character == '-') + character = '+'; + else if (character == '_') + character = '/'; + } + payload.append((4 - payload.size() % 4) % 4, '='); + + std::string encoded; + if (!macaron::Base64::Decode(payload, encoded).empty()) { + return false; + } + + result = meshtastic_ChannelSet_init_zero; + pb_istream_t stream = pb_istream_from_buffer(reinterpret_cast(encoded.data()), encoded.size()); + return pb_decode(&stream, &meshtastic_ChannelSet_msg, &result); +} + +} // namespace + +TEST_CASE("Channel share Replace URL carries channels and LoRa") +{ + meshtastic_ChannelSettings settings = channelSettings("Replace", 1); + meshtastic_Config_LoRaConfig lora = meshtastic_Config_LoRaConfig_init_zero; + lora.region = meshtastic_Config_LoRaConfig_RegionCode_US; + lora.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST; + lora.channel_num = 7; + + const std::string url = ChannelShareURL::make(&settings, 1, lora, ChannelShareMode::Replace); + + CHECK(url.rfind("https://meshtastic.org/e/#", 0) == 0); + CHECK(url.find_first_of("+/=", url.find('#') + 1) == std::string::npos); + + meshtastic_ChannelSet decoded = meshtastic_ChannelSet_init_zero; + REQUIRE(decodeChannelSet(url, decoded)); + REQUIRE(decoded.settings_count == 1); + CHECK(strcmp(decoded.settings[0].name, "Replace") == 0); + CHECK(decoded.settings[0].psk.size == settings.psk.size); + CHECK(memcmp(decoded.settings[0].psk.bytes, settings.psk.bytes, settings.psk.size) == 0); + REQUIRE(decoded.has_lora_config); + CHECK(decoded.lora_config.region == lora.region); + CHECK(decoded.lora_config.modem_preset == lora.modem_preset); + CHECK(decoded.lora_config.channel_num == lora.channel_num); +} + +TEST_CASE("Channel share Add URL omits LoRa for up to eight channels") +{ + meshtastic_ChannelSettings settings[8] = {}; + for (uint8_t i = 0; i < 8; i++) { + settings[i] = channelSettings("Channel", i); + snprintf(settings[i].name, sizeof(settings[i].name), "Channel%u", i); + } + meshtastic_Config_LoRaConfig lora = meshtastic_Config_LoRaConfig_init_zero; + lora.region = meshtastic_Config_LoRaConfig_RegionCode_EU_868; + + const std::string url = ChannelShareURL::make(settings, 8, lora, ChannelShareMode::Add); + + CHECK(url.rfind("https://meshtastic.org/e/?add=true#", 0) == 0); + + meshtastic_ChannelSet decoded = meshtastic_ChannelSet_init_zero; + REQUIRE(decodeChannelSet(url, decoded)); + REQUIRE(decoded.settings_count == 8); + CHECK(strcmp(decoded.settings[7].name, "Channel7") == 0); + CHECK_FALSE(decoded.has_lora_config); +} diff --git a/tests/test_MeshtasticView.cpp b/tests/test_MeshtasticView.cpp new file mode 100644 index 00000000..f2efd98f --- /dev/null +++ b/tests/test_MeshtasticView.cpp @@ -0,0 +1,27 @@ +#include "graphics/common/MeshtasticView.h" +#include + +namespace { + +class TestMeshtasticView : public MeshtasticView +{ + public: + TestMeshtasticView() : MeshtasticView(nullptr, nullptr, nullptr) {} + + bool decodePsk(const std::string &base64, uint8_t *bytes, uint16_t &size, uint16_t capacity) + { + return base64ToPsk(base64, bytes, size, capacity); + } +}; + +} // namespace + +TEST_CASE("MeshtasticView rejects a decoded PSK larger than its destination") +{ + TestMeshtasticView view; + uint8_t bytes[32] = {}; + uint16_t size = 0; + + CHECK_FALSE(view.decodePsk(std::string(44, 'A'), bytes, size, sizeof(bytes))); + CHECK(size == 0); +}