diff --git a/MQTT_IMPLEMENTATION.md b/MQTT_IMPLEMENTATION.md index 681af98b3..32fec249a 100644 --- a/MQTT_IMPLEMENTATION.md +++ b/MQTT_IMPLEMENTATION.md @@ -606,6 +606,7 @@ These settings apply across all MQTT slots: - `get mqtt.packets` - Get packet message setting (on/off) - `get mqtt.raw` - Get raw message setting (on/off) - `get mqtt.rx` - Get RX packet uplinking setting (on/off) +- `get mqtt.advert.verify` - Get advert signature filtering setting (on/off) - `get mqtt.tx` - Get TX packet uplinking setting (on/off/advert) - `get mqtt.interval` - Get status publish interval - `get mqtt.neighbors` - Get periodic neighbors publishing setting (on/off; neighbors-enabled builds) @@ -622,6 +623,7 @@ These settings apply across all MQTT slots: - `set mqtt.packets on|off` - Enable/disable packet messages - `set mqtt.raw on|off` - Enable/disable raw messages - `set mqtt.rx on|off` - Enable/disable RX (received) packet uplinking +- `set mqtt.advert.verify on|off` - Skip received adverts whose signature fails (default off) - `set mqtt.tx on|off|advert` - Set TX packet uplinking mode: - `on` - Uplink all transmitted packets - `advert` - Uplink only this node's own advert packets (self-originated) diff --git a/docs/cli_commands.md b/docs/cli_commands.md index 39d19ccbf..d468e43d0 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -5213,6 +5213,29 @@ USB logging remains independent. On images with both outputs, --- +#### View or change MQTT advert signature filtering + +**Usage:** + +- `get mqtt.advert.verify` +- `set mqtt.advert.verify ` + +**Parameters:** + +- `on`: verify the Ed25519 signature of each received advert and skip publishing any that fail (the same check the mesh applies before relaying) +- `off`: publish received adverts as heard + +**Default:** `off` + +> **Note:** Adverts are uploaded before the mesh validates them, so a frame corrupted over the air that still passes the LoRa CRC is published with a mangled name or location. Leaving this off keeps that corruption visible to collectors; turning it on hides it from them. Dropped adverts are counted as `badsig=` in `get mqtt.stats`. Takes effect immediately. + +On Companion builds, enabling this setting opts into a marked MQTT preference +record. Before rolling back to firmware without this setting, turn it off to +restore the older record format and retain the saved MQTT setup. Ordinary +default-off updates keep the existing record size. + +--- + #### View or change periodic neighbors publishing (MQTT observer, neighbors feature) **Usage:** - `get mqtt.neighbors` diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index 07f459b04..a692c665d 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -5189,6 +5189,7 @@ void MyMesh::execCommand(char* cmd, char* reply) { else if (strcmp(key, "mqtt.packets") == 0) value = _mqtt_prefs.mqtt_packets_enabled ? "on" : "off"; else if (strcmp(key, "mqtt.raw") == 0) value = _mqtt_prefs.mqtt_raw_enabled ? "on" : "off"; else if (strcmp(key, "mqtt.rx") == 0) value = _mqtt_prefs.mqtt_rx_enabled ? "on" : "off"; + else if (strcmp(key, "mqtt.advert.verify") == 0) value = _mqtt_prefs.mqtt_advert_verify ? "on" : "off"; else if (strcmp(key, "mqtt.tx") == 0) value = _mqtt_prefs.mqtt_tx_enabled == 2 ? "advert" : _mqtt_prefs.mqtt_tx_enabled ? "on" : "off"; else if (strcmp(key, "mqtt.ntp") == 0) value = _mqtt_prefs.mqtt_ntp_server; else if (strcmp(key, "mqtt.owner") == 0) value = _mqtt_prefs.mqtt_owner_public_key; @@ -5459,6 +5460,7 @@ void MyMesh::execCommand(char* cmd, char* reply) { } } else if (strcmp(key, "mqtt.status") == 0 || strcmp(key, "mqtt.packets") == 0 || strcmp(key, "mqtt.raw") == 0 || strcmp(key, "mqtt.rx") == 0 + || strcmp(key, "mqtt.advert.verify") == 0 || strcmp(key, "snmp") == 0) { bool enabled; if (!wcParseBool(value, enabled)) { @@ -5469,6 +5471,7 @@ void MyMesh::execCommand(char* cmd, char* reply) { else if (strcmp(key, "mqtt.packets") == 0) _mqtt_prefs.mqtt_packets_enabled = enabled; else if (strcmp(key, "mqtt.raw") == 0) _mqtt_prefs.mqtt_raw_enabled = enabled; else if (strcmp(key, "mqtt.rx") == 0) _mqtt_prefs.mqtt_rx_enabled = enabled; + else if (strcmp(key, "mqtt.advert.verify") == 0) _mqtt_prefs.mqtt_advert_verify = enabled; else _mqtt_prefs.snmp_enabled = enabled; changed = true; } else if (strcmp(key, "mqtt.tx") == 0) { diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index 6d94128a3..83695245c 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -3436,8 +3436,8 @@ bool MyMesh::onPeerPathRecv(mesh::Packet *packet, int sender_idx, const uint8_t void MyMesh::onControlDataRecv(mesh::Packet* packet) { uint8_t type = packet->payload[0] & 0xF0; // just test upper 4 bits if (type == CTL_TYPE_NODE_DISCOVER_REQ && packet->payload_len >= 6 - && discover_limiter.allow(rtc_clock.getCurrentTime()) && !isHiddenNode() // this node wishes to NOT be discoverable + && discover_limiter.allow(rtc_clock.getCurrentTime()) ) { int i = 1; uint8_t filter = packet->payload[i++]; diff --git a/scripts/webconfig_mock_server.py b/scripts/webconfig_mock_server.py index c346e9dbb..c9461d180 100644 --- a/scripts/webconfig_mock_server.py +++ b/scripts/webconfig_mock_server.py @@ -121,7 +121,7 @@ def default_config(setup_mode): "mqtt": { "enabled": False, "origin": "" if setup_mode else "MockNode", "iata": "" if setup_mode else "DEN", - "status": True, "packets": True, "raw": False, "tx": "advert", "rx": True, + "status": True, "packets": True, "raw": False, "tx": "advert", "rx": True, "advert_verify": False, "interval": 5, "timezone": "MST7MDT,M3.2.0,M11.1.0", "timezone_offset": -7, "ntp": "pool.ntp.org", "owner": "", "email": "", "snmp": False, "snmp_community": "public", @@ -228,7 +228,8 @@ BOOL_KEYS = {"mqtt.enabled": ("mqtt", "enabled"), "cad": ("radio", "cad"), "radi "powersaving": ("radio", "powersaving"), "repeat": ("radio", "repeat"), "mqtt.status": ("mqtt", "status"), "mqtt.packets": ("mqtt", "packets"), "mqtt.raw": ("mqtt", "raw"), - "mqtt.rx": ("mqtt", "rx"), "snmp": ("mqtt", "snmp"), + "mqtt.rx": ("mqtt", "rx"), "mqtt.advert.verify": ("mqtt", "advert_verify"), + "snmp": ("mqtt", "snmp"), "mqtt.neighbors": ("mqtt", "neighbors")} INT_KEYS = {"tx": ("radio", "tx"), "flood.max": ("radio", "flood_max"), "flood.max.advert": ("radio", "flood_max_advert"), diff --git a/src/helpers/AdvertSignature.h b/src/helpers/AdvertSignature.h new file mode 100644 index 000000000..3dc4bf2fe --- /dev/null +++ b/src/helpers/AdvertSignature.h @@ -0,0 +1,31 @@ +#pragma once + +#include +#include +#include + +// Signed-message layout of an ADVERT payload, mirroring Mesh::onRecvPacket: +// pub_key(32) + timestamp(4) + signature(64) + app_data. Oversized app_data is +// rejected, like Mesh::onRecvPacket: truncation would accept an unsigned tail. +namespace AdvertSignature { + +static const int kHeaderLen = PUB_KEY_SIZE + 4 + SIGNATURE_SIZE; +static const int kMaxMessageLen = PUB_KEY_SIZE + 4 + MAX_ADVERT_DATA_SIZE; + +// Builds the signed message into `out` (kMaxMessageLen bytes) and points `sig` +// at the signature. Returns the message length, or -1 for invalid arguments or +// a payload outside the complete signed advert layout. Clears sig on failure. +static inline int buildMessage(const uint8_t* payload, int payload_len, + uint8_t* out, const uint8_t** sig) { + if (sig == nullptr) return -1; + *sig = nullptr; + if (payload == nullptr || out == nullptr || payload_len < kHeaderLen || + payload_len > kHeaderLen + MAX_ADVERT_DATA_SIZE) return -1; + int app_data_len = payload_len - kHeaderLen; + memcpy(out, payload, PUB_KEY_SIZE + 4); + memcpy(out + PUB_KEY_SIZE + 4, payload + kHeaderLen, app_data_len); + *sig = payload + PUB_KEY_SIZE + 4; + return PUB_KEY_SIZE + 4 + app_data_len; +} + +} // namespace AdvertSignature diff --git a/src/helpers/CommonCLI.cpp b/src/helpers/CommonCLI.cpp index 4b0deff4c..f330652e9 100644 --- a/src/helpers/CommonCLI.cpp +++ b/src/helpers/CommonCLI.cpp @@ -2776,11 +2776,11 @@ void CommonCLI::loadMQTTPrefs( if (MQTTPrefsCodec::repairDisplayPrefs(&_mqtt_prefs)) { // Old payloads retain valid defaults because the object was defaulted before // their shorter prefix was copied. This branch repairs only a full/newer - // payload carrying invalid display values. + // payload carrying invalid display or advert-verification values. if (!_mqtt_prefs_hold) { mqtt_rewrite_pending = true; } - MESH_DEBUG_PRINTLN("MQTT: invalid display preferences reset to defaults"); + MESH_DEBUG_PRINTLN("MQTT: invalid display/advert preferences reset to defaults"); } _legacy_tail.valid = false; diff --git a/src/helpers/CommonCLI_Observer.cpp b/src/helpers/CommonCLI_Observer.cpp index b6e72ea05..68347c597 100644 --- a/src/helpers/CommonCLI_Observer.cpp +++ b/src/helpers/CommonCLI_Observer.cpp @@ -330,6 +330,19 @@ bool CommonCLI::handleObserverSetCmd(uint32_t sender_timestamp, const char* conf _mqtt_prefs.mqtt_rx_enabled = memcmp(&config[8], "on", 2) == 0 ? 1 : 0; saveObserverPrefs(); strcpy(reply, "OK"); + } else if (memcmp(config, "mqtt.advert.verify ", 19) == 0) { + if (strcmp(config + 19, "on") != 0 && strcmp(config + 19, "off") != 0) { + strcpy(reply, "Error: must be on or off"); + return true; + } + const uint8_t previous = _mqtt_prefs.mqtt_advert_verify; + _mqtt_prefs.mqtt_advert_verify = strcmp(config + 19, "on") == 0; + if (!saveObserverPrefs()) { + _mqtt_prefs.mqtt_advert_verify = previous; + strcpy(reply, "Error: settings could not be saved"); + return true; + } + strcpy(reply, "OK"); } else if (memcmp(config, "mqtt.interval ", 14) == 0) { uint32_t minutes = _atoi(&config[14]); if (minutes >= 1 && minutes <= 60) { @@ -956,6 +969,8 @@ bool CommonCLI::handleObserverGetCmd(uint32_t sender_timestamp, const char* conf sprintf(reply, "> %s", tx_str); } else if (memcmp(config, "mqtt.rx", 7) == 0) { sprintf(reply, "> %s", _mqtt_prefs.mqtt_rx_enabled ? "on" : "off"); + } else if (strcmp(config, "mqtt.advert.verify") == 0) { + sprintf(reply, "> %s", _mqtt_prefs.mqtt_advert_verify ? "on" : "off"); } else if (memcmp(config, "mqtt.interval", 13) == 0) { uint32_t minutes = (_mqtt_prefs.mqtt_status_interval + 29999) / 60000; sprintf(reply, "> %u minutes (%lu ms)", minutes, (unsigned long)_mqtt_prefs.mqtt_status_interval); diff --git a/src/helpers/CompanionMqttPrefsNvs.h b/src/helpers/CompanionMqttPrefsNvs.h index ad20b27fc..f4c63fed2 100644 --- a/src/helpers/CompanionMqttPrefsNvs.h +++ b/src/helpers/CompanionMqttPrefsNvs.h @@ -9,8 +9,10 @@ // The ESP32 Companion setup portal stores a raw MQTTPrefs blob in NVS rather // than the observer's versioned /mqtt_prefs file. Display preferences are not -// used by Companion firmware, so keep writing the last pre-display boundary: -// older firmware can then read a record saved by a newer image after rollback. +// used by Companion firmware. With advert verification off, keep writing the +// last pre-display boundary so rollback preserves a normally configured node. +// Enabling verification opts into a separately marked blob: an old recovery +// record's former padding must never silently enable a new packet filter. namespace CompanionMqttPrefsNvs { static constexpr size_t kWriteSize = MQTT_PREFS_V1_PRE_DISPLAY_PAYLOAD_SIZE; @@ -20,10 +22,24 @@ static constexpr size_t kWriteSize = MQTT_PREFS_V1_PRE_DISPLAY_PAYLOAD_SIZE; static constexpr size_t kRecoverySize = 2880; static_assert(sizeof(MQTTPrefs) >= kRecoverySize, "MQTTPrefs no longer contains the recoverable NVS prefix"); +static constexpr size_t kVerifiedSize = 2881; +static constexpr uint8_t kVerifiedMarker = 0xa7; +struct Record { + MQTTPrefs prefs; + uint8_t marker; +}; +static_assert(offsetof(Record, marker) == kRecoverySize, + "the marked NVS blob must append exactly one format byte"); +static_assert(sizeof(Record) >= kVerifiedSize, + "the marked NVS record must contain its whole serialized blob"); + +inline size_t writeSizeFor(const MQTTPrefs& prefs) { + return prefs.mqtt_advert_verify != 0 ? kVerifiedSize : kWriteSize; +} inline bool accepts(uint16_t version, size_t length) { return version == MQTT_PREFS_VERSION - && (length == kWriteSize || length == kRecoverySize); + && (length == kWriteSize || length == kRecoverySize || length == kVerifiedSize); } } // namespace CompanionMqttPrefsNvs diff --git a/src/helpers/CompanionMqttSetupPortal.cpp b/src/helpers/CompanionMqttSetupPortal.cpp index 5a2689cb7..814d4c054 100644 --- a/src/helpers/CompanionMqttSetupPortal.cpp +++ b/src/helpers/CompanionMqttSetupPortal.cpp @@ -594,12 +594,20 @@ bool CompanionMqttSetupPortal::loadStoredConfig(MQTTPrefs& prefs) { // default its append-only display tail so adding observer UI preferences // cannot discard an existing Companion MQTT setup after an update. const bool shape_ok = CompanionMqttPrefsNvs::accepts(version, length); - MQTTPrefs loaded; + CompanionMqttPrefsNvs::Record record = {}; + MQTTPrefs& loaded = record.prefs; applyMQTTDefaults(&loaded); - const size_t read = shape_ok ? nvs.getBytes(NVS_PREFS_KEY, &loaded, length) : 0; + const size_t read = shape_ok ? nvs.getBytes(NVS_PREFS_KEY, &record, length) : 0; nvs.end(); if (!shape_ok || read != length) return false; + // The old 2880-byte recovery blob ended with unspecified padding, including + // potentially 1. Only an explicitly marked new blob can opt into filtering. + // Invalid optional metadata must not discard otherwise valid credentials. + if (length != CompanionMqttPrefsNvs::kVerifiedSize || + record.marker != CompanionMqttPrefsNvs::kVerifiedMarker || + loaded.mqtt_advert_verify > 1) loaded.mqtt_advert_verify = 0; + if (!storedStringsAreValid(loaded) || loaded.mqtt_status_enabled > 1 || loaded.mqtt_packets_enabled > 1 @@ -663,15 +671,20 @@ bool CompanionMqttSetupPortal::saveEnabled(bool enabled) { bool CompanionMqttSetupPortal::saveStoredConfig(const MQTTPrefs& prefs) { Preferences nvs; if (!nvs.begin(NVS_NAMESPACE, false)) return false; - // Companion does not consume the appended observer display tail. Keeping - // this record at the pre-display boundary makes a firmware rollback retain - // the MQTT setup. loadStoredConfig() still accepts the briefly written 2880 - // byte runtime shape so development images remain recoverable. - const size_t written = nvs.putBytes( - NVS_PREFS_KEY, &prefs, CompanionMqttPrefsNvs::kWriteSize); + // Default configuration keeps the rollback-readable pre-display boundary. + // Advert verification and its format marker are one atomic NVS blob write, + // not separate preference keys that can disagree after power loss. + const size_t payload_size = CompanionMqttPrefsNvs::writeSizeFor(prefs); + size_t written; + if (prefs.mqtt_advert_verify != 0) { + const CompanionMqttPrefsNvs::Record record = {prefs, CompanionMqttPrefsNvs::kVerifiedMarker}; + written = nvs.putBytes(NVS_PREFS_KEY, &record, payload_size); + } else { + written = nvs.putBytes(NVS_PREFS_KEY, &prefs, payload_size); + } const size_t version_written = nvs.putUShort(NVS_VERSION_KEY, MQTT_PREFS_VERSION); nvs.end(); - return written == CompanionMqttPrefsNvs::kWriteSize + return written == payload_size && version_written == sizeof(uint16_t); } diff --git a/src/helpers/MQTTDefaults.h b/src/helpers/MQTTDefaults.h index b2f1c809e..f2f559d9d 100644 --- a/src/helpers/MQTTDefaults.h +++ b/src/helpers/MQTTDefaults.h @@ -70,6 +70,7 @@ static inline void applyMQTTDefaults(MQTTPrefs* prefs) { prefs->mqtt_raw_enabled = 0; prefs->mqtt_tx_enabled = 2; prefs->mqtt_rx_enabled = 1; + prefs->mqtt_advert_verify = 0; prefs->mqtt_status_interval = 300000; prefs->wifi_power_save = mesh::wifi::kDefaultPowerSave; diff --git a/src/helpers/MQTTPrefsCodec.h b/src/helpers/MQTTPrefsCodec.h index 0ea7504e0..4a8e095e8 100644 --- a/src/helpers/MQTTPrefsCodec.h +++ b/src/helpers/MQTTPrefsCodec.h @@ -40,21 +40,24 @@ static const size_t kV1PreObserverPayloadSize = MQTT_PREFS_V1_PRE_OBSERVER_PAYLO static const size_t kV1PreNeighborsPayloadSize = MQTT_PREFS_V1_PRE_NEIGHBORS_PAYLOAD_SIZE; static const size_t kV1PreFilterPayloadSize = MQTT_PREFS_V1_PRE_FILTER_PAYLOAD_SIZE; static const size_t kV1PreDisplayPayloadSize = MQTT_PREFS_V1_PRE_DISPLAY_PAYLOAD_SIZE; +static const size_t kV1PreAdvertPayloadSize = MQTT_PREFS_V1_PRE_ADVERT_PAYLOAD_SIZE; static const size_t kV1BaselinePayloadSize = MQTT_PREFS_V1_FULL_PAYLOAD_SIZE; static const size_t kEncodedSize = sizeof(MQTTPrefsHeader) + kV1BaselinePayloadSize; // Shortest payload length that still round-trips this configuration. // -// The packet-filter and display tails are optional. Their defaults are exactly +// The packet-filter, display and advert-verification tails are optional. Their +// defaults are exactly // what a decoder supplies when the corresponding tail is missing, so merely // upgrading does not lengthen the file. That matters because /mqtt_prefs also // carries WiFi credentials: old firmware that cannot read a longer payload can // otherwise lose its network configuration after a downgrade. Changing a -// filter or display control deliberately opts the node into the required tail. +// filter, display or verification control deliberately opts into its tail. inline size_t payloadLenFor(const MQTTPrefs& prefs) { + if (prefs.mqtt_advert_verify != 0) return kV1BaselinePayloadSize; if (prefs.display_timeout_secs != DISPLAY_TIMEOUT_DEFAULT_SECS || prefs.display_flip != 0) { - return kV1BaselinePayloadSize; + return kV1PreAdvertPayloadSize; } return MQTTPacketFilter::allMasksDefault(prefs.mqtt_slot_packet_filter, MQTT_PREFS_SLOT_COUNT) @@ -62,7 +65,8 @@ inline size_t payloadLenFor(const MQTTPrefs& prefs) { : kV1PreDisplayPayloadSize; } -// Clamp the display tail after loading. Returns true when a persisted value was +// Clamp the optional display/verification tails after loading. Retain the helper +// name for callers predating advert verification. Returns true when a value was // repaired and should be rewritten (unless the source file is being held). inline bool repairDisplayPrefs(MQTTPrefs* prefs) { if (prefs == nullptr) return false; @@ -75,6 +79,10 @@ inline bool repairDisplayPrefs(MQTTPrefs* prefs) { prefs->display_flip = 0; repaired = true; } + if (prefs->mqtt_advert_verify > 1) { + prefs->mqtt_advert_verify = 0; + repaired = true; + } return repaired; } @@ -156,6 +164,10 @@ inline DecodePlan classify(const uint8_t* prefix, size_t prefix_read, size_t fil if (header.payload_len == kV1BaselinePayloadSize) { return {Source::Current, false, false, true, kV1BaselinePayloadSize}; } + if (header.payload_len == kV1PreAdvertPayloadSize) { + // Former complete layout. The default keeps advert verification off. + return {Source::Current, false, false, true, kV1PreAdvertPayloadSize}; + } if (header.payload_len == kV1PreDisplayPayloadSize) { // Written before runtime display controls. Defaults supply timeout and // flip while preserving the packet-filter tail. diff --git a/src/helpers/MQTTPrefsSerializer.h b/src/helpers/MQTTPrefsSerializer.h index 3073a144b..fea05e688 100644 --- a/src/helpers/MQTTPrefsSerializer.h +++ b/src/helpers/MQTTPrefsSerializer.h @@ -203,10 +203,10 @@ class MQTTPrefsSerializer : public ConfigSerializer { class MqttPrefs : public ConfigSerializer { MQTTPrefs* _prefs; - int32_t _packets_enabled, _raw_enabled, _tx_enabled, _rx_enabled; + int32_t _packets_enabled, _raw_enabled, _tx_enabled, _rx_enabled, _advert_verify; char _default_iata[sizeof(MQTTPrefs::mqtt_iata)]; bool _seen_origin = false, _seen_iata = false, _seen_packets = false; - bool _seen_raw = false, _seen_tx = false, _seen_rx = false; + bool _seen_raw = false, _seen_tx = false, _seen_rx = false, _seen_advert_verify = false; bool _seen_status = false, _seen_neighbors = false, _seen_owner = false; bool _seen_slot1 = false, _seen_slot2 = false, _seen_slot3 = false; bool _seen_slot4 = false, _seen_slot5 = false, _seen_slot6 = false; @@ -224,6 +224,7 @@ class MQTTPrefsSerializer : public ConfigSerializer { defStrict("raw_enabled", _raw_enabled, _seen_raw); defStrict("tx_enabled", _tx_enabled, _seen_tx); defStrict("rx_enabled", _rx_enabled, _seen_rx); + defStrict("advert_verify", _advert_verify, _seen_advert_verify); defStrict("status", _status, _seen_status); defStrict("neighbors", _neighbors, _seen_neighbors); defStrict("owner", _owner, _seen_owner); @@ -239,7 +240,8 @@ class MQTTPrefsSerializer : public ConfigSerializer { MQTTPrefsPresetValidator preset_valid) : _prefs(prefs), _packets_enabled(prefs->mqtt_packets_enabled), _raw_enabled(prefs->mqtt_raw_enabled), _tx_enabled(prefs->mqtt_tx_enabled), - _rx_enabled(prefs->mqtt_rx_enabled), _status(prefs), _neighbors(prefs), + _rx_enabled(prefs->mqtt_rx_enabled), _advert_verify(prefs->mqtt_advert_verify), + _status(prefs), _neighbors(prefs), _owner(prefs), _slot1(prefs, 0, preset_valid), _slot2(prefs, 1, preset_valid), _slot3(prefs, 2, preset_valid), _slot4(prefs, 3, preset_valid), _slot5(prefs, 4, preset_valid), @@ -257,10 +259,12 @@ class MQTTPrefsSerializer : public ConfigSerializer { if (_raw_enabled < 0 || _raw_enabled > 1) { _raw_enabled = 0; *repaired = true; } if (_tx_enabled < 0 || _tx_enabled > 2) { _tx_enabled = 2; *repaired = true; } if (_rx_enabled < 0 || _rx_enabled > 1) { _rx_enabled = 1; *repaired = true; } + if (_advert_verify < 0 || _advert_verify > 1) { _advert_verify = 0; *repaired = true; } _prefs->mqtt_packets_enabled = static_cast(_packets_enabled); _prefs->mqtt_raw_enabled = static_cast(_raw_enabled); _prefs->mqtt_tx_enabled = static_cast(_tx_enabled); _prefs->mqtt_rx_enabled = static_cast(_rx_enabled); + _prefs->mqtt_advert_verify = static_cast(_advert_verify); if (_prefs->mqtt_iata[0] != '\0') { if (!mqttIataValid(_prefs->mqtt_iata)) { memcpy(_prefs->mqtt_iata, _default_iata, sizeof(_default_iata)); diff --git a/src/helpers/MQTTPrefsStorage.h b/src/helpers/MQTTPrefsStorage.h index 6478e3574..e804354a4 100644 --- a/src/helpers/MQTTPrefsStorage.h +++ b/src/helpers/MQTTPrefsStorage.h @@ -125,10 +125,13 @@ struct MQTTPrefs { // Runtime display controls. These are an append-only v1 tail: older payloads // stop before them and retain the defaults below. The serialized payload ends - // after display_flip at byte 2879; the compiler's final alignment byte is not - // part of the on-flash format. + // after display_flip at byte 2879 on pre-verification firmware. uint16_t display_timeout_secs; uint8_t display_flip; + // Drop received adverts whose Ed25519 signature fails before publishing. + // Append-only v1 tail at byte 2879 (formerly final struct padding). Defaults + // off; only enabling verification requires the 2880-byte serialized payload. + uint8_t mqtt_advert_verify; }; // Neighbor discovery is scheduled with the wrap-safe millis() helpers, whose @@ -144,7 +147,7 @@ static const uint32_t MQTT_NEIGHBORS_DEFAULT_INTERVAL_MS = MQTT_NEIGHBORS_DEFAUL static const uint16_t DISPLAY_TIMEOUT_DEFAULT_SECS = 60; static const uint16_t DISPLAY_TIMEOUT_MAX_SECS = 3600; -// Version-1 has five payload layouts this firmware can decode. Never infer a +// Version-1 has six payload layouts this firmware can decode. Never infer a // compatible payload from an arbitrary SHORTER size: raw prefs have no // checksum, so a short length has to match a boundary that was really shipped. // @@ -157,18 +160,20 @@ static const uint16_t DISPLAY_TIMEOUT_MAX_SECS = 3600; // - PRE_NEIGHBORS (2860): full observer tail, no neighbors fields yet. // - PRE_FILTER (2864): neighbors tail, no per-slot packet filters. // - PRE_DISPLAY (2876): six filter masks, no runtime display controls. -// - FULL (2879): timeout plus flip, excluding final struct padding. +// - PRE_ADVERT (2879): timeout plus flip, excluding final struct padding. +// - FULL (2880): advert verification consumes that former padding. // // FULL is the maximum written, not the default: MQTTPrefsCodec::payloadLenFor() // keeps emitting PRE_FILTER while every slot and display control has its // default. A non-default filter writes PRE_DISPLAY; a non-default display -// control writes FULL. See the rollback note there -- /mqtt_prefs also carries -// the WiFi credentials. +// control writes PRE_ADVERT; enabling advert verification writes FULL. See the +// rollback note there -- /mqtt_prefs also carries the WiFi credentials. static const size_t MQTT_PREFS_V1_PRE_OBSERVER_PAYLOAD_SIZE = 2736; static const size_t MQTT_PREFS_V1_PRE_NEIGHBORS_PAYLOAD_SIZE = 2860; static const size_t MQTT_PREFS_V1_PRE_FILTER_PAYLOAD_SIZE = 2864; static const size_t MQTT_PREFS_V1_PRE_DISPLAY_PAYLOAD_SIZE = 2876; -static const size_t MQTT_PREFS_V1_FULL_PAYLOAD_SIZE = 2879; +static const size_t MQTT_PREFS_V1_PRE_ADVERT_PAYLOAD_SIZE = 2879; +static const size_t MQTT_PREFS_V1_FULL_PAYLOAD_SIZE = 2880; // /mqtt_prefs starts with a self-describing 8-byte header. Headerless files // are deployed legacy layouts and continue to be distinguished by size. @@ -303,8 +308,13 @@ static_assert(offsetof(MQTTPrefs, mqtt_slot_packet_filter) == MQTT_PREFS_V1_PRE_ static_assert(offsetof(MQTTPrefs, display_timeout_secs) == MQTT_PREFS_V1_PRE_DISPLAY_PAYLOAD_SIZE, "display timeout must begin at the pre-display payload boundary"); static_assert(offsetof(MQTTPrefs, display_flip) + sizeof(MQTTPrefs::display_flip) == - MQTT_PREFS_V1_FULL_PAYLOAD_SIZE, + MQTT_PREFS_V1_PRE_ADVERT_PAYLOAD_SIZE, "serialized display tail must end immediately after display_flip"); +static_assert(offsetof(MQTTPrefs, mqtt_advert_verify) == MQTT_PREFS_V1_PRE_ADVERT_PAYLOAD_SIZE, + "advert verification must consume only the former final padding byte"); +static_assert(offsetof(MQTTPrefs, mqtt_advert_verify) + sizeof(MQTTPrefs::mqtt_advert_verify) == + MQTT_PREFS_V1_FULL_PAYLOAD_SIZE, + "serialized advert verification tail must end immediately after its flag"); static_assert(sizeof(OldMQTTPrefs) == 472, "frozen pre-slot /mqtt_prefs layout changed"); static_assert(sizeof(PreWifiPowerOldMQTTPrefs) == 472, "frozen pre-WiFi-power /mqtt_prefs layout changed"); static_assert(offsetof(OldMQTTPrefs, wifi_power_save) == 144, diff --git a/src/helpers/WebConfigKeys.h b/src/helpers/WebConfigKeys.h index 9f8020f1b..f75a5aa99 100644 --- a/src/helpers/WebConfigKeys.h +++ b/src/helpers/WebConfigKeys.h @@ -29,7 +29,7 @@ static const char* const WC_ALLOWED_SET_KEYS[] = { // MQTTPrefs (WiFi / MQTT / misc observer) "wifi.ssid", "wifi.pwd", "wifi.powersave", "espnow.channel", "mqtt.enabled", "mqtt.origin", "mqtt.iata", "mqtt.status", "mqtt.packets", "mqtt.raw", - "mqtt.tx", "mqtt.rx", "mqtt.interval", "mqtt.neighbors", "mqtt.neighbors.interval", + "mqtt.tx", "mqtt.rx", "mqtt.advert.verify", "mqtt.interval", "mqtt.neighbors", "mqtt.neighbors.interval", "mqtt.ntp", "mqtt.owner", "mqtt.email", "timezone", "timezone.offset", "snmp", "snmp.community", }; diff --git a/src/helpers/bridges/MQTTBridge.cpp b/src/helpers/bridges/MQTTBridge.cpp index ee9d471d8..37265fe0b 100644 --- a/src/helpers/bridges/MQTTBridge.cpp +++ b/src/helpers/bridges/MQTTBridge.cpp @@ -11,6 +11,7 @@ #include "../MQTTReplyFormat.h" #include "../MQTTRuntimeBufferLifecycle.h" #include "../MQTTTopicRouter.h" +#include "../AdvertSignature.h" #include "../TxtDataHelpers.h" #include "../UsbLogging.h" #include "../WiFiPowerSave.h" @@ -519,6 +520,14 @@ void MQTTBridge::formatMqttStatsReply(char* buf, size_t bufsize) { if (b->_filtered_packets > 0) { replyAppendf(buf, bufsize, &pos, " filt=%lu", b->_filtered_packets); } + // badsig=: RX adverts dropped by mqtt.advert.verify. Omitted while zero. + if (b->_bad_sig_adverts > 0) { + replyAppendf(buf, bufsize, &pos, " badsig=%lu", b->_bad_sig_adverts); + } + // down=: ready slots that are not connected. Without this the reply cannot + // show an outage at all - sN= counts publishes, and a slot that never connects + // never publishes, so its ok freezes and its err stays 0 for the whole outage. + // Omitted while zero, like filt=, so a healthy node's reply keeps its length. int down = 0; for (int i = 0; i < RUNTIME_MQTT_SLOTS; i++) { if (MQTTConnectionHealth::isOutageSlot( @@ -3931,6 +3940,19 @@ void MQTTBridge::onPacketReceived(mesh::Packet *packet) { return; } + // Opt-in: drop adverts that fail verification (RF corruption that passed the + // LoRa CRC). The observer callback runs before Mesh::onRecvPacket validates + // received adverts; its opt-in filter must enforce the same signed layout. + if (_obs->mqtt_advert_verify && packet->getPayloadType() == PAYLOAD_TYPE_ADVERT) { + uint8_t message[AdvertSignature::kMaxMessageLen]; + const uint8_t* sig = nullptr; + int msg_len = AdvertSignature::buildMessage(packet->payload, packet->payload_len, message, &sig); + if (msg_len < 0 || !mesh::Identity(packet->payload).verify(sig, message, msg_len)) { + _bad_sig_adverts++; + return; + } + } + // Queue packet for transmission queuePacket(packet, false); } diff --git a/src/helpers/bridges/MQTTBridge.h b/src/helpers/bridges/MQTTBridge.h index 72435672e..2145d6d64 100644 --- a/src/helpers/bridges/MQTTBridge.h +++ b/src/helpers/bridges/MQTTBridge.h @@ -423,6 +423,7 @@ private: // on Core 1 (radio callbacks), read on Core 0 for `mqtt.stats`; a torn read of // a diagnostic counter is harmless, so no atomic is warranted. unsigned long _filtered_packets = 0; + unsigned long _bad_sig_adverts = 0; // RX adverts dropped by mqtt.advert.verify // Status publish retry tracking unsigned long _last_status_retry; // Track last retry attempt (separate from successful publish) diff --git a/src/helpers/esp32/WebConfigServer.cpp b/src/helpers/esp32/WebConfigServer.cpp index 68de7e7df..44d5ea52e 100644 --- a/src/helpers/esp32/WebConfigServer.cpp +++ b/src/helpers/esp32/WebConfigServer.cpp @@ -2188,6 +2188,7 @@ void WebConfigServer::handleConfigGet(AsyncWebServerRequest* req) { mqtt["tx"] = obs->mqtt_tx_enabled == 2 ? "advert" : obs->mqtt_tx_enabled == 1 ? "on" : "off"; mqtt["rx"] = (bool)obs->mqtt_rx_enabled; + mqtt["advert_verify"] = (bool)obs->mqtt_advert_verify; mqtt["interval"] = obs->mqtt_status_interval / 60000; mqtt["neighbors"] = (bool)obs->mqtt_neighbors_enabled; mqtt["neighbors_interval"] = obs->mqtt_neighbors_interval / 3600000UL; diff --git a/test/test_advert_signature/test_advert_signature.cpp b/test/test_advert_signature/test_advert_signature.cpp new file mode 100644 index 000000000..7c4cc5929 --- /dev/null +++ b/test/test_advert_signature/test_advert_signature.cpp @@ -0,0 +1,90 @@ +#include + +#include + +#include "helpers/AdvertSignature.h" + +namespace { + +std::vector fromHex(const char* hex) { + std::vector out; + for (const char* p = hex; p[0] && p[1]; p += 2) { + char byte[3] = {p[0], p[1], 0}; + out.push_back(static_cast(strtoul(byte, nullptr, 16))); + } + return out; +} + +// ADVERT payload for "LCC Andice Observer" as heard by a working observer. +const char* kAdvertPayload = + "1EEB3527ED66C732185A2F29264A7450DBA40F1686CAE580F72BC80E65B2D835" + "F18EC66A" + "1A4FBA70C4BF0A2BCA115F131A6A0F2D54A6E3F8AFCD893F3414DB9DE31EC87E" + "AD2D21F54C4C9ED1CD40147F10515BFE47CB83D27B8C20B29BF5B2A61BDA430B" + "92FCEFD50106D52AFA4C434320416E64696365204F62736572766572"; + +} // namespace + +TEST(AdvertSignature, BuildsPubKeyTimestampAndAppData) { + std::vector payload = fromHex(kAdvertPayload); + uint8_t message[AdvertSignature::kMaxMessageLen]; + const uint8_t* sig = nullptr; + + int len = AdvertSignature::buildMessage(payload.data(), static_cast(payload.size()), + message, &sig); + + int app_data_len = static_cast(payload.size()) - AdvertSignature::kHeaderLen; + ASSERT_EQ(PUB_KEY_SIZE + 4 + app_data_len, len); + EXPECT_EQ(payload.data() + PUB_KEY_SIZE + 4, sig); + EXPECT_EQ(0, memcmp(message, payload.data(), PUB_KEY_SIZE + 4)); + EXPECT_EQ(0, memcmp(message + PUB_KEY_SIZE + 4, payload.data() + AdvertSignature::kHeaderLen, + app_data_len)); +} + +TEST(AdvertSignature, RejectsPayloadShorterThanHeader) { + uint8_t payload[AdvertSignature::kHeaderLen - 1] = {}; + uint8_t message[AdvertSignature::kMaxMessageLen]; + const uint8_t* sig = nullptr; + EXPECT_EQ(-1, AdvertSignature::buildMessage(payload, sizeof(payload), message, &sig)); + EXPECT_EQ(-1, AdvertSignature::buildMessage(nullptr, 0, message, &sig)); +} + +TEST(AdvertSignature, HeaderOnlyPayloadSignsPubKeyAndTimestamp) { + uint8_t payload[AdvertSignature::kHeaderLen]; + for (int i = 0; i < AdvertSignature::kHeaderLen; i++) payload[i] = static_cast(i); + uint8_t message[AdvertSignature::kMaxMessageLen]; + const uint8_t* sig = nullptr; + EXPECT_EQ(PUB_KEY_SIZE + 4, + AdvertSignature::buildMessage(payload, sizeof(payload), message, &sig)); +} + +// Oversized unsigned tails must not bypass the observer's opt-in filter. +TEST(AdvertSignature, RejectsOversizedAppDataLikeHardenedMesh) { + uint8_t payload[AdvertSignature::kHeaderLen + MAX_ADVERT_DATA_SIZE + 10]; + for (size_t i = 0; i < sizeof(payload); i++) payload[i] = static_cast(i); + uint8_t message[AdvertSignature::kMaxMessageLen]; + const uint8_t* sig = nullptr; + + int len = AdvertSignature::buildMessage(payload, sizeof(payload), message, &sig); + + ASSERT_EQ(-1, len); + EXPECT_EQ(nullptr, sig); +} + +TEST(AdvertSignature, ChecksOutputArgumentsAndClearsStaleSignature) { + uint8_t payload[AdvertSignature::kHeaderLen + MAX_ADVERT_DATA_SIZE] = {}; + uint8_t message[AdvertSignature::kMaxMessageLen]; + const uint8_t* sig = payload; + EXPECT_EQ(-1, AdvertSignature::buildMessage(payload, -1, message, &sig)); + EXPECT_EQ(nullptr, sig); + EXPECT_EQ(-1, AdvertSignature::buildMessage(payload, sizeof(payload), nullptr, &sig)); + EXPECT_EQ(nullptr, sig); + EXPECT_EQ(-1, AdvertSignature::buildMessage(payload, sizeof(payload), message, nullptr)); + EXPECT_EQ(AdvertSignature::kMaxMessageLen, + AdvertSignature::buildMessage(payload, sizeof(payload), message, &sig)); +} + +int main(int argc, char** argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/test/test_companion_mqtt_config.py b/test/test_companion_mqtt_config.py index e488870f3..50e0e6a78 100644 --- a/test/test_companion_mqtt_config.py +++ b/test/test_companion_mqtt_config.py @@ -22,6 +22,7 @@ def function(source, signature): PREAMBLE = r""" #include +#include #include #include #include @@ -47,20 +48,33 @@ void applyMQTTDefaults(MQTTPrefs* p) { memset(p, 0, sizeof(*p)); for (auto& preset : p->mqtt_slot_preset) strcpy(preset, "none"); } -MQTTPrefs saved; +CompanionMqttPrefsNvs::Record persisted; +MQTTPrefs& saved = persisted.prefs; +size_t saved_length = CompanionMqttPrefsNvs::kWriteSize; struct Preferences { bool begin(const char*, bool) { return true; } bool isKey(const char*) { return true; } uint16_t getUShort(const char*, uint16_t) { return MQTT_PREFS_VERSION; } - size_t getBytesLength(const char*) { return CompanionMqttPrefsNvs::kWriteSize; } + size_t getBytesLength(const char*) { return saved_length; } size_t getBytes(const char*, void* dest, size_t length) { - memcpy(dest, &saved, length); + memcpy(dest, &persisted, length); return length; } + size_t putBytes(const char*, const void* source, size_t length) { + memset(&persisted, 0, sizeof(persisted)); + memcpy(&persisted, source, length); + saved_length = length; + return length; + } + size_t putUShort(const char*, uint16_t version) { + assert(version == MQTT_PREFS_VERSION); + return sizeof(version); + } void end() {} }; struct CompanionMqttSetupPortal { static bool loadStoredConfig(MQTTPrefs&); + static bool saveStoredConfig(const MQTTPrefs&); static bool hasConfiguredSlot(const MQTTPrefs&); }; """ @@ -76,6 +90,35 @@ int main() { assert(memcmp(&result, &saved, sizeof(saved)) == 0); assert(!CompanionMqttSetupPortal::hasConfiguredSlot(result)); + // Enabling verification persists the appended flag, without lengthening + // ordinary default-off Companion records or losing any broker credentials. + MQTTPrefs enabled = result; + enabled.mqtt_advert_verify = 1; + assert(CompanionMqttSetupPortal::saveStoredConfig(enabled)); + assert(saved_length == 2881); + assert(CompanionMqttSetupPortal::loadStoredConfig(result)); + assert(result.mqtt_advert_verify == 1); + assert(strcmp(result.mqtt_slot_password[0], "keep-this-secret") == 0); + // Every old recovery padding value is unconditionally default-off. + saved_length=2880; + for (uint8_t padding : {uint8_t(0), uint8_t(1), uint8_t(255)}) { + saved.mqtt_advert_verify=padding; + assert(CompanionMqttSetupPortal::loadStoredConfig(result)); + assert(result.mqtt_advert_verify==0); + assert(strcmp(result.mqtt_slot_password[0], "keep-this-secret")==0); + } + saved_length=2881; + saved.mqtt_advert_verify=1; persisted.marker=0; // malformed marker + assert(CompanionMqttSetupPortal::loadStoredConfig(result)); + assert(result.mqtt_advert_verify==0); + persisted.marker=CompanionMqttPrefsNvs::kVerifiedMarker; + saved.mqtt_advert_verify=255; + assert(CompanionMqttSetupPortal::loadStoredConfig(result)); + assert(result.mqtt_advert_verify == 0); + assert(strcmp(result.mqtt_slot_password[0], "keep-this-secret") == 0); + assert(CompanionMqttSetupPortal::saveStoredConfig(result)); + assert(saved_length == 2876); + // Saving a custom broker in stages must not reset it on the next boot. strcpy(saved.mqtt_slot_preset[0], "custom"); assert(CompanionMqttSetupPortal::loadStoredConfig(result)); @@ -143,6 +186,7 @@ class CompanionMqttConfigTests(unittest.TestCase): for signature in ( "static bool isThreeLetterCode", "bool CompanionMqttSetupPortal::loadStoredConfig", + "bool CompanionMqttSetupPortal::saveStoredConfig", "bool CompanionMqttSetupPortal::hasConfiguredSlot", ): actual += function(source, signature) diff --git a/test/test_mqtt_advert_verify.py b/test/test_mqtt_advert_verify.py new file mode 100644 index 000000000..3098f0cb3 --- /dev/null +++ b/test/test_mqtt_advert_verify.py @@ -0,0 +1,132 @@ +"""Exercise the production MQTT advert gate and transactional CLI on the host.""" +from pathlib import Path +import subprocess +import tempfile +import unittest + +from test_replay_reset_integration import extract_braced + +ROOT = Path(__file__).resolve().parents[1] + + +def compile_run(source): + with tempfile.TemporaryDirectory(prefix="mqtt-advert-verify-") as directory: + cpp, binary = Path(directory) / "test.cpp", Path(directory) / "test" + cpp.write_text(source, encoding="utf-8") + result = subprocess.run([ + "g++", "-std=c++17", "-Wall", "-Wextra", "-Werror", "-Wno-unused-parameter", + "-fsanitize=address,undefined", "-fno-sanitize-recover=all", + "-fno-pie", "-no-pie", "-I", str(ROOT / "src"), + str(cpp), "-o", str(binary), + ], text=True, capture_output=True) + if result.returncode: + raise AssertionError(result.stdout + result.stderr) + subprocess.run([str(binary)], check=True) + + +class MqttAdvertVerifyTests(unittest.TestCase): + def test_actual_queue_gate_rejects_bad_and_unsigned_tails_only_when_enabled(self): + source = (ROOT / "src/helpers/bridges/MQTTBridge.cpp").read_text() + method = extract_braced(source, "void MQTTBridge::onPacketReceived(") + compile_run(r''' +#include +#include +#include +#include +#include +constexpr unsigned PAYLOAD_TYPE_ADVERT=4, PAYLOAD_TYPE_TEXT=2; +static unsigned verifies=0; +static bool valid_signature=true; +namespace mesh { +struct Packet { + uint8_t payload[184] = {}; + int payload_len=100; + unsigned type=PAYLOAD_TYPE_ADVERT; + unsigned getPayloadType() const { return type; } +}; +struct Identity { + const uint8_t* pub; + explicit Identity(const uint8_t* p) : pub(p) {} + bool verify(const uint8_t* sig, const uint8_t* message, int size) { + ++verifies; + assert(sig == pub+36 && size>=36 && size<=68); + assert(!memcmp(pub,message,36)); + if(size>36) assert(!memcmp(pub+100,message+36,size-36)); + return valid_signature; + } +}; +} +struct MQTTBridge { + struct Obs { bool mqtt_packets_enabled=true, mqtt_rx_enabled=true, mqtt_advert_verify=false; } obs; + Obs* _obs=&obs; + bool _initialized=true, type_allowed=true, scope_allowed=true; + struct Stop { bool value=false; bool load(int)const{return value;} } _stop_requested; + unsigned _filtered_packets=0, _bad_sig_adverts=0, queued=0; + bool shouldQueuePacketType(unsigned, bool& filtered) { filtered=!type_allowed; return type_allowed; } + bool allowsPacket(mesh::Packet*) { return scope_allowed; } + void queuePacket(mesh::Packet*,bool tx){assert(!tx);++queued;} + void onPacketReceived(mesh::Packet*); +}; +namespace std { constexpr int memory_order_acquire=0; } +''' + method + r''' +int main() { + MQTTBridge bridge; mesh::Packet packet; + for(unsigned i=0;i +#include +#include +struct CLI { + struct { uint8_t mqtt_advert_verify=0; } _mqtt_prefs; + bool save_ok=true; unsigned saves=0; + bool saveObserverPrefs(){++saves;return save_ok;} + bool set(const char* config,char* reply) { +''' + branch + r''' + else return false; + return true; + } +}; +int main() { + CLI cli; char reply[160]={}; + assert(cli.set("mqtt.advert.verify on",reply)); + assert(cli.saves==1 && cli._mqtt_prefs.mqtt_advert_verify==1 && !strcmp(reply,"OK")); + assert(cli.set("mqtt.advert.verify onx",reply)); + assert(cli.saves==1 && cli._mqtt_prefs.mqtt_advert_verify==1 && !strncmp(reply,"Error:",6)); + cli.save_ok=false; assert(cli.set("mqtt.advert.verify off",reply)); + assert(cli.saves==2 && cli._mqtt_prefs.mqtt_advert_verify==1 && !strncmp(reply,"Error:",6)); + cli.save_ok=true; assert(cli.set("mqtt.advert.verify off",reply)); + assert(cli.saves==3 && cli._mqtt_prefs.mqtt_advert_verify==0 && !strcmp(reply,"OK")); +} +''') + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_mqtt_diagnostics.py b/test/test_mqtt_diagnostics.py index bfcaf823a..fdcd30df7 100644 --- a/test/test_mqtt_diagnostics.py +++ b/test/test_mqtt_diagnostics.py @@ -75,6 +75,7 @@ struct MQTTBridge { void* _packet_queue_handle = reinterpret_cast(1); int _queue_count = 3, _skipped_publishes = 0; unsigned long _filtered_packets = 0; + unsigned long _bad_sig_adverts = 0; static constexpr int MAX_QUEUE_SIZE = 50; bool isSlotReady(int) const { return true; } static void formatMqttStatsReply(char*, size_t); @@ -114,6 +115,10 @@ int main() { #endif // Normal five-slot stats still fit the fixed LoRa reply budget. assert(strstr(reply, "s5=123/4") != nullptr); + assert(strstr(reply, "badsig=") == nullptr); + bridge._bad_sig_adverts = 7; + MQTTBridge::formatMqttStatsReply(reply, sizeof(reply)); + assert(strstr(reply, "badsig=7") != nullptr); bridge.logMemoryStatus(); #ifdef ESP_PLATFORM @@ -126,6 +131,7 @@ int main() { struct { char reply[32]; unsigned char guard[8]; } bounded; memset(&bounded, 0xA5, sizeof(bounded)); bridge._filtered_packets = 4294967295UL; + bridge._bad_sig_adverts = 4294967295UL; dma_free = 4294967295UL; dma_largest = 4294967295UL; MQTTBridge::formatMqttStatsReply(bounded.reply, sizeof(bounded.reply)); diff --git a/test/test_mqtt_prefs_codec/test_mqtt_prefs_codec.cpp b/test/test_mqtt_prefs_codec/test_mqtt_prefs_codec.cpp index 11b25c2fa..70397f345 100644 --- a/test/test_mqtt_prefs_codec/test_mqtt_prefs_codec.cpp +++ b/test/test_mqtt_prefs_codec/test_mqtt_prefs_codec.cpp @@ -242,6 +242,7 @@ TEST(MQTTPrefsCodec, CurrentVersionedPayloadRoundTripsExactly) { } source.display_timeout_secs = 300; source.display_flip = 1; + source.mqtt_advert_verify = 1; std::vector bytes(Codec::kEncodedSize); ASSERT_EQ(Codec::kEncodedSize, Codec::encode(source, bytes.data(), bytes.size())); @@ -331,11 +332,12 @@ TEST(MQTTPrefsCodec, EncodeRefusesAnOutputBufferShorterThanItsChosenPayload) { MQTTPrefs longest = defaults(); longest.mqtt_slot_packet_filter[0] = 0; longest.display_flip = 1; + longest.mqtt_advert_verify = 1; EXPECT_EQ(0u, Codec::encode(longest, bytes.data(), short_size)); EXPECT_EQ(Codec::kEncodedSize, Codec::encode(longest, bytes.data(), bytes.size())); } -TEST(MQTTPrefsCodec, EitherNonDefaultDisplayControlOptsIntoTheFullTail) { +TEST(MQTTPrefsCodec, EitherNonDefaultDisplayControlKeepsThePreAdvertTail) { for (int scenario = 0; scenario < 2; ++scenario) { MQTTPrefs source = defaults(); if (scenario == 0) { @@ -344,18 +346,70 @@ TEST(MQTTPrefsCodec, EitherNonDefaultDisplayControlOptsIntoTheFullTail) { source.display_flip = 1; } - ASSERT_EQ(Codec::kV1BaselinePayloadSize, Codec::payloadLenFor(source)); + ASSERT_EQ(Codec::kV1PreAdvertPayloadSize, Codec::payloadLenFor(source)); std::vector bytes(Codec::kEncodedSize); - ASSERT_EQ(Codec::kEncodedSize, - Codec::encode(source, bytes.data(), bytes.size())); + const size_t written = Codec::encode(source, bytes.data(), bytes.size()); + ASSERT_EQ(sizeof(MQTTPrefsHeader) + Codec::kV1PreAdvertPayloadSize, written); + bytes.resize(written); const Codec::DecodePlan plan = classify(bytes); ASSERT_EQ(Codec::Source::Current, plan.source); - ASSERT_EQ(Codec::kV1BaselinePayloadSize, plan.payload_len); + ASSERT_EQ(Codec::kV1PreAdvertPayloadSize, plan.payload_len); MQTTPrefs loaded = defaults(); memcpy(&loaded, bytes.data() + sizeof(MQTTPrefsHeader), plan.payload_len); EXPECT_EQ(source.display_timeout_secs, loaded.display_timeout_secs); EXPECT_EQ(source.display_flip, loaded.display_flip); + EXPECT_EQ(0, loaded.mqtt_advert_verify); + } +} + +TEST(MQTTPrefsCodec, AdvertVerifyRoundTripsInOneAppendOnlyByteAndDefaultsOffOnOldFiles) { + MQTTPrefs source = defaults(); + strcpy(source.wifi_ssid, "keep-network"); + strcpy(source.mqtt_slot_password[0], "keep-credentials"); + EXPECT_EQ(0, source.mqtt_advert_verify); + EXPECT_EQ(2864u, Codec::payloadLenFor(source)); + source.mqtt_advert_verify = 1; + EXPECT_EQ(2880u, Codec::payloadLenFor(source)); + std::vector bytes(Codec::kEncodedSize); + ASSERT_EQ(bytes.size(), Codec::encode(source, bytes.data(), bytes.size())); + EXPECT_EQ(1, bytes[sizeof(MQTTPrefsHeader) + 2879]); + const Codec::DecodePlan plan = classify(bytes); + ASSERT_EQ(2880u, plan.payload_len); + MQTTPrefs loaded = defaults(); + memcpy(&loaded, bytes.data() + sizeof(MQTTPrefsHeader), plan.payload_len); + EXPECT_EQ(1, loaded.mqtt_advert_verify); + EXPECT_STREQ("keep-network", loaded.wifi_ssid); + EXPECT_STREQ("keep-credentials", loaded.mqtt_slot_password[0]); + + // Each shipped older v1 boundary leaves the new default intact. + const auto complete = bytes; + for (size_t boundary : {size_t(2736), size_t(2860), size_t(2864), + size_t(2876), size_t(2879)}) { + bytes = complete; + bytes.resize(sizeof(MQTTPrefsHeader) + boundary); + writeHeader(&bytes, MQTT_PREFS_VERSION, static_cast(boundary)); + const Codec::DecodePlan older = classify(bytes); + ASSERT_EQ(Codec::Source::Current, older.source) << boundary; + ASSERT_EQ(boundary, older.payload_len); + loaded = defaults(); + memcpy(&loaded, bytes.data() + sizeof(MQTTPrefsHeader), older.payload_len); + EXPECT_EQ(0, loaded.mqtt_advert_verify) << boundary; + EXPECT_STREQ("keep-network", loaded.wifi_ssid); + } +} + +TEST(MQTTPrefsCodec, InvalidAdvertVerifyRepairsWithoutDiscardingCredentials) { + MQTTPrefs prefs = defaults(); + strcpy(prefs.wifi_ssid, "keep-network"); + prefs.mqtt_advert_verify = 1; + EXPECT_FALSE(Codec::repairDisplayPrefs(&prefs)); + for (uint8_t value : {uint8_t(2), uint8_t(255)}) { + prefs.mqtt_advert_verify = value; + EXPECT_TRUE(Codec::repairDisplayPrefs(&prefs)); + EXPECT_EQ(0, prefs.mqtt_advert_verify); + EXPECT_EQ(2864u, Codec::payloadLenFor(prefs)); + EXPECT_STREQ("keep-network", prefs.wifi_ssid); } } @@ -655,6 +709,12 @@ TEST(MQTTPrefsCodec, LongerDeclaredLengthWithoutTheBytesIsStillCorrupt) { } TEST(CompanionMqttPrefsNvs, WritesRollbackReadableShapeAndAcceptsRecoveryShape) { + MQTTPrefs prefs = defaults(); + EXPECT_EQ(2876u, CompanionMqttPrefsNvs::writeSizeFor(prefs)); + prefs.mqtt_advert_verify = 1; + EXPECT_EQ(2881u, CompanionMqttPrefsNvs::writeSizeFor(prefs)); + prefs.mqtt_advert_verify = 0; + EXPECT_EQ(2876u, CompanionMqttPrefsNvs::writeSizeFor(prefs)); EXPECT_EQ(MQTT_PREFS_V1_PRE_DISPLAY_PAYLOAD_SIZE, CompanionMqttPrefsNvs::kWriteSize); EXPECT_LT(CompanionMqttPrefsNvs::kWriteSize, sizeof(MQTTPrefs)); @@ -663,6 +723,8 @@ TEST(CompanionMqttPrefsNvs, WritesRollbackReadableShapeAndAcceptsRecoveryShape) MQTT_PREFS_VERSION, CompanionMqttPrefsNvs::kWriteSize)); EXPECT_TRUE(CompanionMqttPrefsNvs::accepts( MQTT_PREFS_VERSION, CompanionMqttPrefsNvs::kRecoverySize)); + EXPECT_TRUE(CompanionMqttPrefsNvs::accepts( + MQTT_PREFS_VERSION, CompanionMqttPrefsNvs::kVerifiedSize)); EXPECT_EQ(2880u, CompanionMqttPrefsNvs::kRecoverySize); EXPECT_LE(CompanionMqttPrefsNvs::kRecoverySize, sizeof(MQTTPrefs)); EXPECT_FALSE(CompanionMqttPrefsNvs::accepts( diff --git a/test/test_mqtt_prefs_json_import/test_mqtt_prefs_json_import.cpp b/test/test_mqtt_prefs_json_import/test_mqtt_prefs_json_import.cpp index d90014067..7492ea04c 100644 --- a/test/test_mqtt_prefs_json_import/test_mqtt_prefs_json_import.cpp +++ b/test/test_mqtt_prefs_json_import/test_mqtt_prefs_json_import.cpp @@ -154,7 +154,7 @@ TEST(MQTTPrefsJsonImport, DecodesEveryObserverGroupAndDisplayTail) { "wifi:{ssid:\"mesh-net\",password:\"p\\\\\\\"ass\\nword\",power_save:2}," "time:{timezone:\"MST7MDT\",utc_offset:-6,ntp_server:\"time.example\"}," "mqtt:{origin:\"observer-one\",iata:\"sea\",packets_enabled:1," - "raw_enabled:1,tx_enabled:1,rx_enabled:0," + "raw_enabled:1,tx_enabled:1,rx_enabled:0,advert_verify:1," "status:{enabled:1,interval_ms:60000}," "neighbors:{enabled:1,interval_ms:43200000}," "owner:{public_key:\"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef\"," @@ -182,6 +182,7 @@ TEST(MQTTPrefsJsonImport, DecodesEveryObserverGroupAndDisplayTail) { EXPECT_STREQ("SEA", candidate.mqtt_iata); EXPECT_EQ(1, candidate.mqtt_raw_enabled); EXPECT_EQ(0, candidate.mqtt_rx_enabled); + EXPECT_EQ(1, candidate.mqtt_advert_verify); EXPECT_EQ(60000u, candidate.mqtt_status_interval); EXPECT_EQ(MQTT_NEIGHBORS_MIN_INTERVAL_MS, candidate.mqtt_neighbors_interval); @@ -213,6 +214,7 @@ TEST(MQTTPrefsJsonImport, MissingFieldsRetainCurrentBuildDefaults) { EXPECT_EQ(0, candidate.wifi_power_save); EXPECT_EQ(300000u, candidate.mqtt_status_interval); EXPECT_EQ(60, candidate.display_timeout_secs); + EXPECT_EQ(0, candidate.mqtt_advert_verify); } TEST(MQTTPrefsJsonImport, SemanticRepairsUseSchemaAndCurrentWifiDefaults) { @@ -221,7 +223,7 @@ TEST(MQTTPrefsJsonImport, SemanticRepairsUseSchemaAndCurrentWifiDefaults) { const char* text = "{version:1,wifi:{power_save:9},time:{utc_offset:99,ntp_server:\"bad/host\"}," "mqtt:{iata:\"bad!\",packets_enabled:-1,raw_enabled:2,tx_enabled:9," - "rx_enabled:2,status:{enabled:3,interval_ms:10}," + "rx_enabled:2,advert_verify:5,status:{enabled:3,interval_ms:10}," "neighbors:{enabled:2,interval_ms:100}," "slot1:{preset:\"bogus\",port:-1,packet_filter:70000}}," "snmp:{enabled:2},radio:{watchdog_min:121}," @@ -238,6 +240,7 @@ TEST(MQTTPrefsJsonImport, SemanticRepairsUseSchemaAndCurrentWifiDefaults) { EXPECT_EQ(0, candidate.mqtt_raw_enabled); EXPECT_EQ(2, candidate.mqtt_tx_enabled); EXPECT_EQ(1, candidate.mqtt_rx_enabled); + EXPECT_EQ(0, candidate.mqtt_advert_verify); EXPECT_EQ(300000u, candidate.mqtt_status_interval); EXPECT_EQ(MQTT_NEIGHBORS_DEFAULT_INTERVAL_MS, candidate.mqtt_neighbors_interval); @@ -267,6 +270,9 @@ TEST(MQTTPrefsJsonImport, RejectsKnownTypeDuplicateLengthAndOverflowErrors) { "{version:{x:1}}", "{version:1,mqtt:1}", "{version:1,mqtt:{slot1:1}}", + "{version:1,mqtt:{advert_verify:true}}", + "{version:1,mqtt:{advert_verify:\"on\"}}", + "{version:1,mqtt:{advert_verify:1,advert_verify:0}}", "{version:1,mqtt:{status:{enabled:1},status:{interval_ms:60000}}}"}) { MQTTPrefs candidate = defaults; EXPECT_FALSE(Import::loaded(decodeText(text, defaults, &candidate))) << text; @@ -339,16 +345,31 @@ TEST(MQTTPrefsJsonImport, ParsedDisplayPrefsRouteThroughFullAtomicBinaryCommit) ASSERT_EQ(Import::Result::Loaded, decodeText("{version:1,display:{timeout_s:45,flip:1}}", defaults, &candidate)); - EXPECT_EQ(MQTT_PREFS_V1_FULL_PAYLOAD_SIZE, + EXPECT_EQ(MQTT_PREFS_V1_PRE_ADVERT_PAYLOAD_SIZE, MQTTPrefsCodec::payloadLenFor(candidate)); CommitStore store(true); ASSERT_EQ(Atomic::Result::Committed, commitPrefs(&store, candidate)); - ASSERT_EQ(sizeof(MQTTPrefsHeader) + MQTT_PREFS_V1_FULL_PAYLOAD_SIZE, + ASSERT_EQ(sizeof(MQTTPrefsHeader) + MQTT_PREFS_V1_PRE_ADVERT_PAYLOAD_SIZE, store.primary.size()); MQTTPrefsHeader header = {}; memcpy(&header, store.primary.data(), sizeof(header)); - EXPECT_EQ(MQTT_PREFS_V1_FULL_PAYLOAD_SIZE, header.payload_len); + EXPECT_EQ(MQTT_PREFS_V1_PRE_ADVERT_PAYLOAD_SIZE, header.payload_len); +} + +TEST(MQTTPrefsJsonImport, AdvertVerifyDefaultsOffRoundTripsAndPersistsThroughBinaryCommit) { + const MQTTPrefs defaults = makeDefaults(); + EXPECT_EQ(0, defaults.mqtt_advert_verify); + MQTTPrefs candidate = defaults; + ASSERT_EQ(Import::Result::Loaded, + decodeText("{version:1,mqtt:{advert_verify:1}}", defaults, &candidate)); + ASSERT_EQ(1, candidate.mqtt_advert_verify); + CommitStore store(true); + ASSERT_EQ(Atomic::Result::Committed, commitPrefs(&store, candidate)); + ASSERT_EQ(sizeof(MQTTPrefsHeader) + 2880u, store.primary.size()); + MQTTPrefs loaded = defaults; + memcpy(&loaded, store.primary.data() + sizeof(MQTTPrefsHeader), 2880); + EXPECT_EQ(1, loaded.mqtt_advert_verify); } TEST(MQTTPrefsJsonImport, FailedAtomicPublishPreservesPreviousBinaryImage) { diff --git a/test/test_transport_filter_wiring.py b/test/test_transport_filter_wiring.py index 674ccfcc5..fbf11e60a 100644 --- a/test/test_transport_filter_wiring.py +++ b/test/test_transport_filter_wiring.py @@ -47,6 +47,10 @@ HARNESS = r''' #define MAX_ESPNOW_PACKET_SIZE 256 unsigned millis() { return 100; } namespace mesh { +struct Identity { + explicit Identity(const uint8_t*) {} + bool verify(const uint8_t*,const uint8_t*,int) {assert(false);return false;} +}; namespace espnow { struct ESPNowRawFrames {uint8_t count=0;uint16_t lengths[2]={};uint8_t data[2][256]={};}; bool encodeEspNowRawFrames(const uint8_t*,size_t,ESPNowRawFrames& f) {f.count=1;return true;} @@ -57,6 +61,10 @@ namespace bridge { const char* espNowFormatName(int) {return "raw";} } } +namespace AdvertSignature { + constexpr unsigned kMaxMessageLen=68; + int buildMessage(const uint8_t*,int,uint8_t*,const uint8_t**) {assert(false);return -1;} +} struct Seen { unsigned checks=0,marks=0; bool wasSeen(mesh::Packet*) {++checks;return false;} @@ -112,11 +120,11 @@ struct ESPNowBridge : BridgeBase { }; struct MQTTBridge : BridgeBase { std::atomic _stop_requested{false}; - struct Obs {bool mqtt_packets_enabled=true,mqtt_rx_enabled=true;uint8_t mqtt_tx_enabled=1;} obs; + struct Obs {bool mqtt_packets_enabled=true,mqtt_rx_enabled=true,mqtt_advert_verify=false;uint8_t mqtt_tx_enabled=1;} obs; Obs* _obs=&obs; struct Identity {uint8_t pub_key[32]={};} id; Identity* _identity=&id; - unsigned _filtered_packets=0; + unsigned _filtered_packets=0,_bad_sig_adverts=0; bool _staged_raw_valid=false; bool shouldQueuePacketType(uint8_t,bool&) {return true;} void queuePacket(mesh::Packet*,bool) {++queued;} diff --git a/variants/waveshare_esp32s3_lr1121/platformio.ini b/variants/waveshare_esp32s3_lr1121/platformio.ini index 8e7b52d4f..f53dad71c 100644 --- a/variants/waveshare_esp32s3_lr1121/platformio.ini +++ b/variants/waveshare_esp32s3_lr1121/platformio.ini @@ -100,3 +100,74 @@ lib_deps = extends = Waveshare_S3_LR1121 build_src_filter = ${Waveshare_S3_LR1121.build_src_filter} +<../examples/kiss_modem/> + +[env:Waveshare_S3_LR1121_repeater_observer_mqtt] +extends = Waveshare_S3_LR1121 +extra_scripts = + ${esp32_base.extra_scripts} + pre:scripts/generate_cert_bundle.py +board_ssl_cert_source = adafruit-full +board_build.embed_files = src/certs/x509_crt_bundle.bin +build_flags = + ${Waveshare_S3_LR1121.build_flags} + -D ADVERT_NAME='"MQTT Observer"' + -D ADVERT_LAT=0.0 + -D ADVERT_LON=0.0 + -D ADMIN_PASSWORD='"password"' + -D MAX_NEIGHBOURS=50 + -D WITH_MQTT_BRIDGE=1 + -D MQTT_MAX_PACKET_SIZE=1024 + -D MQTT_DEBUG=1 +; -D MESH_PACKET_LOGGING=1 +; -D MESH_DEBUG=1 + -D CONFIG_MBEDTLS_CERTIFICATE_BUNDLE=y + -D ESP32_CPU_FREQ=160 +build_src_filter = ${Waveshare_S3_LR1121.build_src_filter} + + + + + + + +<../examples/simple_repeater> +lib_deps = + ${Waveshare_S3_LR1121.lib_deps} + ${esp32_ota.lib_deps} + elims/PsychicMqttClient@^0.2.4 + bblanchon/ArduinoJson @ 7.4.3 + arduino-libraries/NTPClient + JChristensen/Timezone + paulstoffregen/Time@1.6.1 + +[env:Waveshare_S3_LR1121_room_server_observer_mqtt] +extends = Waveshare_S3_LR1121 +extra_scripts = + ${esp32_base.extra_scripts} + pre:scripts/generate_cert_bundle.py +board_ssl_cert_source = adafruit-full +board_build.embed_files = src/certs/x509_crt_bundle.bin +build_flags = + ${Waveshare_S3_LR1121.build_flags} + -D ADVERT_NAME='"Waveshare LR1121 Room Observer"' + -D ADVERT_LAT=0.0 + -D ADVERT_LON=0.0 + -D ADMIN_PASSWORD='"password"' + -D ROOM_PASSWORD='"hello"' + -D MAX_NEIGHBOURS=50 + -D WITH_MQTT_BRIDGE=1 + -D MQTT_MAX_PACKET_SIZE=1024 + -D MQTT_DEBUG=1 +; -D MESH_PACKET_LOGGING=1 +; -D MESH_DEBUG=1 + -D CONFIG_MBEDTLS_CERTIFICATE_BUNDLE=y + -D ESP32_CPU_FREQ=160 +build_src_filter = ${Waveshare_S3_LR1121.build_src_filter} + + + + + + + +<../examples/simple_room_server> +lib_deps = + ${Waveshare_S3_LR1121.lib_deps} + ${esp32_ota.lib_deps} + elims/PsychicMqttClient@^0.2.4 + bblanchon/ArduinoJson @ 7.4.3 + arduino-libraries/NTPClient + JChristensen/Timezone + paulstoffregen/Time@1.6.1 diff --git a/webui/index.html b/webui/index.html index 49319f3d3..475245561 100644 --- a/webui/index.html +++ b/webui/index.html @@ -492,6 +492,8 @@ body.tab-cli{padding-bottom:0}
Received packetsReport packets heard over the air
+
Drop bad advert signaturesSkip received adverts that fail verification. Off keeps RF corruption visible to collectors +
Publish neighborsPeriodic neighbor table and scopes (PSRAM boards only)
@@ -839,6 +841,7 @@ function cfgVal(k){ // map a `set` key to its current value string, from st.cfg case"mqtt.origin":return q.origin;case"mqtt.iata":return q.iata; case"mqtt.status":return q.status?"on":"off";case"mqtt.packets":return q.packets?"on":"off"; case"mqtt.raw":return q.raw?"on":"off";case"mqtt.tx":return q.tx;case"mqtt.rx":return q.rx?"on":"off"; + case"mqtt.advert.verify":return q.advert_verify?"on":"off"; case"mqtt.interval":return String(q.interval); case"mqtt.neighbors":return q.neighbors?"on":"off";case"mqtt.neighbors.interval":return String(q.neighbors_interval); case"mqtt.ntp":return q.ntp; @@ -1960,6 +1963,7 @@ var CLI_KEYS=[ ["mqtt.packets","Publish packet messages",0,"on|off"], ["mqtt.raw","Also publish full raw frames",0,"on|off"], ["mqtt.rx","Publish packets heard over the air",0,"on|off"], + ["mqtt.advert.verify","Drop received adverts with bad signatures",0,"on|off"], ["mqtt.tx","Publish packets this node sends",0,"off|on|advert"], ["mqtt.interval","Status publish interval {1-60 min}",0], ["mqtt.neighbors","Publish the neighbour table (PSRAM boards)",0,"on|off"],