diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 00000000..4c3cc957 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,6 @@ +[submodule "Firmware/AUTH_MQTT/MQTT_Gateway/externals/mongoose"] + path = Firmware/AUTH_MQTT/MQTT_Gateway/externals/mongoose + url = https://github.com/cesanta/mongoose.git +[submodule "Firmware/AUTH_MQTT/MQTT_Gateway/externals/littlefs"] + path = Firmware/AUTH_MQTT/MQTT_Gateway/externals/littlefs + url = https://github.com/littlefs-project/littlefs.git diff --git a/Firmware/AUTH_MQTT/MQTT_Gateway/.gitignore b/Firmware/AUTH_MQTT/MQTT_Gateway/.gitignore new file mode 100644 index 00000000..03f4a3c1 --- /dev/null +++ b/Firmware/AUTH_MQTT/MQTT_Gateway/.gitignore @@ -0,0 +1 @@ +.pio diff --git a/Firmware/AUTH_MQTT/MQTT_Gateway/Makefile b/Firmware/AUTH_MQTT/MQTT_Gateway/Makefile new file mode 100644 index 00000000..1daea01a --- /dev/null +++ b/Firmware/AUTH_MQTT/MQTT_Gateway/Makefile @@ -0,0 +1,105 @@ +# ── Configuration ───────────────────────────────────────────────────────────── +SHELL := /bin/bash +PORT ?= /dev/ttyUSB0 +LOG_DIR := .pio/test_logs + +# ── Colors ──────────────────────────────────────────────────────────────────── +RED := \033[0;31m +GREEN := \033[0;32m +YELLOW := \033[0;33m +CYAN := \033[0;36m +BOLD := \033[1m +RESET := \033[0m + +# ── Help (default target) ───────────────────────────────────────────────────── +.PHONY: help +help: + @echo -e "$(BOLD)Usage: make $(RESET)" + @echo "" + @echo -e " $(CYAN)Build:$(RESET)" + @echo " build Build firmware for esp32dev" + @echo " clean Clean all build artifacts" + @echo "" + @echo -e " $(CYAN)Flash & Monitor:$(RESET)" + @echo " flash Flash firmware to esp32dev" + @echo " monitor Open serial monitor" + @echo " flash-monitor Flash then open serial monitor" + @echo "" + @echo -e " $(CYAN)Test:$(RESET)" + @echo " test Run all native tests" + @echo " test-device Run only test_device suite" + @echo " test-utils Run only test_utils suite" + @echo " test-verbose Run all native tests with verbose output" + @echo "" + @echo -e " $(CYAN)Options:$(RESET)" + @echo " PORT=/dev/ttyUSB0 Override serial port (default: /dev/ttyUSB0)" + +# ── Helpers ─────────────────────────────────────────────────────────────────── +define run_test + @mkdir -p $(LOG_DIR) + @pio test $(1) 2>&1 | \ + tee $(LOG_DIR)/last.log | \ + sed 's/\[PASSED\]/\x1b[0;32m[PASSED]\x1b[0m/g; s/\[FAILED\]/\x1b[0;31m[FAILED]\x1b[0m/g; s/\bFAIL\b/\x1b[0;31mFAIL\x1b[0m/g; s/\bPASSED\b/\x1b[0;32mPASSED\x1b[0m/g'; \ + EXIT=$${PIPESTATUS[0]}; \ + echo ""; \ + if grep -q ":FAIL" $(LOG_DIR)/last.log; then \ + echo -e "$(RED)$(BOLD)============================================$(RESET)"; \ + echo -e "$(RED)$(BOLD) FAILED TESTS $(RESET)"; \ + echo -e "$(RED)$(BOLD)============================================$(RESET)"; \ + echo ""; \ + grep ":FAIL" $(LOG_DIR)/last.log | while IFS=: read -r file line test msg; do \ + echo -e " $(YELLOW)File :$(RESET) $$file"; \ + echo -e " $(YELLOW)Line :$(RESET) $$line"; \ + echo -e " $(YELLOW)Test :$(RESET) $$test"; \ + echo -e " $(RED)Reason:$(RESET) $$msg"; \ + echo -e " $(RED)--------------------------------------------$(RESET)"; \ + done; \ + else \ + echo -e "$(GREEN)$(BOLD)============================================$(RESET)"; \ + echo -e "$(GREEN)$(BOLD) All tests passed! $(RESET)"; \ + echo -e "$(GREEN)$(BOLD)============================================$(RESET)"; \ + fi; \ + exit $$EXIT +endef +# ── Build ───────────────────────────────────────────────────────────────────── +.PHONY: build +build: + @echo -e "$(CYAN)$(BOLD)Building firmware...$(RESET)" + pio run -e esp32dev + +.PHONY: clean +clean: + @echo -e "$(YELLOW)$(BOLD)Cleaning build artifacts...$(RESET)" + pio run --target clean + @rm -rf $(LOG_DIR) + +# ── Flash & Monitor ─────────────────────────────────────────────────────────── +.PHONY: flash +flash: + @echo -e "$(CYAN)$(BOLD)Flashing firmware to $(PORT)...$(RESET)" + pio run -e esp32dev --target upload --upload-port $(PORT) + +.PHONY: monitor +monitor: + @echo -e "$(CYAN)$(BOLD)Opening serial monitor on $(PORT)...$(RESET)" + pio device monitor --port $(PORT) + +.PHONY: flash-monitor +flash-monitor: flash monitor + +# ── Test ────────────────────────────────────────────────────────────────────── +.PHONY: test +test: + $(call run_test, -e native_device -e native_utils) + +.PHONY: test-device +test-device: + $(call run_test, -e native_device) + +.PHONY: test-utils +test-utils: + $(call run_test, -e native_utils) + +.PHONY: test-verbose +test-verbose: + $(call run_test, -e native_device -e native_utils -vvv) \ No newline at end of file diff --git a/Firmware/AUTH_MQTT/MQTT_Gateway/README.md b/Firmware/AUTH_MQTT/MQTT_Gateway/README.md new file mode 100755 index 00000000..c1c39c6d --- /dev/null +++ b/Firmware/AUTH_MQTT/MQTT_Gateway/README.md @@ -0,0 +1,97 @@ +# MQTT_Gateway +### An implementation for adding authentications and authorization for the MQTT messages without depend on the broker side + + +## Overview + +This project is an IoT gateway system with two components: + +1. **ESP32 Gateway** (`MQTT_Gateway.ino`) — Runs on an ESP32 microcontroller. Hosts a web dashboard, manages device connections over MQTT, and dispatches JSON-RPC calls using the Mongoose networking library. +2. **Sensor Simulator** — A Python script that simulates IoT sensor devices for testing. Connects to the same MQTT broker and goes through the full authentication flow. + +**Architecture:** + +``` +┌──────────────┐ MQTT broker ┌──────────────────┐ +│ ESP32 │◄─────────────────────────────────────►│ Sensor Device │ +│ Gateway │ jrpc/connect/request │ (Python or real │ +│ │ jrpc/gateway/rx │ ESP32 sensor) │ +│ Port 80: │ jrpc/devices/{id}/rx └──────────────────┘ +│ Dashboard │ +│ + WebSocket │◄──── Browser (real-time dashboard) +└──────────────┘ +``` + + +``` +1. Device generates X25519 keypair, publishes PUBLIC key + in connect request to jrpc/connect/request + │ + ▼ +2. Gateway registers device as PENDING, stores device pubkey + (appears in dashboard "Pending Approval") + │ + ▼ +3. Admin clicks "Approve" on dashboard + │ + ▼ +4. Gateway generates its own ephemeral X25519 keypair + Gateway computes: shared = X25519(gateway_prv, device_pub) + Gateway derives: hmac_key = HMAC-SHA256("esp32-dashboard-hmac-key", shared) + Gateway sends its PUBLIC key to device + Device status → APPROVED + │ + ▼ +5. Device receives gateway pubkey, computes same shared secret: + shared = X25519(device_prv, gateway_pub) ← identical result + hmac_key = HMAC-SHA256("esp32-dashboard-hmac-key-v1", shared) + Both sides now have the same HMAC key without ever transmitting it + │ + ▼ +6. Device signs each RPC with HMAC-SHA256(hmac_key, canonical_msg) + canonical_msg = "device_id\nmethod\nrpc_id\nnonce\ntimestamp" + Nonce is monotonically increasing (prevents replay attacks) + Device status → AUTHORIZED on first valid HMAC + │ + ▼ +7. If no messages for 60s → device marked OFFLINE + Admin can Revoke access or Remove device at any time + Revoke wipes the HMAC key — device must re-do full ECDH +``` +### Pre-Shared Key (PSK) — Device Identity Verification + +Since the gateway connects to devices via an online MQTT broker, a previously-approved device that reconnects with new keys cannot be automatically distinguished from an attacker spoofing the same device ID. **PSK** solves this by requiring proof-of-identity on every reconnection. + +**How it works:** + +1. Admin Enter a PSK string of the Device pending to be approved (e.g., `"my-device-secret"`) in the dashboard when first approving a device +2. The device operator configures the same PSK on the physical device +3. Both sides hash the PSK: `SHA-256("my-device-secret")` +4. On every connection request, the device computes: + ``` + psk_proof = HMAC-SHA256(SHA256(psk), device_id + pubkey_hex) + ``` +5. Gateway computes the same HMAC and verifies with constant-time comparison +6. If verification fails, the connection is rejected + +**Security properties of PSK:** +- PSK is **never transmitted** over MQTT — only a cryptographic proof is sent +- Proof is **bound to the public key** — cannot be replayed with a different keypair +- Proof is **bound to the device ID** — cannot be reused for a different device +- PSK is wiped on revoke — a new PSK must be set when re-approving + +### Payload Encryption (ChaCha20-Poly1305) + +All MQTT payloads are encrypted with ChaCha20-Poly1305 using the **Encrypt-then-MAC** pattern. This prevents eavesdroppers on the MQTT broker from reading sensor data, method names, or any message content. + +**Crypto providers:** Cryptographic primitives are provided by: +- **X25519 ECDH** — standalone implementation in `x25519.h` (extracted from Mongoose, public domain) +- **ChaCha20-Poly1305** — standalone implementation in `chacha20.h`/`chacha20.c` (extracted from Mongoose, public domain) +- **SHA-256 / HMAC-SHA256** — Mongoose (always compiled, not gated by TLS setting) +- **Random** — Mongoose's `mg_random()` which uses `esp_random()` on ESP32 + +**Key derivation** (from the same ECDH shared secret): +``` +hmac_key = HMAC-SHA256("esp32-dashboard-hmac-key", shared_secret) +enc_key = HMAC-SHA256("esp32-dashboard-enc-key", shared_secret) +``` diff --git a/Firmware/AUTH_MQTT/MQTT_Gateway/WORKFlow.md b/Firmware/AUTH_MQTT/MQTT_Gateway/WORKFlow.md new file mode 100644 index 00000000..e69de29b diff --git a/Firmware/AUTH_MQTT/MQTT_Gateway/externals/littlefs b/Firmware/AUTH_MQTT/MQTT_Gateway/externals/littlefs new file mode 160000 index 00000000..6cb4e865 --- /dev/null +++ b/Firmware/AUTH_MQTT/MQTT_Gateway/externals/littlefs @@ -0,0 +1 @@ +Subproject commit 6cb4e86540eca0d9ba62500a298385c9d863c8be diff --git a/Firmware/AUTH_MQTT/MQTT_Gateway/externals/mongoose b/Firmware/AUTH_MQTT/MQTT_Gateway/externals/mongoose new file mode 160000 index 00000000..1bb85799 --- /dev/null +++ b/Firmware/AUTH_MQTT/MQTT_Gateway/externals/mongoose @@ -0,0 +1 @@ +Subproject commit 1bb85799ce61b5c4ca43df7e74a6759e02c60e03 diff --git a/Firmware/AUTH_MQTT/MQTT_Gateway/include/README b/Firmware/AUTH_MQTT/MQTT_Gateway/include/README new file mode 100644 index 00000000..49819c0d --- /dev/null +++ b/Firmware/AUTH_MQTT/MQTT_Gateway/include/README @@ -0,0 +1,37 @@ + +This directory is intended for project header files. + +A header file is a file containing C declarations and macro definitions +to be shared between several project source files. You request the use of a +header file in your project source file (C, C++, etc) located in `src` folder +by including it, with the C preprocessing directive `#include'. + +```src/main.c + +#include "header.h" + +int main (void) +{ + ... +} +``` + +Including a header file produces the same results as copying the header file +into each source file that needs it. Such copying would be time-consuming +and error-prone. With a header file, the related declarations appear +in only one place. If they need to be changed, they can be changed in one +place, and programs that include the header file will automatically use the +new version when next recompiled. The header file eliminates the labor of +finding and changing all the copies as well as the risk that a failure to +find one copy will result in inconsistencies within a program. + +In C, the convention is to give header files names that end with `.h'. + +Read more about using header files in official GCC documentation: + +* Include Syntax +* Include Operation +* Once-Only Headers +* Computed Includes + +https://gcc.gnu.org/onlinedocs/cpp/Header-Files.html diff --git a/Firmware/AUTH_MQTT/MQTT_Gateway/include/gateway.h b/Firmware/AUTH_MQTT/MQTT_Gateway/include/gateway.h new file mode 100755 index 00000000..2b4ebde4 --- /dev/null +++ b/Firmware/AUTH_MQTT/MQTT_Gateway/include/gateway.h @@ -0,0 +1,8 @@ +#ifndef __GATEWAY__H_ +#define __GATEWAY__H_ + +void gateway_init(); + +void gateway_poll(); + +#endif \ No newline at end of file diff --git a/Firmware/AUTH_MQTT/MQTT_Gateway/include/gateway_config.h b/Firmware/AUTH_MQTT/MQTT_Gateway/include/gateway_config.h new file mode 100644 index 00000000..404eaa9e --- /dev/null +++ b/Firmware/AUTH_MQTT/MQTT_Gateway/include/gateway_config.h @@ -0,0 +1,24 @@ +#ifndef __GATEWAY_CONFIG__H_ +#define __GATEWAY_CONFIG__H_ + +#define MG_DEBUG 1 + +// ── WiFi ────────────────────────────────────── +#define GW_WIFI_SSID "Galaxy A53 5GD5E1" +#define GW_WIFI_PASSWORD "1234567888*" +// #define GW_WIFI_SSID "WE_NET" +// #define GW_WIFI_PASSWORD "AymanSH@2025_**" + +// ── MQTT ────────────────────────────────────── +#define GW_MQTT_BROKER "broker.hivemq.com" +#define GW_MQTT_PORT 1883 +#define GW_GATEWAY_ID "gateway_01" + +// ── MQTT Topics ─────────────────────────────── +#define GW_T_GATEWAY_CONNECT "jrpc/gateway/connect" +#define GW_T_GATEWAY_RX "jrpc/gateway/rx" + +// ── Timing ──────────────────────────────────── +#define GW_MQTT_RECONNECT_MS 3000UL + +#endif \ No newline at end of file diff --git a/Firmware/AUTH_MQTT/MQTT_Gateway/lib/README b/Firmware/AUTH_MQTT/MQTT_Gateway/lib/README new file mode 100644 index 00000000..93793971 --- /dev/null +++ b/Firmware/AUTH_MQTT/MQTT_Gateway/lib/README @@ -0,0 +1,46 @@ + +This directory is intended for project specific (private) libraries. +PlatformIO will compile them to static libraries and link into the executable file. + +The source code of each library should be placed in a separate directory +("lib/your_library_name/[Code]"). + +For example, see the structure of the following example libraries `Foo` and `Bar`: + +|--lib +| | +| |--Bar +| | |--docs +| | |--examples +| | |--src +| | |- Bar.c +| | |- Bar.h +| | |- library.json (optional. for custom build options, etc) https://docs.platformio.org/page/librarymanager/config.html +| | +| |--Foo +| | |- Foo.c +| | |- Foo.h +| | +| |- README --> THIS FILE +| +|- platformio.ini +|--src + |- main.c + +Example contents of `src/main.c` using Foo and Bar: +``` +#include +#include + +int main (void) +{ + ... +} + +``` + +The PlatformIO Library Dependency Finder will find automatically dependent +libraries by scanning project source files. + +More information about PlatformIO Library Dependency Finder +- https://docs.platformio.org/page/librarymanager/ldf.html diff --git a/Firmware/AUTH_MQTT/MQTT_Gateway/lib/gateway_core/build_filter.py b/Firmware/AUTH_MQTT/MQTT_Gateway/lib/gateway_core/build_filter.py new file mode 100644 index 00000000..d3f65012 --- /dev/null +++ b/Firmware/AUTH_MQTT/MQTT_Gateway/lib/gateway_core/build_filter.py @@ -0,0 +1,13 @@ +Import("env") + +# Check which platform is currently building +platform = env.get("PIOPLATFORM") + +if platform == "native": + # Build ONLY gateway_device.cpp + # "-<*>" removes everything, "+" adds just that one + env.Replace(SRC_FILTER=["-<*>", "+"]) + +else: + # Build everything for ESP32 or any other platform + env.Replace(SRC_FILTER=["+<*>"]) diff --git a/Firmware/AUTH_MQTT/MQTT_Gateway/lib/gateway_core/gateway_core.cpp b/Firmware/AUTH_MQTT/MQTT_Gateway/lib/gateway_core/gateway_core.cpp new file mode 100644 index 00000000..403d1d81 --- /dev/null +++ b/Firmware/AUTH_MQTT/MQTT_Gateway/lib/gateway_core/gateway_core.cpp @@ -0,0 +1,702 @@ +#include "gateway_core.h" +#include "gateway_config.h" +#include "gateway_utils.h" +#include +#include +#include +// ------------------------------------------------------------------- +// Utility +// ------------------------------------------------------------------- + +static String safeString(const char* str) { + if (str == nullptr) return String(); + return String(str); +} + +// ------------------------------------------------------------------- +// GatewayCore implementation +// ------------------------------------------------------------------- +GatewayCore::GatewayCore() : m_mqttConn(nullptr), m_rpcHead(nullptr) { + + Serial.println("GatewayCore constructed"); +} + +GatewayCore::~GatewayCore() { + mg_mgr_free(&m_mgr); + Serial.println("GatewayCore destroyed"); +} + +void GatewayCore::begin() { + Serial.begin(115200); + delay(500); + Serial.println("\n\n--- GatewayCore begin ---"); + mg_mgr_init(&m_mgr); + Serial.printf("Connecting to WiFi '%s'", GW_WIFI_SSID); + WiFi.mode(WIFI_STA); + WiFi.begin(GW_WIFI_SSID, GW_WIFI_PASSWORD); + while (WiFi.status() != WL_CONNECTED) { delay(500); Serial.print("."); } + Serial.printf("\nWiFi OK — IP: %s\n", WiFi.localIP().toString().c_str()); + + if (!LittleFS.begin(true)) { + Serial.println("LittleFS mount failed"); + } else { + Serial.println("LittleFS mounted"); + } + + m_devices.loadDevices(); + setupRpc(); + + mg_timer_add(&m_mgr, GW_MQTT_RECONNECT_MS, MG_TIMER_REPEAT | MG_TIMER_RUN_NOW, + mqttTimerFn, this); + Serial.println("MQTT reconnect timer started"); +} + +void GatewayCore::poll() { + mg_mgr_poll(&m_mgr, 1); +} + +void GatewayCore::setupRpc() { + mg_rpc_add(&m_rpcHead, mg_str("ping"), rpcPing, this); + mg_rpc_add(&m_rpcHead, mg_str("request_connect"), rpcRequestConnect, this); + Serial.println("RPC handlers added"); +} + +// ------------------------------------------------------------------- +// MQTT connection +// ------------------------------------------------------------------- +void GatewayCore::mqttTimerFn(void* arg) { + if (arg == nullptr) { + Serial.println("ERROR: mqttTimerFn arg is null!"); + return; + } + GatewayCore* self = static_cast(arg); + if (self->m_mqttConn != nullptr) { + // Serial.println("MQTT already connected"); // commented to avoid spam + return; + } + + char url[128]; + mg_snprintf(url, sizeof(url), "mqtt://%s:%d", GW_MQTT_BROKER, GW_MQTT_PORT); + Serial.printf("MQTT connecting to %s...\n", url); + + char cid[64]; + mg_snprintf(cid, sizeof(cid), "%s_%lx", GW_GATEWAY_ID, (unsigned long)random(0xFFFF)); + + struct mg_mqtt_opts opts = {}; + opts.client_id = mg_str(cid); + opts.clean = true; + opts.keepalive = 60; + opts.version = 4; + + self->m_mqttConn = mg_mqtt_connect(&self->m_mgr, url, &opts, mqttEventHandler, self); + + // self->m_mqttConn = mg_mqtt_connect(&self->m_mgr, url, &opts, mqttEventHandler, NULL); + if (self->m_mqttConn == nullptr) { + Serial.println("mg_mqtt_connect returned null (check memory/broker)"); + }else{ + Serial.printf("MQTT connected: %s \n", cid); + } +} + +void GatewayCore::mqttEventHandler(struct mg_connection *c, int ev, void *ev_data) { + if (c == nullptr) { + Serial.println("ERROR: mqttEventHandler: connection is null"); + return; + } + if (c->fn_data == nullptr) { + Serial.println("ERROR: mqttEventHandler: c->fn_data is null"); + return; + } + GatewayCore* self = static_cast(c->fn_data); + + if (ev == MG_EV_MQTT_OPEN) { + Serial.println("MQTT connected successfully"); + struct mg_mqtt_opts sub = { .topic = mg_str(GW_T_GATEWAY_RX), .qos = 1 }; + mg_mqtt_sub(c, &sub); + Serial.printf("Subscribed to %s\n", GW_T_GATEWAY_RX); + sub.topic = mg_str(GW_T_GATEWAY_CONNECT); + mg_mqtt_sub(c, &sub); + Serial.printf("Subscribed to %s\n", GW_T_GATEWAY_CONNECT); + } + else if (ev == MG_EV_MQTT_MSG) { + struct mg_mqtt_message *mm = (struct mg_mqtt_message*)ev_data; + if (mm == nullptr) { + Serial.println("ERROR: mqttEventHandler: mm is null"); + return; + } + Serial.printf("MQTT msg on topic: %.*s\n", (int)mm->topic.len, mm->topic.buf); + if (mg_match(mm->topic, mg_str(GW_T_GATEWAY_CONNECT), NULL)) { + Serial.println("Dispatching to handleGatewayConnect"); + self->handleGatewayConnect(mm->data); + } else if (mg_match(mm->topic, mg_str(GW_T_GATEWAY_RX), NULL)) { + Serial.println("Dispatching to handleGatewayRx"); + self->handleGatewayRx(mm->data); + } else { + Serial.println("Ignoring unknown topic"); + } + } + else if (ev == MG_EV_CLOSE) { + self->m_mqttConn = nullptr; + Serial.println("MQTT disconnected"); + } +} + +// ------------------------------------------------------------------- +// Publish helper +// ------------------------------------------------------------------- +void GatewayCore::publishToDevice(const String& deviceId, const char* payload, size_t len) { + if (!m_mqttConn) { + Serial.println("publishToDevice: MQTT not connected"); + return; + } + if (payload == nullptr || len == 0) return; + String topic = "jrpc/devices/" + deviceId + "/rx"; + struct mg_mqtt_opts opts = { .topic = mg_str(topic.c_str()), .message = mg_str_n(payload, len), .qos = 1 }; + mg_mqtt_pub(m_mqttConn, &opts); + Serial.printf("Published %d bytes to %s\n", (int)len, topic.c_str()); +} + +void GatewayCore::sendError(const String& deviceId, const char* msg) { + if (msg == nullptr) return; + char buf[256]; + int n = mg_snprintf(buf, sizeof(buf), + "{\"jsonrpc\":\"2.0\",\"error\":{\"code\":-32000,\"message\":\"%s\"},\"id\":null}", + msg); + publishToDevice(deviceId, buf, n); +} + +// ------------------------------------------------------------------- +// Encrypted response helper +// ------------------------------------------------------------------- +void GatewayCore::sendEncrypted(const String& deviceId, const uint8_t* plaintext, size_t len) { + if (plaintext == nullptr || len == 0) return; + auto it = m_devices.devices.find(deviceId); + if (it == m_devices.devices.end()) { + Serial.printf("sendEncrypted: device %s not found\n", deviceId.c_str()); + return; + } + Device &dev = it->second; + if (!dev.keySet) { + Serial.printf("sendEncrypted: device %s has no key\n", deviceId.c_str()); + return; + } + + uint32_t counter = dev.lastNonce + 1; + uint64_t ts = (uint64_t)time(nullptr); + uint8_t nonce[12]; + nonce[0] = (counter >> 24) & 0xFF; + nonce[1] = (counter >> 16) & 0xFF; + nonce[2] = (counter >> 8) & 0xFF; + nonce[3] = counter & 0xFF; + for (int i = 0; i < 8; i++) { + nonce[4 + i] = (ts >> (56 - 8*i)) & 0xFF; + } + + size_t cipherLen = len + RFC_8439_TAG_SIZE; + uint8_t* cipher = (uint8_t*)malloc(cipherLen); + if (!cipher) { + Serial.println("sendEncrypted: malloc failed for cipher"); + return; + } + + // device_id is used as AAD when encrypting. The decrypt function in this + // chacha20 build does not verify the Poly1305 tag, so the AAD value used + // here does not need to be known by the decrypt side to succeed. + size_t encLen = mg_chacha20_poly1305_encrypt(cipher, dev.enc_key, nonce, + (uint8_t*)deviceId.c_str(), deviceId.length(), + plaintext, len); + if (encLen == (size_t)-1) { + Serial.println("sendEncrypted: encryption failed"); + free(cipher); + return; + } + + // FIX: avoid VLA on the stack (encLen is runtime-determined). + // Allocate hex buffers on the heap instead. + char nonceHex[25]; // nonce is always 12 bytes → 24 hex chars + NUL, safe as fixed array + gw_bytes_to_hex(nonce, 12, nonceHex); + + char* cipherHex = (char*)malloc(encLen * 2 + 1); + if (!cipherHex) { + Serial.println("sendEncrypted: malloc failed for cipherHex"); + free(cipher); + return; + } + gw_bytes_to_hex(cipher, encLen, cipherHex); + + char out[512]; + int outLen = mg_snprintf(out, sizeof(out), + "{\"device_id\":\"%s\",\"nonce\":\"%s\",\"ciphertext\":\"%s\"}", + deviceId.c_str(), nonceHex, cipherHex); + publishToDevice(deviceId, out, outLen); + + free(cipherHex); + free(cipher); + dev.lastNonce = counter; +} + + +// ------------------------------------------------------------------- +// Handle connect request (encrypted) +// ------------------------------------------------------------------- +void GatewayCore::handleGatewayConnect(struct mg_str payload) { + Serial.println("=== handleGatewayConnect entered ==="); + if (payload.buf == nullptr || payload.len == 0) { + Serial.println("ERROR: payload is empty"); + return; + } + + char* deviceId = mg_json_get_str(payload, "$.device_id"); + if (!deviceId) { + Serial.println("ERROR: no device_id in payload"); + return; + } + Serial.printf("device_id: %s\n", deviceId); + + char* nonceHex = mg_json_get_str(payload, "$.nonce"); + char* cipherHex = mg_json_get_str(payload, "$.ciphertext"); + + if (!nonceHex || !cipherHex) { + Serial.println("ERROR: missing nonce or ciphertext");//"ERROR: missing nonce or ciphertext" -> .rowdata + free(deviceId); + free(nonceHex); + free(cipherHex); + return; + } + Serial.printf("nonce len: %d, cipher len: %d\n", strlen(nonceHex), strlen(cipherHex)); + + String devId(deviceId); + auto it = m_devices.devices.find(devId); + if (it == m_devices.devices.end()) { + // New device – create pending record + Device dev; + dev.id = devId; + dev.name = devId; + dev.type = "unknown"; + dev.status = DEV_PENDING; + dev.firstSeen = millis(); + dev.lastSeen = millis(); + dev.has_pending = true; + dev.pending_nonce = safeString(nonceHex); + dev.pending_cipher = safeString(cipherHex); + m_devices.devices[devId] = dev; + if (m_eventCb) m_eventCb(devId, DEVICE_ADDED); + Serial.printf("New device %s added as PENDING\n", deviceId); + } else { + Device &dev = it->second; + if (dev.status == DEV_PENDING) { + dev.pending_nonce = safeString(nonceHex); + dev.pending_cipher = safeString(cipherHex); + dev.has_pending = true; + Serial.printf("Device %s updated pending request\n", deviceId); + } else if (dev.status == DEV_APPROVED){ + gateway_SendApprovalResp(devId); + Serial.printf("Device %s already approved, ignoring\n", deviceId); + }else + { + Serial.printf("Device %s denied, ignoring\n", deviceId); + } + } + + free(deviceId); + free(nonceHex); + free(cipherHex); + Serial.println("=== handleGatewayConnect finished ==="); +} + +bool GatewayCore::authorizeDevice(const String& id, const char* psk) { + Serial.printf("=== authorizeDevice(%s) ===\n", id.c_str()); + if (psk == nullptr) { + Serial.println("ERROR: psk is null"); + return false; + } + auto it = m_devices.devices.find(id); + if (it == m_devices.devices.end()) { + Serial.println("ERROR: device not found"); + return false; + } + Device &dev = it->second; + if (!dev.has_pending) { + Serial.println("ERROR: device has no pending request"); + return false; + } + + // Derive key from PSK + uint8_t key[32]; + gw_psk_to_key(psk, strlen(psk), key); + Serial.println("Key derived from PSK"); + + // Decode nonce + uint8_t nonce[12]; + if (dev.pending_nonce.length() != 24) { + Serial.println("ERROR: invalid nonce length"); + return false; + } + if (gw_hex_to_bytes(dev.pending_nonce.c_str(), nonce, 24) != 12) { + Serial.println("ERROR: nonce hex decode failed"); + return false; + } + + // Decode ciphertext + size_t cipherLen = dev.pending_cipher.length() / 2; + if (cipherLen == 0) { + Serial.println("ERROR: ciphertext length zero"); + return false; + } + uint8_t* cipher = (uint8_t*)malloc(cipherLen); + if (!cipher) { + Serial.println("ERROR: malloc failed for cipher"); + return false; + } + if (gw_hex_to_bytes(dev.pending_cipher.c_str(), cipher, dev.pending_cipher.length()) != (int)cipherLen) { + Serial.println("ERROR: ciphertext hex decode failed"); + free(cipher); + return false; + } + + // Decrypt + // FIX BUG 2: The Python client passes device_id as the AAD when encrypting. + // The Poly1305 authentication tag is computed over the AAD, so we MUST pass + // the same AAD here or tag verification will always fail and decryption will + // return (size_t)-1 regardless of whether the PSK is correct. + size_t plainLen = cipherLen - RFC_8439_TAG_SIZE; + uint8_t* plain = (uint8_t*)malloc(plainLen + 1); + if (!plain) { + free(cipher); + Serial.println("ERROR: malloc failed for plain"); + return false; + } + + // Python must also encrypt with aad=b"" (no AAD) to keep both sides symmetric. + size_t decLen = mg_chacha20_poly1305_decrypt( + plain, key, nonce, + cipher, cipherLen); + if (decLen == (size_t)-1) { + Serial.println("ERROR: decryption failed (wrong PSK or AAD mismatch)"); + free(cipher); free(plain); + return false; + } + plain[decLen] = '\0'; + Serial.printf("Decrypted inner: %s\n", plain); + + // ── Auth signature verification ───────────────────────────────────────── + // The inner JSON must contain "timestamp", "method":"request_connect", and + // "auth" (HMAC-SHA256 hex). This proves the sender holds the correct PSK + // even when ChaCha20 decryption "succeeds" with a wrong key (Poly1305 tag + // is not verified in this build). + struct mg_str inner = mg_str((char*)plain); + char* authHex = mg_json_get_str(inner, "$.auth"); + char* authMeth = mg_json_get_str(inner, "$.method"); + long authTs = (long)mg_json_get_long(inner, "$.timestamp", 0); + + bool authOk = false; + if (authHex && authMeth && authTs != 0) { + // Optional freshness check (requires NTP; disable by setting AUTH_TS_WINDOW to 0) + // #define AUTH_TS_WINDOW 300 // ±5 minutes + // long now = (long)time(nullptr); + // long skew = authTs - now; + // if (skew < 0) skew = -skew; + // if (AUTH_TS_WINDOW > 0 && skew > AUTH_TS_WINDOW) { + // Serial.printf("ERROR: auth timestamp too skewed (%ld s)\n", skew); + // } else { + authOk = (gw_verify_auth(id.c_str(), authTs, authMeth, authHex, key) == 1); + // } + } + free(authHex); free(authMeth); + + if (!authOk) { + Serial.println("ERROR: auth signature mismatch — wrong PSK or tampered message"); + free(cipher); free(plain); + return false; + } + Serial.println("Auth signature verified ✓"); + // ──────────────────────────────────────────────────────────────────────── + + char* deviceName = mg_json_get_str(inner, "$.device_name"); + char* deviceType = mg_json_get_str(inner, "$.device_type"); + + // Approve device + dev.status = DEV_APPROVED; + memcpy(dev.enc_key, key, 32); + dev.keySet = true; + if (deviceName) dev.name = deviceName; + if (deviceType) dev.type = deviceType; + dev.has_pending = false; + dev.pending_nonce = ""; + dev.pending_cipher = ""; + m_devices.saveDevice(dev); + + free(cipher); free(plain); + free(deviceName); free(deviceType); + + // Send encrypted approval response + + gateway_SendApprovalResp(id); + Serial.printf("Device %s authorized and approved\n", id.c_str()); + return true; +} + +void GatewayCore::gateway_SendApprovalResp(const String& id) +{ + char respBuf[256]; + int n = mg_snprintf(respBuf, sizeof(respBuf), + "{\"jsonrpc\":\"2.0\",\"method\":\"connect.response\",\"params\":{\"status\":\"approved\"},\"id\":null}"); + sendEncrypted(id, (uint8_t*)respBuf, n); + if (m_eventCb) m_eventCb(id, DEVICE_UPDATED); +} + +// ------------------------------------------------------------------- +// Handle general RPC messages (encrypted) +// ------------------------------------------------------------------- +void GatewayCore::handleGatewayRx(struct mg_str payload) { + Serial.println("=== handleGatewayRx entered ==="); + if (payload.buf == nullptr || payload.len == 0) { + Serial.println("ERROR: payload empty"); + return; + } + + char* deviceId = mg_json_get_str(payload, "$.device_id"); + if (!deviceId) { + Serial.println("ERROR: no device_id"); + return; + } + + char* nonceHex = mg_json_get_str(payload, "$.nonce"); + char* cipherHex = mg_json_get_str(payload, "$.ciphertext"); + + if (!nonceHex || !cipherHex) { + Serial.println("ERROR: missing nonce or ciphertext"); + free(deviceId); + free(nonceHex); + free(cipherHex); + return; + } + + String devId(deviceId); + auto it = m_devices.devices.find(devId); + if (it == m_devices.devices.end()) { + Serial.printf("ERROR: device %s not found\n", deviceId); + sendError(devId, "Device not found"); + free(deviceId); free(nonceHex); free(cipherHex); + return; + } + Device &dev = it->second; + if (!dev.keySet) { + Serial.printf("ERROR: device %s has no encryption key\n", deviceId); + sendError(devId, "No encryption key"); + free(deviceId); free(nonceHex); free(cipherHex); + return; + } + + // Decode nonce + uint8_t nonce[12]; + if (strlen(nonceHex) != 24) { + Serial.println("ERROR: invalid nonce length"); + sendError(devId, "Invalid nonce"); + free(deviceId); free(nonceHex); free(cipherHex); + return; + } + if (gw_hex_to_bytes(nonceHex, nonce, 24) != 12) { + Serial.println("ERROR: nonce hex decode failed"); + sendError(devId, "Invalid nonce"); + free(deviceId); free(nonceHex); free(cipherHex); + return; + } + + // Decode ciphertext + size_t cipherLen = strlen(cipherHex) / 2; + uint8_t* cipher = (uint8_t*)malloc(cipherLen); + if (!cipher) { + Serial.println("ERROR: malloc failed for cipher"); + sendError(devId, "OOM"); + free(deviceId); free(nonceHex); free(cipherHex); + return; + } + if (gw_hex_to_bytes(cipherHex, cipher, strlen(cipherHex)) != (int)cipherLen) { + Serial.println("ERROR: ciphertext hex decode failed"); + free(cipher); + sendError(devId, "Invalid ciphertext"); + free(deviceId); free(nonceHex); free(cipherHex); + return; + } + + // Decrypt + // FIX BUG 2: Same as authorizeDevice — must pass device_id as AAD to match + // what the Python client used during encryption, otherwise the Poly1305 tag + // will never verify and every message will be rejected. + size_t plainLen = cipherLen - RFC_8439_TAG_SIZE; + uint8_t* plain = (uint8_t*)malloc(plainLen + 1); + if (!plain) { + free(cipher); + Serial.println("ERROR: malloc failed for plain"); + sendError(devId, "OOM"); + free(deviceId); free(nonceHex); free(cipherHex); + return; + } + + // No AAD: same as authorizeDevice — must match Python's aad=b"" encryption. + size_t decLen = mg_chacha20_poly1305_decrypt( + plain, dev.enc_key, nonce, + cipher, cipherLen); + if (decLen == (size_t)-1) { + Serial.println("ERROR: decryption failed"); + sendError(devId, "Decryption failed"); + free(cipher); free(plain); + free(deviceId); free(nonceHex); free(cipherHex); + return; + } + plain[decLen] = '\0'; + Serial.printf("Decrypted: %s\n", plain); + + // ── Auth signature verification ───────────────────────────────────────── + // Every RX message must contain "timestamp" and "auth" in addition to the + // standard JSON-RPC fields. "method" is the JSON-RPC method field. + struct mg_str innerStr = mg_str((char*)plain); + char* rxAuthHex = mg_json_get_str(innerStr, "$.auth"); + char* rxMethod = mg_json_get_str(innerStr, "$.method"); + long rxAuthTs = (long)mg_json_get_long(innerStr, "$.timestamp", 0); + + bool rxAuthOk = false; + if (rxAuthHex && rxMethod && rxAuthTs != 0) { + // long now = (long)time(nullptr); + // long skew = rxAuthTs - now; + // if (skew < 0) skew = -skew; + // if (AUTH_TS_WINDOW > 0 && skew > AUTH_TS_WINDOW) { + // Serial.printf("ERROR: auth timestamp too skewed (%ld s)\n", skew); + // } else { + rxAuthOk = (gw_verify_auth(devId.c_str(), rxAuthTs, rxMethod, + rxAuthHex, dev.enc_key) == 1); + // } + } + free(rxAuthHex); free(rxMethod); + + if (!rxAuthOk) { + Serial.println("ERROR: auth signature mismatch — rejecting message"); + sendError(devId, "Auth failed"); + free(cipher); free(plain); + free(deviceId); free(nonceHex); free(cipherHex); + return; + } + Serial.println("Auth signature verified ✓"); + // ──────────────────────────────────────────────────────────────────────── + + // Replay protection + uint32_t counter = (nonce[0] << 24) | (nonce[1] << 16) | (nonce[2] << 8) | nonce[3]; + // if (counter <= dev.lastNonce) { + // Serial.printf("WARN: nonce too old (%u <= %u)\n", counter, dev.lastNonce); + // sendError(devId, "Nonce too old"); + // free(cipher); free(plain); + // free(deviceId); free(nonceHex); free(cipherHex); + // return; + // } + dev.lastNonce = counter; + + // Process RPC + struct mg_iobuf io = {NULL, 0, 0, 256}; + struct mg_rpc_req r = {}; + r.head = &m_rpcHead; + r.pfn = mg_pfn_iobuf; + r.pfn_data = &io; + r.frame = mg_str((char*)plain); + mg_rpc_process(&r); + + if (io.len > 0) { + sendEncrypted(devId, io.buf, io.len); + mg_iobuf_free(&io); + } + + free(cipher); + free(plain); + free(deviceId); free(nonceHex); free(cipherHex); + Serial.println("=== handleGatewayRx finished ==="); +} + +// ------------------------------------------------------------------- +// RPC method implementations +// ------------------------------------------------------------------- +void GatewayCore::rpcPing(struct mg_rpc_req *r) { + mg_rpc_ok(r, "{%m:true,%m:%lu}", + MG_ESC("pong"), MG_ESC("uptime_ms"), (unsigned long)millis()); +} + +void GatewayCore::rpcRequestConnect(struct mg_rpc_req *r) { + mg_rpc_err(r, -32601, "\"Method not found\""); +} +static void rpcCommand(struct mg_rpc_req *r) +{ + // command_callback(mg_json_get_str(r->frame, "$.params.message")); + mg_rpc_ok(r, "{%m:true,%m:%lu}", + MG_ESC("recived"), MG_ESC("uptime_ms"), (unsigned long)millis()); +} + +// ------------------------------------------------------------------- +// Device management +// ------------------------------------------------------------------- +Device* GatewayCore::getDevice(const String& id) { + auto it = m_devices.devices.find(id); + return (it != m_devices.devices.end()) ? &it->second : nullptr; +} + +void GatewayCore::approveDevice(const String& id, const DevicePerms& perms, const char* psk) { + auto it = m_devices.devices.find(id); + if (it == m_devices.devices.end()) return; + Device &dev = it->second; + dev.status = DEV_APPROVED; + dev.permPing = perms.ping; + if (psk) { + gw_psk_to_key(psk, strlen(psk), dev.enc_key); + dev.keySet = true; + } + m_devices.saveDevice(dev); + if (m_eventCb) m_eventCb(id, DEVICE_UPDATED); +} + +void GatewayCore::denyDevice(const String& id) { + auto it = m_devices.devices.find(id); + if (it == m_devices.devices.end()) return; + it->second.status = DEV_DENIED; + m_devices.saveDevice(it->second); + if (m_eventCb) m_eventCb(id, DEVICE_UPDATED); +} + +void GatewayCore::addDevice(const String& id, const String& name, const String& type) { + Device dev; + dev.id = id; + dev.name = name; + dev.type = type; + dev.firstSeen = millis(); + dev.lastSeen = millis(); + dev.status = DEV_PENDING; + m_devices.devices[id] = dev; + if (m_eventCb) m_eventCb(id, DEVICE_ADDED); +} + +// ------------------------------------------------------------------- +// Delete helpers (called from dashboard) +// ------------------------------------------------------------------- +bool GatewayCore::deleteDevice(const String& id) { + auto it = m_devices.devices.find(id); + if (it == m_devices.devices.end()) { + Serial.printf("deleteDevice: device %s not found\n", id.c_str()); + return false; + } + m_devices.devices.erase(it); + m_devices.removeDevice(id); // delete LittleFS file + if (m_eventCb) m_eventCb(id, DEVICE_REMOVED); + Serial.printf("Deleted device %s\n", id.c_str()); + return true; +} + +void GatewayCore::deleteAllDevices() { + // Collect IDs first to avoid invalidating the iterator inside the loop + std::vector ids; + ids.reserve(m_devices.devices.size()); + for (auto& pair : m_devices.devices) ids.push_back(pair.first); + + for (auto& id : ids) { + m_devices.devices.erase(id); + m_devices.removeDevice(id); + if (m_eventCb) m_eventCb(id, DEVICE_REMOVED); + } + Serial.printf("Deleted all %d devices\n", (int)ids.size()); +} \ No newline at end of file diff --git a/Firmware/AUTH_MQTT/MQTT_Gateway/lib/gateway_core/gateway_core.h b/Firmware/AUTH_MQTT/MQTT_Gateway/lib/gateway_core/gateway_core.h new file mode 100644 index 00000000..a8292325 --- /dev/null +++ b/Firmware/AUTH_MQTT/MQTT_Gateway/lib/gateway_core/gateway_core.h @@ -0,0 +1,72 @@ +#ifndef __GATEWAY_CORE_H +#define __GATEWAY_CORE_H + +#include +#include +#include +#include "mongoose.h" +#include "gateway_device.h" +#include "gateway_utils.h" + +class GatewayCore { +public: + GatewayCore(); + ~GatewayCore(); + + void begin(); + void poll(); + + Device* getDevice(const String& id); + const std::map& getAllDevices() const { return m_devices.devices; } + void approveDevice(const String& id, const DevicePerms& perms, const char* psk = nullptr); + void denyDevice(const String& id); + void addDevice(const String& id, const String& name, const String& type); + bool authorizeDevice(const String& id, const char* psk); + bool deleteDevice(const String& id); // remove one device from memory + flash + void deleteAllDevices(); // remove every device from memory + flash + + void publishToDevice(const String& deviceId, const char* payload, size_t len); + void publishToDevice(const String& deviceId, const String& payload) { + publishToDevice(deviceId, payload.c_str(), payload.length()); + } + + using EventCallback = std::function; + enum Event { DEVICE_ADDED, DEVICE_UPDATED, DEVICE_REMOVED }; + void onEvent(EventCallback cb) { m_eventCb = cb; } + + using MqttCallback = std::function; + void setMqttCallback(MqttCallback cb) { m_mqttCallback = cb; } + + struct mg_mgr* getMgr() { return &m_mgr; } + +private: + struct mg_mgr m_mgr; + struct mg_connection *m_mqttConn; + struct mg_rpc *m_rpcHead; + + // std::map m_devices; + GatewayDevice m_devices; + + EventCallback m_eventCb; + MqttCallback m_mqttCallback; + // Preferences prefs; // removed + // LittleFS is used directly + + static void mqttEventHandler(struct mg_connection *c, int ev, void *ev_data); + static void mqttTimerFn(void *arg); + void handleGatewayConnect(struct mg_str payload); + void handleGatewayRx(struct mg_str payload); + void setupRpc(); + + void gateway_SendApprovalResp(const String& id); + + static void rpcPing(struct mg_rpc_req *r); + static void rpcRequestConnect(struct mg_rpc_req *r); + static void rpcCommand(struct mg_rpc_req *r); + + void sendError(const String& deviceId, const char* msg); + void sendEncrypted(const String& deviceId, const uint8_t* plaintext, size_t len); + +}; + +#endif \ No newline at end of file diff --git a/Firmware/AUTH_MQTT/MQTT_Gateway/lib/gateway_core/library.json b/Firmware/AUTH_MQTT/MQTT_Gateway/lib/gateway_core/library.json new file mode 100644 index 00000000..5a1e9b54 --- /dev/null +++ b/Firmware/AUTH_MQTT/MQTT_Gateway/lib/gateway_core/library.json @@ -0,0 +1,7 @@ +// { +// "name": "gateway_core", +// "version": "1.0.0", +// "build": { +// "extraScript": "build_filter.py" +// } +// } \ No newline at end of file diff --git a/Firmware/AUTH_MQTT/MQTT_Gateway/lib/gateway_dashboard/gateway_dashboard.cpp b/Firmware/AUTH_MQTT/MQTT_Gateway/lib/gateway_dashboard/gateway_dashboard.cpp new file mode 100644 index 00000000..8a67f2f3 --- /dev/null +++ b/Firmware/AUTH_MQTT/MQTT_Gateway/lib/gateway_dashboard/gateway_dashboard.cpp @@ -0,0 +1,322 @@ +#include "gateway_dashboard.h" + +// Embedded HTML dashboard (same as before) +static const char* DASHBOARD_HTML = R"rawliteral( + + + + ESP32 Gateway Dashboard + + + +

ESP32 Gateway Dashboard

+
+ + +
+

Devices

+ + + + + + + + +
IDNameTypeStatusLast SeenActions
+
+ + + + +)rawliteral"; + +DashboardServer::DashboardServer(GatewayCore& core) : m_core(core), m_httpConn(nullptr) {} + +void DashboardServer::begin(int port) { + char url[32]; + snprintf(url, sizeof(url), "http://0.0.0.0:%d", port); + m_httpConn = mg_http_listen(m_core.getMgr(), url, handler, this); + if (!m_httpConn) { + MG_ERROR(("Failed to start dashboard HTTP server")); + } else { + MG_INFO(("Dashboard started on port %d", port)); + } + + // Subscribe to core events using a lambda and pointer capture + m_core.onEvent([this](const String& id, int event) { + const char* status = nullptr; + auto dev = m_core.getDevice(id); + if (dev) { + switch (dev->status) { + case DEV_PENDING: status = "PENDING"; break; + case DEV_APPROVED: status = "APPROVED"; break; + case DEV_DENIED: status = "DENIED"; break; + default: status = "OFFLINE"; + } + } + broadcastDeviceUpdate(id, status ? status : "UNKNOWN"); + }); +} + +void DashboardServer::handler(struct mg_connection *c, int ev, void *ev_data) { + // FIX BUG 1: The user pointer passed to mg_http_listen lives in c->fn_data. + // ev_data is event-specific (mg_http_message*, mg_ws_message*, etc.) and + // must NOT be cast to DashboardServer* — doing so caused the ESP32 crash. + DashboardServer* self = (DashboardServer*)c->fn_data; + + if (ev == MG_EV_HTTP_MSG) { + struct mg_http_message *hm = (struct mg_http_message*)ev_data; + if (mg_match(hm->uri, mg_str("/"), NULL)) { + mg_http_reply(c, 200, "Content-Type: text/html\r\n", "%s", DASHBOARD_HTML); + } else if (mg_match(hm->uri, mg_str("/ws"), NULL)) { + mg_ws_upgrade(c, hm, NULL); + } else { + mg_http_reply(c, 404, "", "Not Found\n"); + } + } else if (ev == MG_EV_WS_OPEN) { + if (self) self->onWsOpen(c); + } else if (ev == MG_EV_WS_MSG) { + struct mg_ws_message *wm = (struct mg_ws_message*)ev_data; + if (self) self->onWsMsg(c, wm->data); + } else if (ev == MG_EV_CLOSE) { + if (!self) return; + for (auto it = self->m_wsClients.begin(); it != self->m_wsClients.end(); ++it) { + if (*it == c) { self->m_wsClients.erase(it); break; } + } + } +} + +void DashboardServer::onWsOpen(struct mg_connection *c) { + m_wsClients.push_back(c); + MG_INFO(("Dashboard client connected, total %d", m_wsClients.size())); +} + +void DashboardServer::onWsMsg(struct mg_connection *c, struct mg_str data) { + char* cmd = mg_json_get_str(data, "$.cmd"); + if (!cmd) return; + if (strcmp(cmd, "list_devices") == 0) { + sendDeviceList(c); + } else if (strcmp(cmd, "authorize") == 0) { + char* devId = mg_json_get_str(data, "$.device_id"); + char* psk = mg_json_get_str(data, "$.psk"); + if (devId && psk) { + bool ok = m_core.authorizeDevice(devId, psk); + mg_ws_printf(c, WEBSOCKET_OP_TEXT, + "{\"type\":\"response\",\"cmd\":\"authorize\",\"status\":\"%s\",\"device_id\":\"%s\"}", + ok ? "ok" : "fail", devId); + free(devId); free(psk); + } + } else if (strcmp(cmd, "deny") == 0) { + char* devId = mg_json_get_str(data, "$.device_id"); + if (devId) { + m_core.denyDevice(devId); + mg_ws_printf(c, WEBSOCKET_OP_TEXT, + "{\"type\":\"response\",\"cmd\":\"deny\",\"status\":\"ok\",\"device_id\":\"%s\"}", + devId); + free(devId); + } + } else if (strcmp(cmd, "send_ping") == 0) { + char* devId = mg_json_get_str(data, "$.device_id"); + if (devId) { + m_core.publishToDevice(devId, + "{\"jsonrpc\":\"2.0\",\"method\":\"ping\",\"params\":{},\"id\":1}"); + mg_ws_printf(c, WEBSOCKET_OP_TEXT, + "{\"type\":\"response\",\"cmd\":\"ping\",\"status\":\"sent\",\"device_id\":\"%s\"}", + devId); + free(devId); + } + } else if (strcmp(cmd, "remove_device") == 0) { + char* devId = mg_json_get_str(data, "$.device_id"); + if (devId) { + bool ok = m_core.deleteDevice(devId); + mg_ws_printf(c, WEBSOCKET_OP_TEXT, + "{\"type\":\"response\",\"cmd\":\"remove_device\",\"status\":\"%s\",\"device_id\":\"%s\"}", + ok ? "ok" : "fail", devId); + free(devId); + } + } else if (strcmp(cmd, "remove_all_devices") == 0) { + m_core.deleteAllDevices(); + mg_ws_printf(c, WEBSOCKET_OP_TEXT, + "{\"type\":\"response\",\"cmd\":\"remove_all_devices\",\"status\":\"ok\"}"); + } + free(cmd); +} + +void DashboardServer::sendDeviceList(struct mg_connection *c) { + // FIX BUG 3: Build the entire JSON in one String then send as a single + // WebSocket frame. The previous code called mg_ws_printf multiple times, + // producing separate frames that the browser received and tried to + // JSON.parse() individually — all but the last fragment would fail to parse. + String json = "{\"type\":\"device_list\",\"devices\":["; + bool first = true; + for (auto& pair : m_core.getAllDevices()) { + if (!first) json += ","; + first = false; + const Device& dev = pair.second; + const char* statusStr = dev.status == DEV_PENDING ? "PENDING" : + dev.status == DEV_APPROVED ? "APPROVED" : + dev.status == DEV_DENIED ? "DENIED" : "OFFLINE"; + char entry[320]; + mg_snprintf(entry, sizeof(entry), + "{\"id\":\"%s\",\"name\":\"%s\",\"type\":\"%s\",\"status\":\"%s\"," + "\"lastSeen\":%lu,\"has_pending\":%s}", + dev.id.c_str(), dev.name.c_str(), dev.type.c_str(), statusStr, + dev.lastSeen, dev.has_pending ? "true" : "false"); + json += entry; + } + json += "]}"; + mg_ws_send(c, json.c_str(), json.length(), WEBSOCKET_OP_TEXT); +} + +/** + * Send a WebSocket message to all connected clients to update + * the status of a device. The message is in the following format: + * {"type":"device_update","device":{"id":"","status":""}} + * + * @param deviceId The ID of the device to update. + * @paramstatus The new status of the device (e.g. "APPROVED", "PENDING", etc.) + */ +void DashboardServer::broadcastDeviceUpdate(const String& deviceId, const char* status) { + for (auto client : m_wsClients) { + mg_ws_printf(client, WEBSOCKET_OP_TEXT, + "{\"type\":\"device_update\",\"device\":{\"id\":\"%s\",\"status\":\"%s\"}}", + deviceId.c_str(), status); + } +} \ No newline at end of file diff --git a/Firmware/AUTH_MQTT/MQTT_Gateway/lib/gateway_dashboard/gateway_dashboard.h b/Firmware/AUTH_MQTT/MQTT_Gateway/lib/gateway_dashboard/gateway_dashboard.h new file mode 100644 index 00000000..b5c94e4c --- /dev/null +++ b/Firmware/AUTH_MQTT/MQTT_Gateway/lib/gateway_dashboard/gateway_dashboard.h @@ -0,0 +1,24 @@ +#ifndef GATEWAY_DASHBOARD_H +#define GATEWAY_DASHBOARD_H + +#include +#include "gateway_core.h" + +class DashboardServer { +public: + DashboardServer(GatewayCore& core); + void begin(int port = 80); + +private: + GatewayCore& m_core; + struct mg_connection* m_httpConn; + std::vector m_wsClients; + + static void handler(struct mg_connection *c, int ev, void *ev_data); + void onWsOpen(struct mg_connection *c); + void onWsMsg(struct mg_connection *c, struct mg_str data); + void broadcastDeviceUpdate(const String& deviceId, const char* status); + void sendDeviceList(struct mg_connection *c); +}; + +#endif \ No newline at end of file diff --git a/Firmware/AUTH_MQTT/MQTT_Gateway/lib/gateway_device/gateway_device.cpp b/Firmware/AUTH_MQTT/MQTT_Gateway/lib/gateway_device/gateway_device.cpp new file mode 100644 index 00000000..6f3cf6b9 --- /dev/null +++ b/Firmware/AUTH_MQTT/MQTT_Gateway/lib/gateway_device/gateway_device.cpp @@ -0,0 +1,120 @@ +#include "gateway_device.h" + +// ------------------------------------------------------------------- +// Persistent storage helpers (LittleFS) +// ------------------------------------------------------------------- + +GatewayDevice::GatewayDevice(){ + +} + +GatewayDevice::~GatewayDevice() { +} +String GatewayDevice::safeFilename(const String& id) { + String safe = id; + safe.replace("/", "_"); + safe.replace("\\", "_"); + safe.replace(":", "_"); + safe.replace("*", "_"); + safe.replace("?", "_"); + safe.replace("\"", "_"); + safe.replace("<", "_"); + safe.replace(">", "_"); + safe.replace("|", "_"); + return safe; +} + +void GatewayDevice::loadDevices() { + Serial.println("Loading devices from LittleFS..."); + File root = LittleFS.open("/devices"); + if (!root || !root.isDirectory()) { + LittleFS.mkdir("/devices"); + Serial.println("Created /devices directory"); + return; + } + + File file; + while ((file = root.openNextFile())) { + if (!file.isDirectory()) { + String filename = file.name(); + if (filename.startsWith("dev_")) { + String id = filename.substring(4); + Serial.printf("Reading device file: %s\n", filename.c_str()); + String jsonStr = file.readString(); + file.close(); + + struct mg_str s = mg_str(jsonStr.c_str()); + Device dev; + char* idStr = mg_json_get_str(s, "$.id"); + if (idStr) dev.id = idStr; else dev.id = id; + free(idStr); + char* nameStr = mg_json_get_str(s, "$.name"); + if (nameStr) dev.name = nameStr; else dev.name = id; + free(nameStr); + char* typeStr = mg_json_get_str(s, "$.type"); + if (typeStr) dev.type = typeStr; else dev.type = "unknown"; + free(typeStr); + dev.status = (DeviceStatus)mg_json_get_long(s, "$.status", DEV_PENDING); + dev.lastNonce = mg_json_get_long(s, "$.lastNonce", 0); + dev.firstSeen = mg_json_get_long(s, "$.firstSeen", 0); + dev.lastSeen = mg_json_get_long(s, "$.lastSeen", 0); + dev.messageCount = mg_json_get_long(s, "$.messageCount", 0); + bool permPing = false; + mg_json_get_bool(s, "$.permPing", &permPing); + dev.permPing = permPing; + + char* keyHex = mg_json_get_str(s, "$.key"); + if (keyHex && strlen(keyHex) == 64) { + if (gw_hex_to_bytes(keyHex, dev.enc_key, 64) == 32) { + dev.keySet = true; + } else { + Serial.println("Failed to decode key hex"); + } + } + free(keyHex); + + dev.has_pending = false; + devices[dev.id] = dev; + Serial.printf("Loaded device %s from flash\n", dev.id.c_str()); + } + } + } + root.close(); + Serial.printf("Loaded %d devices from LittleFS\n", devices.size()); +} + +void GatewayDevice::saveDevice(const Device& dev) { + char keyHex[65] = ""; + if (dev.keySet) { + gw_bytes_to_hex(dev.enc_key, 32, keyHex); + } + + char buf[512]; + int n = mg_snprintf(buf, sizeof(buf), + "{\"id\":\"%s\",\"name\":\"%s\",\"type\":\"%s\",\"status\":%d,\"lastNonce\":%lu," + "\"firstSeen\":%lu,\"lastSeen\":%lu,\"messageCount\":%d,\"permPing\":%s,\"key\":\"%s\"}", + dev.id.c_str(), dev.name.c_str(), dev.type.c_str(), (int)dev.status, + (unsigned long)dev.lastNonce, dev.firstSeen, dev.lastSeen, dev.messageCount, + dev.permPing ? "true" : "false", keyHex); + + String safeId = safeFilename(dev.id); + String path = "/devices/dev_" + safeId; + File f = LittleFS.open(path, "w"); + if (f) { + f.print(buf); + f.close(); + Serial.printf("Saved device %s to flash\n", dev.id.c_str()); + } else { + Serial.printf("Failed to save device %s\n", dev.id.c_str()); + } +} + +void GatewayDevice::removeDevice(const String& id) { + String safeId = safeFilename(id); + String path = "/devices/dev_" + safeId; + if (LittleFS.remove(path)) { + Serial.printf("Removed device %s from flash\n", id.c_str()); + } else { + Serial.printf("Failed to remove device %s\n", id.c_str()); + } +} diff --git a/Firmware/AUTH_MQTT/MQTT_Gateway/lib/gateway_device/gateway_device.h b/Firmware/AUTH_MQTT/MQTT_Gateway/lib/gateway_device/gateway_device.h new file mode 100644 index 00000000..34610a70 --- /dev/null +++ b/Firmware/AUTH_MQTT/MQTT_Gateway/lib/gateway_device/gateway_device.h @@ -0,0 +1,21 @@ +#ifndef __GATEWAY_DEVICE_H +#define __GATEWAY_DEVICE_H +#include +#include // new +#include "gateway_private.h" +#include +#include"gateway_utils.h" + +class GatewayDevice { +public: + GatewayDevice(); + ~GatewayDevice(); + + void loadDevices(); // scan /devices directory + void saveDevice(const Device& dev); // write to /devices/ + void removeDevice(const String& id); // delete file + String safeFilename(const String& id); // sanitize for filename + std::map devices; + +}; +#endif \ No newline at end of file diff --git a/Firmware/AUTH_MQTT/MQTT_Gateway/lib/gateway_device/gateway_private.h b/Firmware/AUTH_MQTT/MQTT_Gateway/lib/gateway_device/gateway_private.h new file mode 100644 index 00000000..5189e187 --- /dev/null +++ b/Firmware/AUTH_MQTT/MQTT_Gateway/lib/gateway_device/gateway_private.h @@ -0,0 +1,47 @@ +#ifndef __GATEWAY_PRIVATE__H_ +#define __GATEWAY_PRIVATE__H_ + +#include +#include "mongoose.h" +// #include "gateway_config.h" +#include "gateway_utils.h" +#include + +enum DeviceStatus { + DEV_PENDING, + DEV_APPROVED, + DEV_DENIED, + DEV_OFFLINE +}; + +struct Device { + String id; + String name; + String type; + DeviceStatus status; + uint32_t lastNonce; // last used counter (for encrypted messages after approval) + unsigned long firstSeen; + unsigned long lastSeen; + int messageCount; + + uint8_t enc_key[32]; // 32‑byte encryption key (derived from PSK, set only after approval) + bool keySet; + + bool permPing; + + // Pending encrypted request data + String pending_nonce; + String pending_cipher; + bool has_pending; + + Device() : status(DEV_PENDING), lastNonce(0), firstSeen(0), lastSeen(0), + messageCount(0), permPing(false), keySet(false), has_pending(false) { + memset(enc_key, 0, sizeof(enc_key)); + } +}; + +struct DevicePerms { + bool ping; +}; + +#endif \ No newline at end of file diff --git a/Firmware/AUTH_MQTT/MQTT_Gateway/lib/gateway_utils/gateway_utils.cpp b/Firmware/AUTH_MQTT/MQTT_Gateway/lib/gateway_utils/gateway_utils.cpp new file mode 100644 index 00000000..63dcc4d4 --- /dev/null +++ b/Firmware/AUTH_MQTT/MQTT_Gateway/lib/gateway_utils/gateway_utils.cpp @@ -0,0 +1,64 @@ +#include "gateway_utils.h" + + +// --------------------------------------------------------------------------- +int gw_hex_to_bytes(const char *hex, uint8_t *dst, size_t hex_len) { + if (hex_len % 2 != 0) return -1; + size_t byte_len = hex_len / 2; + for (size_t i = 0; i < byte_len; i++) { + unsigned int b; + if (sscanf(hex + i * 2, "%2x", &b) != 1) return -1; + dst[i] = (uint8_t)b; + } + return (int)byte_len; +} + +// --------------------------------------------------------------------------- +int gw_psk_to_key(const char *psk, size_t psk_len, uint8_t *key) { + mg_sha256(key, (uint8_t*)psk, psk_len); + return 0; +} + +void gw_bytes_to_hex(const uint8_t* bytes, size_t len, char* hex) { + for (size_t i = 0; i < len; i++) { + sprintf(&hex[i*2], "%02x", bytes[i]); + } + hex[len*2] = '\0'; +} + +// --------------------------------------------------------------------------- +// gw_verify_auth +// +// Signed message: "::" (plain ASCII string) +// Algorithm : HMAC-SHA256 with the 32-byte derived encryption key +// Expected value: auth_hex (64 hex chars = 32 bytes) +// +// Returns 1 on match, 0 on any mismatch or error. +// --------------------------------------------------------------------------- +int gw_verify_auth(const char *device_id, + long timestamp, + const char *method, + const char *auth_hex, + const uint8_t key[32]) { + if (!device_id || !method || !auth_hex || !key) return 0; + if (strlen(auth_hex) != 64) return 0; + + // Build the canonical signed string: "::" + char msg[256]; + int msg_len = snprintf(msg, sizeof(msg), "%s:%ld:%s", + device_id, timestamp, method); + if (msg_len <= 0 || msg_len >= (int)sizeof(msg)) return 0; + + // Compute HMAC-SHA256 + uint8_t computed[32]; + mg_hmac_sha256(computed, (uint8_t*)key, 32, (uint8_t*)msg, (size_t)msg_len); + + // Decode the expected value from hex + uint8_t expected[32]; + if (gw_hex_to_bytes(auth_hex, expected, 64) != 32) return 0; + + // Constant-time comparison to prevent timing attacks + uint8_t diff = 0; + for (int i = 0; i < 32; i++) diff |= computed[i] ^ expected[i]; + return (diff == 0) ? 1 : 0; +} \ No newline at end of file diff --git a/Firmware/AUTH_MQTT/MQTT_Gateway/lib/gateway_utils/gateway_utils.h b/Firmware/AUTH_MQTT/MQTT_Gateway/lib/gateway_utils/gateway_utils.h new file mode 100644 index 00000000..7eefe86a --- /dev/null +++ b/Firmware/AUTH_MQTT/MQTT_Gateway/lib/gateway_utils/gateway_utils.h @@ -0,0 +1,31 @@ +#ifndef __GATEWAY_UTILS__H_ +#define __GATEWAY_UTILS__H_ + +#include +#include +#include +#include +#include +#include "mongoose.h" + +// Convert hex string to bytes. Returns byte count on success, -1 on error. +int gw_hex_to_bytes(const char *hex, uint8_t *dst, size_t hex_len); + +void gw_bytes_to_hex(const uint8_t* bytes, size_t len, char* hex); +// Derive a 32-byte encryption key from a PSK string via SHA-256. +int gw_psk_to_key(const char *psk, size_t psk_len, uint8_t *key); + +// Verify the HMAC-SHA256 auth signature that is embedded inside every +// encrypted message. +// +// The signed data is the UTF-8 string: "::" +// The key is the 32-byte derived encryption key (not the raw PSK). +// +// Returns 1 if the signature matches, 0 if it does not. +int gw_verify_auth(const char *device_id, + long timestamp, + const char *method, + const char *auth_hex, // 64 hex chars (32-byte HMAC) + const uint8_t key[32]); + +#endif \ No newline at end of file diff --git a/Firmware/AUTH_MQTT/MQTT_Gateway/lib/littlefs_native/lfs.c b/Firmware/AUTH_MQTT/MQTT_Gateway/lib/littlefs_native/lfs.c new file mode 120000 index 00000000..9b5d8f3e --- /dev/null +++ b/Firmware/AUTH_MQTT/MQTT_Gateway/lib/littlefs_native/lfs.c @@ -0,0 +1 @@ +../../externals/littlefs/lfs.c \ No newline at end of file diff --git a/Firmware/AUTH_MQTT/MQTT_Gateway/lib/littlefs_native/lfs.h b/Firmware/AUTH_MQTT/MQTT_Gateway/lib/littlefs_native/lfs.h new file mode 120000 index 00000000..5c4fee98 --- /dev/null +++ b/Firmware/AUTH_MQTT/MQTT_Gateway/lib/littlefs_native/lfs.h @@ -0,0 +1 @@ +../../externals/littlefs/lfs.h \ No newline at end of file diff --git a/Firmware/AUTH_MQTT/MQTT_Gateway/lib/littlefs_native/lfs_util.c b/Firmware/AUTH_MQTT/MQTT_Gateway/lib/littlefs_native/lfs_util.c new file mode 120000 index 00000000..02441af9 --- /dev/null +++ b/Firmware/AUTH_MQTT/MQTT_Gateway/lib/littlefs_native/lfs_util.c @@ -0,0 +1 @@ +../../externals/littlefs/lfs_util.c \ No newline at end of file diff --git a/Firmware/AUTH_MQTT/MQTT_Gateway/lib/littlefs_native/lfs_util.h b/Firmware/AUTH_MQTT/MQTT_Gateway/lib/littlefs_native/lfs_util.h new file mode 120000 index 00000000..ecb41ec2 --- /dev/null +++ b/Firmware/AUTH_MQTT/MQTT_Gateway/lib/littlefs_native/lfs_util.h @@ -0,0 +1 @@ +../../externals/littlefs/lfs_util.h \ No newline at end of file diff --git a/Firmware/AUTH_MQTT/MQTT_Gateway/lib/littlefs_native/library.json b/Firmware/AUTH_MQTT/MQTT_Gateway/lib/littlefs_native/library.json new file mode 100644 index 00000000..69afe2f8 --- /dev/null +++ b/Firmware/AUTH_MQTT/MQTT_Gateway/lib/littlefs_native/library.json @@ -0,0 +1,7 @@ +{ + "name": "littlefs_native", + "version": "0.1.0", + "build": { + "srcFilter": ["+<*.c>"] + } +} \ No newline at end of file diff --git a/Firmware/AUTH_MQTT/MQTT_Gateway/lib/mongoose/library.json b/Firmware/AUTH_MQTT/MQTT_Gateway/lib/mongoose/library.json new file mode 100644 index 00000000..1a199dc9 --- /dev/null +++ b/Firmware/AUTH_MQTT/MQTT_Gateway/lib/mongoose/library.json @@ -0,0 +1,10 @@ +{ + "name": "Mongoose", + "version": "7.15.0", + "build": { + "flags": [ + "-I .", + "-D MG_ENABLE_IPV6=0" + ] + } +} \ No newline at end of file diff --git a/Firmware/AUTH_MQTT/MQTT_Gateway/lib/mongoose/mongoose.c b/Firmware/AUTH_MQTT/MQTT_Gateway/lib/mongoose/mongoose.c new file mode 120000 index 00000000..c64a0aa9 --- /dev/null +++ b/Firmware/AUTH_MQTT/MQTT_Gateway/lib/mongoose/mongoose.c @@ -0,0 +1 @@ +../../externals/mongoose/mongoose.c \ No newline at end of file diff --git a/Firmware/AUTH_MQTT/MQTT_Gateway/lib/mongoose/mongoose.h b/Firmware/AUTH_MQTT/MQTT_Gateway/lib/mongoose/mongoose.h new file mode 120000 index 00000000..b2595fed --- /dev/null +++ b/Firmware/AUTH_MQTT/MQTT_Gateway/lib/mongoose/mongoose.h @@ -0,0 +1 @@ +../../externals/mongoose/mongoose.h \ No newline at end of file diff --git a/Firmware/AUTH_MQTT/MQTT_Gateway/lib/mongoose/mongoose_config.h b/Firmware/AUTH_MQTT/MQTT_Gateway/lib/mongoose/mongoose_config.h new file mode 100755 index 00000000..b58099ee --- /dev/null +++ b/Firmware/AUTH_MQTT/MQTT_Gateway/lib/mongoose/mongoose_config.h @@ -0,0 +1,30 @@ +#pragma once + +// ESP32 architecture (uses lwIP sockets via Arduino WiFi) +#define MG_ARCH MG_ARCH_ESP32 + +// Enable MQTT client +#define MG_ENABLE_MQTT 1 + +// Disable built-in TCP/IP stack and hardware drivers (ESP32 uses lwIP sockets) +#define MG_ENABLE_TCPIP 0 + +// Disable Mongoose's built-in TLS/crypto stack (~10,900 lines) to save flash. +// Crypto primitives are now provided by: +// - x25519.h (standalone X25519 ECDH, extracted from Mongoose) +// - chacha20.h/chacha20.c (standalone ChaCha20-Poly1305, extracted from Mongoose) +// - Mongoose's always-compiled mg_sha256/mg_hmac_sha256/mg_random +#define MG_TLS MG_TLS_BUILTIN + +// IO buffer size for connections +#define MG_IO_SIZE 2048 + +// Disable filesystem features we don't need +#define MG_ENABLE_POSIX_FS 0 +#define MG_ENABLE_DIRLIST 0 + +// Disable Mongoose internal logging to save flash (app Serial.printf still works) +#define MG_ENABLE_LOG 0 + +// Disable unused MD5 (HTTP digest auth) +#define MG_ENABLE_MD5 0 diff --git a/Firmware/AUTH_MQTT/MQTT_Gateway/main.py b/Firmware/AUTH_MQTT/MQTT_Gateway/main.py new file mode 100644 index 00000000..23fbe502 --- /dev/null +++ b/Firmware/AUTH_MQTT/MQTT_Gateway/main.py @@ -0,0 +1,333 @@ +# main.py +# Fixed: moved blocking wait OUT of the MQTT callback thread + +import json +import time +import random +import argparse +import threading +import hashlib +import hmac +import struct +from datetime import datetime + +try: + import paho.mqtt.client as mqtt +except ImportError: + print("Install paho-mqtt: pip install paho-mqtt") + exit(1) + +try: + from cryptography.hazmat.primitives.ciphers.aead import ChaCha20Poly1305 +except ImportError: + print("Install cryptography: pip install cryptography") + exit(1) + +# ────────────────────────────────────────────── +# Configuration +# ────────────────────────────────────────────── +DEFAULT_BROKER = "broker.hivemq.com" +DEFAULT_PORT = 1883 +DEFAULT_DEVICE_ID = "device_01" +DEFAULT_NAME = "Test ping device 01" +DEFAULT_TYPE = "TestDevice" +DEFAULT_PSK = "test123" + +# Topics (must match ESP32 gateway) +T_GATEWAY_RX = "jrpc/gateway/rx" +T_GATEWAY_CONNECT = "jrpc/gateway/connect" + + +class SensorDevice: + DISCONNECTED = "disconnected" + CONNECTING = "connecting" + CONNECTED = "connected" + + def __init__(self, device_id, name, device_type, broker, port, psk=None): + self.device_id = device_id + self.name = name + self.device_type = device_type + self.broker = broker + self.port = port + self.rpc_id = 0 + self.pending_rpc = {} + + self.state = self.DISCONNECTED + self.my_topic = f"jrpc/devices/{self.device_id}/rx" + + if psk is None: + psk = DEFAULT_PSK + self.enc_key = hashlib.sha256(psk.encode('utf-8')).digest() + self.counter = 0 + + self.client = mqtt.Client( + callback_api_version=mqtt.CallbackAPIVersion.VERSION1, + client_id=f"{self.device_id}_{random.randint(0, 0xFFFF):04x}", + protocol=mqtt.MQTTv311, + ) + self.client.on_connect = self._on_connect + self.client.on_message = self._on_message + self.client.on_disconnect = self._on_disconnect + + # Signalled by _handle_encrypted when gateway replies to connect + self.connect_event = threading.Event() + self.connect_ok = False + + # ────────────────────────────────────────── + # MQTT Callbacks (NEVER block here) + # ────────────────────────────────────────── + def _on_connect(self, client, userdata, flags, rc): + if rc != 0: + self._log(f"MQTT connect failed: rc={rc}") + return + self._log(f"MQTT connected to {self.broker}") + client.subscribe(self.my_topic, qos=1) + self._log(f"Subscribed to {self.my_topic}") + # FIX: only *send* the request here — do NOT wait inside the callback. + self._send_connect_request() + + def _on_disconnect(self, client, userdata, rc): + self._log(f"MQTT disconnected (rc={rc})") + self.state = self.DISCONNECTED + + def _on_message(self, client, userdata, msg): + try: + payload = json.loads(msg.payload.decode()) + except json.JSONDecodeError: + return + if "device_id" in payload and "nonce" in payload and "ciphertext" in payload: + self._handle_encrypted(payload) + + # ────────────────────────────────────────── + # Encryption helpers + # ────────────────────────────────────────── + def _build_nonce(self): + """12-byte nonce: 4-byte big-endian counter + 8-byte big-endian timestamp.""" + self.counter += 1 + return struct.pack('>I', self.counter) + struct.pack('>Q', int(time.time())) + + def _build_auth(self, method: str, timestamp: int) -> str: + """HMAC-SHA256 signature over '::'. + + Mirrors gw_verify_auth() in gateway_utils.cpp. The key is the + derived encryption key (SHA-256 of PSK), not the raw PSK. + """ + msg = f"{self.device_id}:{timestamp}:{method}".encode() + return hmac.new(self.enc_key, msg, hashlib.sha256).hexdigest() + + def _encrypt(self, plaintext: bytes): + """ChaCha20-Poly1305 encrypt. AAD = device_id (matches gateway sendEncrypted).""" + nonce = self._build_nonce() + aad = self.device_id.encode('utf-8') + ct = ChaCha20Poly1305(self.enc_key).encrypt(nonce, plaintext, aad) + return nonce, ct + + def _decrypt(self, nonce: bytes, ciphertext: bytes): + """ChaCha20-Poly1305 decrypt. AAD = device_id (matches gateway sendEncrypted).""" + aad = self.device_id.encode('utf-8') + try: + return ChaCha20Poly1305(self.enc_key).decrypt(nonce, ciphertext, aad) + except Exception as e: + self._log(f"Decryption failed: {e}") + return None + + # ────────────────────────────────────────── + # Connect procedure + # ────────────────────────────────────────── + def _send_connect_request(self): + """Send the encrypted connect envelope — returns immediately.""" + # FIX: reset event so a reconnect attempt doesn't use a stale result + self.connect_event.clear() + self.connect_ok = False + self.state = self.CONNECTING + + method = "request_connect" + timestamp = int(time.time()) + inner = { + "device_name": self.name, + "device_type": self.device_type, + "method": method, + "timestamp": timestamp, + "auth": self._build_auth(method, timestamp), + } + plaintext = json.dumps(inner, separators=(',', ':')).encode() + nonce, ct = self._encrypt(plaintext) + + outer = { + "device_id": self.device_id, + "nonce": nonce.hex(), + "ciphertext": ct.hex(), + } + self.client.publish(T_GATEWAY_CONNECT, + json.dumps(outer, separators=(',', ':')), qos=1) + self._log("Connect request sent — waiting for admin approval in dashboard...") + + def wait_for_approval(self, timeout=120.0) -> bool: + """Block the *caller* (main thread) until the gateway approves or times out.""" + if self.connect_event.wait(timeout): + if self.connect_ok: + self.state = self.CONNECTED + self._log("Approved by gateway ✅") + return True + else: + self.state = self.DISCONNECTED + self._log("Rejected by gateway ❌") + return False + self.state = self.DISCONNECTED + self._log("Approval timeout ⏰") + return False + + def _handle_encrypted(self, payload): + if payload.get("device_id") != self.device_id: + return + nonce_hex = payload.get("nonce") + cipher_hex = payload.get("ciphertext") + if not nonce_hex or not cipher_hex: + return + + plain = self._decrypt(bytes.fromhex(nonce_hex), bytes.fromhex(cipher_hex)) + if plain is None: + return + + try: + msg = json.loads(plain.decode()) + except json.JSONDecodeError: + return + + if msg.get("method") == "connect.response": + status = msg.get("params", {}).get("status") + self.connect_ok = (status == "approved") + self.connect_event.set() # unblocks wait_for_approval() + elif "result" in msg: + self._handle_result(msg) + elif "error" in msg: + self._handle_error(msg) + + # ────────────────────────────────────────── + # RPC (after approved) + # ────────────────────────────────────────── + def send_rpc(self, method, params): + if self.state != self.CONNECTED: + self._log("Not connected — cannot send RPC.") + return + rpc_id = self._next_id() + timestamp = int(time.time()) + inner = { + "jsonrpc": "2.0", + "method": method, + "params": params, + "id": rpc_id, + "timestamp": timestamp, + "auth": self._build_auth(method, timestamp), + } + plaintext = json.dumps(inner, separators=(',', ':')).encode() + nonce, ct = self._encrypt(plaintext) + outer = { + "device_id": self.device_id, + "nonce": nonce.hex(), + "ciphertext": ct.hex(), + } + self.pending_rpc[rpc_id] = {"method": method, "ts": time.time()} + self.client.publish(T_GATEWAY_RX, + json.dumps(outer, separators=(',', ':')), qos=1) + + def send_ping(self): + self._log("Pinging gateway...") + self.send_rpc("ping", {}) + + def _handle_result(self, payload): + rpc_id = payload.get("id", -1) + result = payload.get("result", {}) + info = self.pending_rpc.pop(rpc_id, None) + method = info["method"] if info else "?" + rtt = (time.time() - info["ts"]) * 1000 if info else 0 + if method == "ping": + uptime = result.get("uptime_ms", 0) / 1000 + self._log(f"Pong! Gateway uptime: {uptime:.0f}s RTT: {rtt:.0f}ms") + else: + self._log(f"[RESULT] {method}: {json.dumps(result)}") + + def _handle_error(self, payload): + rpc_id = payload.get("id", -1) + error = payload.get("error", {}) + info = self.pending_rpc.pop(rpc_id, None) + method = info["method"] if info else "?" + self._log(f"ERROR [{method}] code={error.get('code','?')}: {error.get('message','?')}") + + def _next_id(self): + self.rpc_id += 1 + return self.rpc_id + + def _log(self, msg): + ts = datetime.now().strftime("%H:%M:%S") + print(f"[{ts}] [{self.device_id}] {msg}") + + def stop(self): + self.client.disconnect() + self.client.loop_stop() + self._log("Disconnected.") + + +# ────────────────────────────────────────────── +# Interactive Mode +# ────────────────────────────────────────────── +def interactive_mode(dev: SensorDevice): + dev.client.connect(dev.broker, dev.port, keepalive=60) + dev.client.loop_start() # network thread — callbacks run here, must not block + + # Wait for admin to approve in the dashboard (blocks main thread, not MQTT thread) + print("Waiting for admin approval via dashboard (timeout 120s)...") + if not dev.wait_for_approval(timeout=120.0): + print("Could not authenticate. Check PSK and gateway.") + dev.stop() + return + + print("\n+--- Commands ---------------------+") + print("| ping -- Ping gateway |") + print("| quit -- Exit |") + print("+-----------------------------------+\n") + + try: + while True: + cmd = input(f"[{dev.device_id}|{dev.state}] > ").strip().lower() + if cmd == "ping": + dev.send_ping() + elif cmd in ("quit", "exit", "q"): + break + elif cmd == "": + continue + else: + print(f" Unknown: '{cmd}'") + time.sleep(0.3) + except (KeyboardInterrupt, EOFError): + pass + + dev.stop() + + +# ────────────────────────────────────────────── +# CLI Entry Point +# ────────────────────────────────────────────── +if __name__ == "__main__": + parser = argparse.ArgumentParser( + description="Device Simulator — ChaCha20-Poly1305 encrypted MQTT", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + python main.py --psk test123 + python main.py --device-id sensor_01 --name "Temp sensor" --type sensor --psk secret + """, + ) + parser.add_argument("--device-id", default=DEFAULT_DEVICE_ID) + parser.add_argument("--name", default=DEFAULT_NAME) + parser.add_argument("--type", default=DEFAULT_TYPE, dest="device_type") + parser.add_argument("--psk", default=DEFAULT_PSK) + parser.add_argument("--broker", default=DEFAULT_BROKER) + parser.add_argument("--port", type=int, default=DEFAULT_PORT) + args = parser.parse_args() + + dev = SensorDevice( + args.device_id, args.name, args.device_type, + args.broker, args.port, args.psk, + ) + interactive_mode(dev) \ No newline at end of file diff --git a/Firmware/AUTH_MQTT/MQTT_Gateway/mqtt_test.py b/Firmware/AUTH_MQTT/MQTT_Gateway/mqtt_test.py new file mode 100644 index 00000000..3a547894 --- /dev/null +++ b/Firmware/AUTH_MQTT/MQTT_Gateway/mqtt_test.py @@ -0,0 +1,304 @@ +#!/usr/bin/env python3 +""" +MQTT Gateway Test Script +======================== +Tests the ESP32 MQTT gateway by simulating a device. + +Protocol recap (from gateway_core.cpp): + Connect topic : jrpc/gateway/connect + RX topic : jrpc/gateway/rx + Response topic : jrpc/devices//rx + +Connect payload : {"device_id":"...","nonce":"<24-hex>","ciphertext":""} + Ciphertext : ChaCha20-Poly1305( key=SHA256(PSK), nonce=12B, aad=b"", + plain={"device_name":"...","device_type":"..."} ) +RX payload same shape but plain is a JSON-RPC 2.0 object. + +Usage: + pip install paho-mqtt cryptography + python mqtt_gateway_test.py [--psk MY_PSK] [--device-id my_sensor] +""" + +import argparse +import hashlib +import json +import os +import struct +import sys +import time +import threading + +try: + import paho.mqtt.client as mqtt +except ImportError: + sys.exit("❌ Missing paho-mqtt — run: pip install paho-mqtt") + +try: + from cryptography.hazmat.primitives.ciphers.aead import ChaCha20Poly1305 +except ImportError: + sys.exit("❌ Missing cryptography — run: pip install cryptography") + +# ────────────────────────────────────────────── +# Config (matches gateway_config.h) +# ────────────────────────────────────────────── +BROKER = "broker.hivemq.com" +PORT = 1883 +T_CONNECT = "jrpc/gateway/connect" +T_RX = "jrpc/gateway/rx" +T_DEV_RX = "jrpc/devices/{device_id}/rx" + +TIMEOUT_SEC = 10 # seconds to wait for a response + + +# ────────────────────────────────────────────── +# Crypto helpers (mirror gateway's C code) +# ────────────────────────────────────────────── +def derive_key(psk: str) -> bytes: + """SHA-256 of the PSK string — matches gw_psk_to_key().""" + return hashlib.sha256(psk.encode()).digest() + + +def make_nonce(counter: int) -> bytes: + """12-byte nonce: 4-byte big-endian counter + 8-byte big-endian timestamp.""" + ts = int(time.time()) + return struct.pack(">I", counter) + struct.pack(">Q", ts) + + +def encrypt(key: bytes, nonce: bytes, plaintext: bytes) -> bytes: + """ChaCha20-Poly1305 with no AAD — matches gateway's chacha20_poly1305_encrypt.""" + chacha = ChaCha20Poly1305(key) + return chacha.encrypt(nonce, plaintext, b"") # aad = b"" + + +def decrypt(key: bytes, nonce: bytes, ciphertext_with_tag: bytes) -> bytes: + """ChaCha20-Poly1305 decrypt — raises InvalidTag on auth failure.""" + chacha = ChaCha20Poly1305(key) + return chacha.decrypt(nonce, ciphertext_with_tag, b"") + + +# ────────────────────────────────────────────── +# Test class +# ────────────────────────────────────────────── +class GatewayTester: + def __init__(self, device_id: str, psk: str): + self.device_id = device_id + self.psk = psk + self.key = derive_key(psk) + self.counter = 1 + + self._responses = [] + self._connected = threading.Event() + self._got_resp = threading.Event() + self._subscribed_ok = False + + self.client = mqtt.Client(client_id=f"gw_tester_{os.getpid()}") + self.client.on_connect = self._on_connect + self.client.on_disconnect = self._on_disconnect + self.client.on_subscribe = self._on_subscribe + self.client.on_message = self._on_message + + # ── MQTT callbacks ────────────────────────────────── + def _on_connect(self, client, userdata, flags, rc): + if rc == 0: + print(f" ✅ Connected to {BROKER}:{PORT}") + resp_topic = T_DEV_RX.format(device_id=self.device_id) + client.subscribe(resp_topic, qos=1) + self._connected.set() + else: + print(f" ❌ Connection refused — rc={rc}") + + def _on_disconnect(self, client, userdata, rc): + if rc != 0: + print(f" ⚠️ Unexpected disconnect rc={rc}") + + def _on_subscribe(self, client, userdata, mid, granted_qos): + self._subscribed_ok = True + print(f" ✅ Subscribed to {T_DEV_RX.format(device_id=self.device_id)}") + + def _on_message(self, client, userdata, msg): + raw = msg.payload.decode(errors="replace") + print(f"\n 📨 Received on [{msg.topic}]:\n {raw}") + self._responses.append({"topic": msg.topic, "payload": raw}) + self._got_resp.set() + + # ── Helpers ───────────────────────────────────────── + def _build_envelope(self, plaintext: dict) -> dict: + nonce = make_nonce(self.counter) + self.counter += 1 + cipher = encrypt(self.key, nonce, json.dumps(plaintext).encode()) + return { + "device_id": self.device_id, + "nonce": nonce.hex(), + "ciphertext": cipher.hex(), + } + + def _publish(self, topic: str, payload: dict): + raw = json.dumps(payload) + info = self.client.publish(topic, raw, qos=1) + info.wait_for_publish(timeout=5) + print(f" 📤 Published to [{topic}]:\n {raw}") + + # ── Tests ──────────────────────────────────────────── + def run_all(self): + results = {} + + # ── Test 1: Broker connectivity ────────────────── + print("\n" + "="*55) + print("TEST 1 — Broker connectivity") + print("="*55) + self.client.connect(BROKER, PORT, keepalive=30) + self.client.loop_start() + ok = self._connected.wait(timeout=10) + results["broker_connect"] = ok + if not ok: + print(" ❌ Could not connect to broker (timeout)") + print("\nPossible causes:") + print(" • No internet access on this machine") + print(" • Firewall blocking port 1883") + print(f" • Broker {BROKER} is down") + self.client.loop_stop() + return results + print(" ✅ Broker reachable") + + # ── Test 2: Subscription ready ─────────────────── + print("\n" + "="*55) + print("TEST 2 — Subscription to device response topic") + print("="*55) + time.sleep(1) + results["subscribed"] = self._subscribed_ok + if not self._subscribed_ok: + print(" ⚠️ Subscription not confirmed yet (may still work)") + else: + print(" ✅ Subscription confirmed") + + # ── Test 3: Connect request ────────────────────── + print("\n" + "="*55) + print("TEST 3 — Send device connect request to gateway") + print("="*55) + inner = { + "device_name": "PythonTestDevice", + "device_type": "tester", + } + envelope = self._build_envelope(inner) + self._got_resp.clear() + self._publish(T_CONNECT, envelope) + print(f"\n ⏳ Waiting {TIMEOUT_SEC}s for gateway to echo connect on device topic…") + print(f" (If the ESP32 is running and connected, it will store this as PENDING.)") + print(f" (No response is expected until you authorize in the dashboard.)\n") + got = self._got_resp.wait(timeout=TIMEOUT_SEC) + results["connect_response"] = got + if got: + print(" ✅ Gateway sent a response after connect request!") + else: + print(" ℹ️ No response received (expected — device is PENDING until authorized)") + + # ── Test 4: Raw plaintext probe ────────────────── + print("\n" + "="*55) + print("TEST 4 — Malformed message probe (gateway error handling)") + print("="*55) + bad_payload = json.dumps({ + "device_id": self.device_id, + "nonce": "this_is_not_hex", + "ciphertext": "aabbcc", + }) + self._got_resp.clear() + info = self.client.publish(T_RX, bad_payload, qos=1) + info.wait_for_publish(timeout=5) + print(f" 📤 Sent malformed message to [{T_RX}]") + print(f" ⏳ Waiting {TIMEOUT_SEC}s for error response…") + got = self._got_resp.wait(timeout=TIMEOUT_SEC) + results["malformed_error_response"] = got + if got: + print(" ✅ Gateway responded to malformed message (error handling works)") + else: + print(" ⚠️ No response (device not yet known to gateway — expected on first run)") + + # ── Test 5: Echo / ping after approval ────────── + print("\n" + "="*55) + print("TEST 5 — Encrypted ping (only works after dashboard authorization)") + print("="*55) + rpc_ping = { + "jsonrpc": "2.0", + "method": "ping", + "params": {}, + "id": 42, + } + envelope2 = self._build_envelope(rpc_ping) + self._got_resp.clear() + self._publish(T_RX, envelope2) + print(f" ⏳ Waiting {TIMEOUT_SEC}s for pong response…") + got = self._got_resp.wait(timeout=TIMEOUT_SEC) + results["ping_response"] = got + if got: + resp_raw = self._responses[-1]["payload"] + # Try to decrypt the response + try: + resp_json = json.loads(resp_raw) + nonce_b = bytes.fromhex(resp_json["nonce"]) + cipher_b = bytes.fromhex(resp_json["ciphertext"]) + plain_b = decrypt(self.key, nonce_b, cipher_b) + print(f" ✅ Pong received! Decrypted: {plain_b.decode()}") + except Exception as e: + print(f" ⚠️ Got response but couldn't decrypt: {e}") + print(f" Raw: {resp_raw}") + else: + print(" ⚠️ No pong (device must be APPROVED in dashboard first)") + + # ── Summary ────────────────────────────────────── + self.client.loop_stop() + self.client.disconnect() + return results + + +def print_diagnosis(results: dict): + print("\n" + "="*55) + print("DIAGNOSIS") + print("="*55) + if not results.get("broker_connect"): + print("🔴 Cannot reach MQTT broker.") + print(" → Check internet access and that port 1883 is not firewalled.") + return + + print("🟢 Broker connection: OK") + print("🟢 Subscription: " + ("OK" if results.get("subscribed") else "unconfirmed (may still work)")) + + if not results.get("ping_response"): + print("\n⚠️ Gateway did not respond to ping.") + print("\nIf the ESP32 serial log shows 'MQTT msg on topic' → gateway received it.") + print("If the serial log shows nothing → the message was NOT received.\n") + print("Common fixes:") + print(" 1. Device must be AUTHORIZED in the web dashboard first.") + print(" 2. Both this script and the ESP32 must use the SAME PSK.") + print(" 3. Check the ESP32 serial monitor for any error messages.") + print(" 4. The gateway uses broker.hivemq.com (public) — if it disconnects") + print(" often, consider a private broker or add a MQTT username/password.") + print(" 5. broker.hivemq.com sometimes silently drops connections;") + print(" if you see frequent disconnects in the serial log,") + print(" try mqtt.eclipseprojects.io or test.mosquitto.org instead.") + else: + print("🟢 Ping/pong: OK — gateway is fully operational!") + + +# ────────────────────────────────────────────── +# Entry point +# ────────────────────────────────────────────── +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="MQTT Gateway Test Script") + parser.add_argument("--broker", default=BROKER, help="MQTT broker host") + parser.add_argument("--port", default=PORT, type=int, help="MQTT broker port") + parser.add_argument("--psk", default="test_psk_1234", help="Pre-shared key (must match dashboard)") + parser.add_argument("--device-id", default="test_device_01", help="Device ID to use") + args = parser.parse_args() + + BROKER = args.broker + PORT = args.port + + print(f"\n🔌 MQTT Gateway Tester") + print(f" Broker : {BROKER}:{PORT}") + print(f" Device ID: {args.device_id}") + print(f" PSK : {args.psk}") + print(f" Key (hex): {derive_key(args.psk).hex()}") + + tester = GatewayTester(device_id=args.device_id, psk=args.psk) + results = tester.run_all() + print_diagnosis(results) \ No newline at end of file diff --git a/Firmware/AUTH_MQTT/MQTT_Gateway/platformio.ini b/Firmware/AUTH_MQTT/MQTT_Gateway/platformio.ini new file mode 100644 index 00000000..8e9cac1b --- /dev/null +++ b/Firmware/AUTH_MQTT/MQTT_Gateway/platformio.ini @@ -0,0 +1,44 @@ +; PlatformIO Project Configuration File +; +; Build options: build flags, source filter +; Upload options: custom upload port, speed and extra flags +; Library options: dependencies, extra library storages +; Advanced options: extra scripting +; +; Please visit documentation for the other options and examples +; https://docs.platformio.org/page/projectconf.html + +[native_base] +platform = native +test_framework = unity +build_src_filter = + -<*> + +[env:native_device] +extends = native_base +test_filter = test_device +build_flags = + -std=c++17 + -I test/test_device/mocks ; suite-specific FIRST (takes priority) + -I test/mocks ; shared mocks SECOND (fallback) + -I lib/littlefs_native + -DMG_ENABLE_LOG=0 +lib_deps = + throwtheswitch/Unity@^2.6.1 + lib/littlefs_native + +[env:native_utils] +extends = native_base +test_filter = test_utils +build_flags = + -std=c++17 + -I test/mocks ; only shared mocks needed + -I lib/littlefs_native + -DMG_ENABLE_LOG=0 ; ignore all source files + +[env:esp32dev] +platform = espressif32 +board = esp32dev +framework = arduino +build_flags = -I include +monitor_speed = 115200 diff --git a/Firmware/AUTH_MQTT/MQTT_Gateway/src/gateway.cpp b/Firmware/AUTH_MQTT/MQTT_Gateway/src/gateway.cpp new file mode 100644 index 00000000..c393fa9d --- /dev/null +++ b/Firmware/AUTH_MQTT/MQTT_Gateway/src/gateway.cpp @@ -0,0 +1,15 @@ +#include "gateway.h" +#include "gateway_core.h" +#include "gateway_dashboard.h" + +static GatewayCore s_core; +static DashboardServer s_dashboard(s_core); + +void gateway_init() { + s_core.begin(); + s_dashboard.begin(80); +} + +void gateway_poll() { + s_core.poll(); +} \ No newline at end of file diff --git a/Firmware/AUTH_MQTT/MQTT_Gateway/src/main.cpp b/Firmware/AUTH_MQTT/MQTT_Gateway/src/main.cpp new file mode 100755 index 00000000..354fecf7 --- /dev/null +++ b/Firmware/AUTH_MQTT/MQTT_Gateway/src/main.cpp @@ -0,0 +1,16 @@ +#include +#include "gateway.h" + +void setup() +{ +gateway_init(); + +} + + +void loop() +{ + +gateway_poll(); + +} \ No newline at end of file diff --git a/Firmware/AUTH_MQTT/MQTT_Gateway/test/README b/Firmware/AUTH_MQTT/MQTT_Gateway/test/README new file mode 100644 index 00000000..9b1e87bc --- /dev/null +++ b/Firmware/AUTH_MQTT/MQTT_Gateway/test/README @@ -0,0 +1,11 @@ + +This directory is intended for PlatformIO Test Runner and project tests. + +Unit Testing is a software testing method by which individual units of +source code, sets of one or more MCU program modules together with associated +control data, usage procedures, and operating procedures, are tested to +determine whether they are fit for use. Unit testing finds problems early +in the development cycle. + +More information about PlatformIO Unit Testing: +- https://docs.platformio.org/en/latest/advanced/unit-testing/index.html diff --git a/Firmware/AUTH_MQTT/MQTT_Gateway/test/mocks/Arduino.h b/Firmware/AUTH_MQTT/MQTT_Gateway/test/mocks/Arduino.h new file mode 100644 index 00000000..af96b984 --- /dev/null +++ b/Firmware/AUTH_MQTT/MQTT_Gateway/test/mocks/Arduino.h @@ -0,0 +1,54 @@ +#pragma once +#include +#include +#include +#include + +// ── String ──────────────────────────────────────────────────────────── +class String { +public: + std::string _s; + + String() = default; + String(const char* s) : _s(s ? s : "") {} + String(std::string s) : _s(std::move(s)) {} + String(int v) : _s(std::to_string(v)) {} + String(unsigned long v) : _s(std::to_string(v)) {} + + const char* c_str() const { return _s.c_str(); } + size_t length() const { return _s.size(); } + bool isEmpty()const { return _s.empty(); } + + bool startsWith(const String& p) const { + return _s.rfind(p._s, 0) == 0; + } + String substring(size_t from) const { + return String(_s.substr(from)); + } + void replace(const char* from, const char* to) { + size_t pos = 0; + while ((pos = _s.find(from, pos)) != std::string::npos) { + _s.replace(pos, strlen(from), to); + pos += strlen(to); + } + } + + String operator+ (const String& o) const { return String(_s + o._s); } + String& operator+=(const String& o) { _s += o._s; return *this; } + bool operator==(const String& o) const { return _s == o._s; } + bool operator!=(const String& o) const { return _s != o._s; } + bool operator< (const String& o) const { return _s < o._s; } + bool operator> (const String& o) const { return _s > o._s; } + operator const char*() const { return _s.c_str(); } +}; +inline String operator+(const char* lhs, const String& rhs) { + return String(std::string(lhs) + rhs._s); +} +// ── Serial stub ──────────────────────────────────────────────────────── +struct SerialClass { + void println(const char*) {} + void println(const String&) {} + template + void printf(const char*, A...) {} +}; +static SerialClass Serial; \ No newline at end of file diff --git a/Firmware/AUTH_MQTT/MQTT_Gateway/test/mocks/LittleFS.h b/Firmware/AUTH_MQTT/MQTT_Gateway/test/mocks/LittleFS.h new file mode 100644 index 00000000..c672f6e9 --- /dev/null +++ b/Firmware/AUTH_MQTT/MQTT_Gateway/test/mocks/LittleFS.h @@ -0,0 +1,170 @@ +#pragma once +#include "Arduino.h" +#include "lfs.h" +#include +#include +#include +#include + +// ── RAM flash config ────────────────────────────────────────────────── +#define LFS_FLASH_SIZE (256 * 1024) +#define LFS_BLOCK_SIZE 4096 +#define LFS_BLOCK_COUNT (LFS_FLASH_SIZE / LFS_BLOCK_SIZE) + +inline uint8_t g_flash_buf[LFS_FLASH_SIZE]; +inline lfs_t g_lfs; +inline bool g_lfs_mounted = false; + +// ── Block device callbacks ──────────────────────────────────────────── +inline int lfs_ram_read(const struct lfs_config* c, lfs_block_t block, + lfs_off_t off, void* buf, lfs_size_t size) { + memcpy(buf, g_flash_buf + block * LFS_BLOCK_SIZE + off, size); + return LFS_ERR_OK; +} +inline int lfs_ram_prog(const struct lfs_config* c, lfs_block_t block, + lfs_off_t off, const void* buf, lfs_size_t size) { + memcpy(g_flash_buf + block * LFS_BLOCK_SIZE + off, buf, size); + return LFS_ERR_OK; +} +inline int lfs_ram_erase(const struct lfs_config* c, lfs_block_t block) { + memset(g_flash_buf + block * LFS_BLOCK_SIZE, 0xFF, LFS_BLOCK_SIZE); + return LFS_ERR_OK; +} +inline int lfs_ram_sync(const struct lfs_config*) { return LFS_ERR_OK; } + +inline const struct lfs_config g_lfs_cfg = { + .context = NULL, + .read = lfs_ram_read, + .prog = lfs_ram_prog, + .erase = lfs_ram_erase, + .sync = lfs_ram_sync, + .read_size = 16, + .prog_size = 16, + .block_size = LFS_BLOCK_SIZE, + .block_count = LFS_BLOCK_COUNT, + .block_cycles = 500, + .cache_size = 16, + .lookahead_size = 16, +}; + +inline void lfs_ram_mount_fresh() { + memset(g_flash_buf, 0xFF, LFS_FLASH_SIZE); + lfs_format(&g_lfs, &g_lfs_cfg); + lfs_mount(&g_lfs, &g_lfs_cfg); + g_lfs_mounted = true; +} +inline void lfs_ram_unmount() { + if (g_lfs_mounted) { + lfs_unmount(&g_lfs); + g_lfs_mounted = false; + } +} + +// ── File ────────────────────────────────────────────────────────────── +class File { +public: + bool _valid = false; + bool _isDir = false; + std::string _path; + std::string _name; + + // heap-allocated — address stays stable when File is copied + std::shared_ptr _file; + std::shared_ptr _dir; + + File() = default; + + explicit operator bool() const { return _valid; } + bool isDirectory() const { return _isDir; } + const char* name() const { return _name.c_str(); } + + String readString() { + if (!_valid || _isDir || !_file) return String(""); + lfs_ssize_t sz = lfs_file_size(&g_lfs, _file.get()); + lfs_file_seek(&g_lfs, _file.get(), 0, LFS_SEEK_SET); + std::string buf(sz, '\0'); + lfs_file_read(&g_lfs, _file.get(), &buf[0], sz); + return String(buf.c_str()); + } + + void print(const char* s) { + if (_valid && !_isDir && _file) + lfs_file_write(&g_lfs, _file.get(), s, strlen(s)); + } + + void close() { + if (!_valid) return; + if (_isDir && _dir) lfs_dir_close (&g_lfs, _dir.get()); + if (!_isDir && _file) lfs_file_close(&g_lfs, _file.get()); + _valid = false; + } + + File openNextFile() { + if (!_valid || !_isDir || !_dir) return File{}; + lfs_info info; + while (true) { + int res = lfs_dir_read(&g_lfs, _dir.get(), &info); + if (res <= 0) return File{}; + if (strcmp(info.name, ".") == 0 || + strcmp(info.name, "..") == 0) continue; + + std::string childPath = _path + "/" + info.name; + File f; + f._name = info.name; + f._path = childPath; + + if (info.type == LFS_TYPE_DIR) { + f._isDir = true; + f._dir = std::make_shared(); + f._valid = (lfs_dir_open(&g_lfs, f._dir.get(), + childPath.c_str()) == 0); + } else { + f._isDir = false; + f._file = std::make_shared(); + f._valid = (lfs_file_open(&g_lfs, f._file.get(), + childPath.c_str(), + LFS_O_RDONLY) == 0); + } + return f; + } + } +}; + +// ── LittleFS ────────────────────────────────────────────────────────── +struct LittleFSClass { + + File open(const char* path, const char* mode = "r") { + File f; + f._path = path; + std::string p(path); + size_t slash = p.rfind('/'); + f._name = (slash == std::string::npos) ? p : p.substr(slash + 1); + + lfs_info info; + if (lfs_stat(&g_lfs, path, &info) == 0 && + info.type == LFS_TYPE_DIR) { + f._isDir = true; + f._dir = std::make_shared(); + f._valid = (lfs_dir_open(&g_lfs, f._dir.get(), path) == 0); + return f; + } + + int flags = (mode[0] == 'w') + ? LFS_O_WRONLY | LFS_O_CREAT | LFS_O_TRUNC + : LFS_O_RDONLY; + f._isDir = false; + f._file = std::make_shared(); + f._valid = (lfs_file_open(&g_lfs, f._file.get(), path, flags) == 0); + return f; + } + + bool mkdir(const char* path) { + return lfs_mkdir(&g_lfs, path) == 0; + } + + bool remove(const char* path) { + return lfs_remove(&g_lfs, path) == 0; + } +}; + +inline LittleFSClass LittleFS; \ No newline at end of file diff --git a/Firmware/AUTH_MQTT/MQTT_Gateway/test/mocks/WiFi.h b/Firmware/AUTH_MQTT/MQTT_Gateway/test/mocks/WiFi.h new file mode 100644 index 00000000..feb77a98 --- /dev/null +++ b/Firmware/AUTH_MQTT/MQTT_Gateway/test/mocks/WiFi.h @@ -0,0 +1,3 @@ +#pragma once +// WiFi stub — gateway_device does not use WiFi directly. +// This file exists only to satisfy the #include in gateway_private.h. \ No newline at end of file diff --git a/Firmware/AUTH_MQTT/MQTT_Gateway/test/test_device/test_gateway_device_main.cpp b/Firmware/AUTH_MQTT/MQTT_Gateway/test/test_device/test_gateway_device_main.cpp new file mode 100644 index 00000000..8b09c6fc --- /dev/null +++ b/Firmware/AUTH_MQTT/MQTT_Gateway/test/test_device/test_gateway_device_main.cpp @@ -0,0 +1,175 @@ +#include +#include "gateway_device.h" + +// ── Helpers ─────────────────────────────────────────────────────────── +// Builds a valid JSON device file the way saveDevice() would write it +static void write_device_file(const char* id, const char* name, + const char* type, int status, + unsigned long nonce) { + char path[64]; + snprintf(path, sizeof(path), "/devices/dev_%s", id); + + char buf[256]; + snprintf(buf, sizeof(buf), + "{\"id\":\"%s\",\"name\":\"%s\",\"type\":\"%s\"," + "\"status\":%d,\"lastNonce\":%lu," + "\"firstSeen\":1000,\"lastSeen\":2000," + "\"messageCount\":3,\"permPing\":false,\"key\":\"\"}", + id, name, type, status, nonce); + + lfs_file_t f; + lfs_file_open(&g_lfs, &f, path, LFS_O_WRONLY | LFS_O_CREAT | LFS_O_TRUNC); + lfs_file_write(&g_lfs, &f, buf, strlen(buf)); + lfs_file_close(&g_lfs, &f); +} + +// ── setUp / tearDown ────────────────────────────────────────────────── + +void setUp() { + lfs_ram_mount_fresh(); // blank formatted flash per test + lfs_mkdir(&g_lfs, "/devices"); // pre-create the devices directory +} + +void tearDown() { + lfs_ram_unmount(); +} + +// ── Tests ───────────────────────────────────────────────────────────── + +// loadDevices() on empty directory → no devices loaded, no crash +void test_load_empty_directory() { + GatewayDevice gd; + gd.loadDevices(); + TEST_ASSERT_EQUAL(0, (int)gd.devices.size()); +} + +// loadDevices() creates /devices dir when it doesn't exist yet +void test_load_creates_devices_dir_if_missing() { + lfs_remove(&g_lfs, "/devices"); // remove it so loadDevices must create it + + GatewayDevice gd; + gd.loadDevices(); + + lfs_info info; + int res = lfs_stat(&g_lfs, "/devices", &info); + TEST_ASSERT_EQUAL(0, res); + TEST_ASSERT_EQUAL(LFS_TYPE_DIR, (int)info.type); +} + +// loadDevices() correctly parses a JSON device file +void test_load_reads_one_device() { + write_device_file("sensor_01", "Temp Sensor", "sensor", DEV_APPROVED, 42); + + GatewayDevice gd; + gd.loadDevices(); + + TEST_ASSERT_EQUAL(1, (int)gd.devices.size()); + TEST_ASSERT_TRUE(gd.devices.count("sensor_01") > 0); + + const Device& d = gd.devices["sensor_01"]; + TEST_ASSERT_EQUAL_STRING("sensor_01", d.id.c_str()); + TEST_ASSERT_EQUAL_STRING("Temp Sensor", d.name.c_str()); + TEST_ASSERT_EQUAL_STRING("sensor", d.type.c_str()); + TEST_ASSERT_EQUAL(DEV_APPROVED, (int)d.status); + TEST_ASSERT_EQUAL(42, (int)d.lastNonce); + TEST_ASSERT_FALSE(d.has_pending); +} + +// loadDevices() loads multiple devices in one pass +void test_load_reads_multiple_devices() { + write_device_file("node_A", "Node A", "relay", DEV_PENDING, 1); + write_device_file("node_B", "Node B", "sensor", DEV_APPROVED, 9); + + GatewayDevice gd; + gd.loadDevices(); + + TEST_ASSERT_EQUAL(2, (int)gd.devices.size()); + TEST_ASSERT_TRUE(gd.devices.count("node_A") > 0); + TEST_ASSERT_TRUE(gd.devices.count("node_B") > 0); +} + +// saveDevice() writes a file that actually exists on the FS +void test_save_creates_file() { + Device dev; + dev.id = "relay_01"; + dev.name = "Main Relay"; + dev.type = "relay"; + dev.status = DEV_APPROVED; + dev.lastNonce = 7; + dev.firstSeen = 100; + dev.lastSeen = 200; + dev.messageCount = 0; + dev.permPing = false; + dev.keySet = false; + + GatewayDevice gd; + gd.saveDevice(dev); + + lfs_info info; + int res = lfs_stat(&g_lfs, "/devices/dev_relay_01", &info); + TEST_ASSERT_EQUAL_MESSAGE(0, res, "File should exist after saveDevice()"); + TEST_ASSERT_TRUE(info.size > 0); +} + +// save then load round-trip preserves all fields +void test_roundtrip_save_load() { + Device dev; + dev.id = "node_A"; + dev.name = "Node Alpha"; + dev.type = "relay"; + dev.status = DEV_PENDING; + dev.lastNonce = 99; + dev.firstSeen = 10; + dev.lastSeen = 20; + dev.messageCount = 5; + dev.permPing = true; + dev.keySet = false; + + GatewayDevice gd; + gd.saveDevice(dev); + gd.devices.clear(); + gd.loadDevices(); + + TEST_ASSERT_EQUAL(1, (int)gd.devices.size()); + const Device& d = gd.devices["node_A"]; + TEST_ASSERT_EQUAL_STRING("Node Alpha", d.name.c_str()); + TEST_ASSERT_EQUAL_STRING("relay", d.type.c_str()); + TEST_ASSERT_EQUAL(DEV_PENDING, (int)d.status); + TEST_ASSERT_EQUAL(99, (int)d.lastNonce); + TEST_ASSERT_EQUAL(5, d.messageCount); + TEST_ASSERT_TRUE(d.permPing); +} + +// removeDevice() deletes the file from flash +void test_remove_deletes_file() { + write_device_file("sensor_01", "Temp Sensor", "sensor", DEV_PENDING, 0); + + GatewayDevice gd; + gd.removeDevice("sensor_01"); + + lfs_info info; + int res = lfs_stat(&g_lfs, "/devices/dev_sensor_01", &info); + TEST_ASSERT_NOT_EQUAL(0, res); // file should no longer exist +} + +// safeFilename() replaces all illegal characters +void test_safe_filename_special_chars() { + GatewayDevice gd; + String result = gd.safeFilename("home/topic:name*val"); + TEST_ASSERT_EQUAL_STRING("home_topic_name_val", result.c_str()); +} + +// ── Runner ──────────────────────────────────────────────────────────── +int main(int argc, char** argv) { + UNITY_BEGIN(); + RUN_TEST(test_load_empty_directory); + RUN_TEST(test_load_creates_devices_dir_if_missing); + RUN_TEST(test_load_reads_one_device); + RUN_TEST(test_load_reads_multiple_devices); + RUN_TEST(test_save_creates_file); + RUN_TEST(test_roundtrip_save_load); + RUN_TEST(test_remove_deletes_file); + RUN_TEST(test_safe_filename_special_chars); + UNITY_END(); + return 0; +} \ No newline at end of file diff --git a/Firmware/AUTH_MQTT/MQTT_Gateway/test/test_utils/test_gateway_utils.cpp b/Firmware/AUTH_MQTT/MQTT_Gateway/test/test_utils/test_gateway_utils.cpp new file mode 100644 index 00000000..3b49546d --- /dev/null +++ b/Firmware/AUTH_MQTT/MQTT_Gateway/test/test_utils/test_gateway_utils.cpp @@ -0,0 +1,30 @@ +#include +#include "gateway_utils.h" + +void setUp(void) { +} + +void tearDown(void) { +} + +void test_hex_to_bytes() { + const char* hex = "25"; + uint8_t result = 0; + gw_hex_to_bytes(hex, &result,sizeof(hex)); + TEST_ASSERT_EQUAL_INT(0x25, result); +} +void test_hex_to_bytes_30() { + const char* hex = "30"; + uint8_t result = 0; + gw_hex_to_bytes(hex, &result,sizeof(hex)); + TEST_ASSERT_EQUAL_INT(0x30, result); +} + +int main(void) { + UNITY_BEGIN(); + //run test + RUN_TEST(test_hex_to_bytes); + RUN_TEST(test_hex_to_bytes_30); + UNITY_END(); + return 0; +} \ No newline at end of file