From ac13378bb66bdb0dee9a5b5e8b7b2506e636a3a0 Mon Sep 17 00:00:00 2001 From: Aruna Tennakoon Date: Mon, 31 Aug 2026 19:49:19 +0700 Subject: [PATCH 1/2] feat: local control --- CHANGELOG.md | 66 +++++ CMakeLists.txt | 26 +- Kconfig | 56 ++++ README.md | 25 +- examples/switch/sdkconfig.defaults | 4 + idf_component.yml | 6 +- include/sinricpro.h | 15 +- src/core/sinricpro_core.c | 422 ++++++++++++++++++++++------- src/core/sinricpro_mdns.c | 165 +++++++++++ src/core/sinricpro_mdns.h | 56 ++++ src/core/sinricpro_message_queue.c | 22 +- src/core/sinricpro_message_queue.h | 72 +++++ src/core/sinricpro_signature.c | 372 ++++++++++++++++++------- src/core/sinricpro_signature.h | 68 +++++ src/core/sinricpro_udp.c | 307 +++++++++++++++++++++ src/core/sinricpro_udp.h | 89 ++++++ test/host/run.sh | 40 +++ test/host/shims/esp_err.h | 15 + test/host/shims/esp_log.h | 10 + test/host/shims/mbedtls/base64.h | 10 + test/host/shims/mbedtls/md.h | 28 ++ test/host/shims/mbedtls/version.h | 8 + test/host/shims/shims.c | 124 +++++++++ test/host/test_signature.c | 251 +++++++++++++++++ 24 files changed, 2037 insertions(+), 220 deletions(-) create mode 100644 examples/switch/sdkconfig.defaults create mode 100644 src/core/sinricpro_mdns.c create mode 100644 src/core/sinricpro_mdns.h create mode 100644 src/core/sinricpro_udp.c create mode 100644 src/core/sinricpro_udp.h create mode 100644 test/host/run.sh create mode 100644 test/host/shims/esp_err.h create mode 100644 test/host/shims/esp_log.h create mode 100644 test/host/shims/mbedtls/base64.h create mode 100644 test/host/shims/mbedtls/md.h create mode 100644 test/host/shims/mbedtls/version.h create mode 100644 test/host/shims/shims.c create mode 100644 test/host/test_signature.c diff --git a/CHANGELOG.md b/CHANGELOG.md index c381a8e..b5550ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,71 @@ # Changelog +## [1.3.0] + +### Features + +- feat: local control. The device answers signed SinricPro commands over the LAN + (UDP 3333, multicast 224.9.9.9, unicast too), so it keeps responding to the app + while the cloud is unreachable. Requests are dispatched through the same + capability callbacks as cloud requests. +- feat: mDNS announcement of `_sinricpro._udp.local.` as `sinricpro-`, with + TXT records `deviceIds`, `sdk` and `udp=1`, refreshed when the device list changes. +- feat: Kconfig gates `SINRICPRO_ENABLE_LOCAL_CONTROL` (default on) and + `SINRICPRO_LOCAL_CONTROL_NO_MDNS` (UDP without the announcement). +- feat: `sinricpro_local_control_is_running()`. + +### Fixes + +- fix: an unreachable cloud no longer aborts `sinricpro_start()`. Only an invalid + configuration is fatal; a connect failure logs, leaves the reconnect armed and + keeps local control serving. Callers check `sinricpro_is_connected()`. +- fix: outgoing messages are signed over the exact bytes transmitted. The payload + is serialised once and spliced into the envelope instead of being serialised a + second time, and the signature is emitted last so a receiver can slice it out. +- fix: a message with no signature, or with a payload that could not be located, + is no longer processed as if it had verified. +- fix: a request that fails verification now gets a signed "Signature is invalid" + response instead of silence, so a client can tell a wrong app secret from an + unreachable device. +- fix: signatures are compared in constant time. +- fix: payload extraction no longer treats a brace inside a JSON string as the end + of the payload. +- fix: `sinricpro_core_send_event()` no longer leaks the caller's value object when + it returns early because the SDK is stopped or the cloud is down. + + +| | | +|---|---| +| Transport | UDP port 3333, multicast group 224.9.9.9, unicast to the device too | +| Envelope | Identical to the cloud format, HMAC-SHA256 over the payload, base64 | +| Discovery | mDNS `_sinricpro._udp.local.`, host `sinricpro-` | +| TXT records | `deviceIds=`, `sdk=`, `udp=1` | + +Check it is up with `sinricpro_local_control_is_running()`. It is independent of +`sinricpro_is_connected()`: a device that has never reached SinricPro still +answers the LAN. + +Verify from a desktop on the same network: + +```bash +avahi-browse -r _sinricpro._udp # Linux +dns-sd -B _sinricpro._udp # macOS / Windows +``` + +### Notes + +- The listener runs as its own FreeRTOS task (~6 KB stack by default). Device + callbacks execute on it, so size it for your own callbacks. +- The mDNS responder costs roughly 40 KB of flash. The default 2 MB single-app + partition layout has no room for it - the examples set + `CONFIG_PARTITION_TABLE_SINGLE_APP_LARGE=y`. +- A request that fails signature verification gets a signed + "Signature is invalid" reply rather than silence, so a client can tell a wrong + app secret from an unreachable device. +- Android clients need a `WifiManager.MulticastLock` or mDNS returns nothing; + iOS clients need the service type in `NSBonjourServices`. + + ## [1.2.1] ### Fixes diff --git a/CMakeLists.txt b/CMakeLists.txt index 87a5b21..a1fbab0 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,3 +1,19 @@ +# Requirements are resolved during early expansion, where sdkconfig values are +# already available; the mdns component is only pulled in when it is used. +set(SINRICPRO_REQUIRES + esp_websocket_client + mbedtls + esp_event + nvs_flash + esp_netif + esp_wifi + cjson +) + +if(CONFIG_SINRICPRO_ENABLE_LOCAL_CONTROL AND NOT CONFIG_SINRICPRO_LOCAL_CONTROL_NO_MDNS) + list(APPEND SINRICPRO_REQUIRES espressif__mdns) +endif() + idf_component_register( SRCS "src/core/sinricpro_core.c" @@ -5,6 +21,8 @@ idf_component_register( "src/core/sinricpro_signature.c" "src/core/sinricpro_message_queue.c" "src/core/sinricpro_event_limiter.c" + "src/core/sinricpro_udp.c" + "src/core/sinricpro_mdns.c" "src/devices/sinricpro_switch.c" "src/devices/sinricpro_motion_sensor.c" "src/devices/sinricpro_contact_sensor.c" @@ -47,11 +65,5 @@ idf_component_register( INCLUDE_DIRS "include" REQUIRES - esp_websocket_client - mbedtls - esp_event - nvs_flash - esp_netif - esp_wifi - cjson + ${SINRICPRO_REQUIRES} ) diff --git a/Kconfig b/Kconfig index 483a549..91fdc9d 100644 --- a/Kconfig +++ b/Kconfig @@ -104,4 +104,60 @@ menu "SinricPro Configuration" help Interval for sending heartbeat/ping messages to server. + config SINRICPRO_ENABLE_LOCAL_CONTROL + bool "Enable local control (LAN/UDP)" + default y + help + Answer signed SinricPro commands received over the LAN, so the + device keeps responding to the app while the cloud is unreachable. + + Listens on UDP SINRICPRO_UDP_PORT, joined to the SinricPro + multicast group and also answering unicast. Requests are dispatched + through the same capability callbacks as cloud requests. + + config SINRICPRO_LOCAL_CONTROL_NO_MDNS + bool "Disable the mDNS announcement" + default n + depends on SINRICPRO_ENABLE_LOCAL_CONTROL + help + Keep the UDP listener but do not publish _sinricpro._udp.local. + The device is then only reachable at the address the cloud reports + for it, and cannot be discovered on the LAN. + + Saves the flash and RAM cost of the mdns component. + + config SINRICPRO_UDP_PORT + int "Local control UDP port" + default 3333 + range 1 65535 + depends on SINRICPRO_ENABLE_LOCAL_CONTROL + help + Wire contract with the SinricPro app. Only change this if the app + has been configured to match. + + config SINRICPRO_UDP_MULTICAST_IP + string "Local control multicast group" + default "224.9.9.9" + depends on SINRICPRO_ENABLE_LOCAL_CONTROL + help + Wire contract with the SinricPro app. Only change this if the app + has been configured to match. + + config SINRICPRO_UDP_TASK_STACK_SIZE + int "Local control task stack size" + default 6144 + depends on SINRICPRO_ENABLE_LOCAL_CONTROL + help + Stack for the UDP listener task. Device callbacks run on this task, + on top of signature verification and JSON parsing, so lower it only + after measuring the high-water mark of your own callbacks. + + config SINRICPRO_UDP_TASK_PRIORITY + int "Local control task priority" + default 5 + range 1 24 + depends on SINRICPRO_ENABLE_LOCAL_CONTROL + help + Priority of the UDP listener task. + endmenu diff --git a/README.md b/README.md index d6bf32a..c5f45c0 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ Official ESP-IDF component for [SinricPro](https://sinric.pro) - Control your ES - ✅ **Voice Control** - Works with Alexa and Google Home - ✅ **Real-time** - WebSocket-based bidirectional communication +- ✅ **Local Control** - Answers the app over the LAN when the cloud is down - ✅ **Secure** - HMAC-SHA256 message signatures - ✅ **Reliable** - Auto-reconnection and heartbeat monitoring - ✅ **Event-driven** - ESP event loop integration @@ -53,6 +54,26 @@ All devices below have complete API support and working examples: - ✅ Power Sensor - Voltage, current, power monitoring - ✅ Window AC - Air conditioner with fan speed and temperature +## Local Control (LAN) + +The device answers signed SinricPro commands received over the LAN, so the app +keeps working when the cloud is unreachable. It is on by default and needs no +code changes: LAN requests are dispatched through the same capability callbacks +as cloud requests. + +### Configuration + +`idf.py menuconfig` → *Component config* → *SinricPro Configuration*: + +| Option | Default | Effect | +|---|---|---| +| `SINRICPRO_ENABLE_LOCAL_CONTROL` | on | Compile local control in | +| `SINRICPRO_LOCAL_CONTROL_NO_MDNS` | off | Keep UDP, drop the announcement (and the `mdns` dependency) | +| `SINRICPRO_UDP_PORT` | 3333 | Wire contract with the app | +| `SINRICPRO_UDP_MULTICAST_IP` | 224.9.9.9 | Wire contract with the app | +| `SINRICPRO_UDP_TASK_STACK_SIZE` | 6144 | Device callbacks run on this task | +| `SINRICPRO_UDP_TASK_PRIORITY` | 5 | | + ## Requirements - ESP-IDF v4.4 or higher. Tested on ESP-IDF 6.1 @@ -67,7 +88,7 @@ Add to your project's `idf_component.yml`: ```yaml dependencies: - sinricpro/esp-idf: "^1.2.1" + sinricpro/esp-idf: "^1.3.1" ``` ### Method 2: Manual Installation @@ -80,7 +101,7 @@ git clone https://github.com/sinricpro/esp-idf.git sinricpro Or ```bash -idf.py add-dependency "sinricpro/esp-idf^1.2.1" +idf.py add-dependency "sinricpro/esp-idf^1.3.1" ``` View at: https://components.espressif.com/components/sinricpro/esp-idf diff --git a/examples/switch/sdkconfig.defaults b/examples/switch/sdkconfig.defaults new file mode 100644 index 0000000..6dd41c9 --- /dev/null +++ b/examples/switch/sdkconfig.defaults @@ -0,0 +1,4 @@ +# The default 2 MB / single-app layout leaves no room once local control pulls +# in the mdns responder. +CONFIG_ESPTOOLPY_FLASHSIZE_4MB=y +CONFIG_PARTITION_TABLE_SINGLE_APP_LARGE=y diff --git a/idf_component.yml b/idf_component.yml index a68e31a..1e83a18 100644 --- a/idf_component.yml +++ b/idf_component.yml @@ -1,4 +1,4 @@ -version: "1.2.1" +version: "1.3.0" description: "SinricPro IoT platform integration for ESP-IDF - Control ESP32 devices with Alexa and Google Home" url: "https://github.com/sinricpro/esp-idf" documentation: "https://help.sinric.pro" @@ -12,6 +12,10 @@ dependencies: version: "^1.2.0" espressif/cjson: version: "*" + # Local control announcement. Always fetched; only linked when + # CONFIG_SINRICPRO_ENABLE_LOCAL_CONTROL is set and ..._NO_MDNS is not. + espressif/mdns: + version: "^1.8.0" tags: - iot diff --git a/include/sinricpro.h b/include/sinricpro.h index 2498fc1..bf5a2fb 100644 --- a/include/sinricpro.h +++ b/include/sinricpro.h @@ -22,7 +22,7 @@ extern "C" { /** * @brief SinricPro SDK version */ -#define SINRICPRO_VERSION "1.2.1" +#define SINRICPRO_VERSION "1.3.0" /** * @brief SinricPro event base @@ -120,6 +120,19 @@ esp_err_t sinricpro_deinit(void); */ bool sinricpro_is_connected(void); +/** + * @brief Check whether local control is serving LAN requests + * + * Independent of the cloud connection: a device that has never reached + * SinricPro still answers signed commands from the app on the LAN. + * + * @return true if the UDP listener is bound and joined to the multicast group, + * false if it is not, or if local control was configured out + * + * @note This function is thread-safe + */ +bool sinricpro_local_control_is_running(void); + /** * @brief Get current timestamp from server * diff --git a/src/core/sinricpro_core.c b/src/core/sinricpro_core.c index 865b910..8351451 100644 --- a/src/core/sinricpro_core.c +++ b/src/core/sinricpro_core.c @@ -11,6 +11,8 @@ #include "sinricpro_websocket.h" #include "sinricpro_signature.h" #include "sinricpro_message_queue.h" +#include "sinricpro_udp.h" +#include "sinricpro_mdns.h" #include #include #include "esp_log.h" @@ -37,6 +39,10 @@ static struct { bool initialized; bool started; SemaphoreHandle_t mutex; + /* Serialises request dispatch. The websocket task and the local control + * task both land in the same device callbacks, which were written for a + * single caller. */ + SemaphoreHandle_t dispatch_mutex; TaskHandle_t send_task; } core_state = {0}; @@ -45,6 +51,10 @@ static void handle_received_message(const char *data, size_t length, void *conte static void handle_connected(void *context); static void handle_disconnected(void *context); static void send_task_func(void *arg); +static void process_incoming(const char *data, size_t length, + const sinricpro_msg_origin_t *origin); +static size_t build_device_ids(char *out, size_t out_len, char separator); +static void announce_devices(void); /* ======================================================================== * Device Management @@ -85,6 +95,8 @@ esp_err_t sinricpro_core_register_device(sinricpro_device_t *device) ESP_LOGI(TAG, "Device registered: %s (total: %d)", device->device_id, core_state.device_count); + announce_devices(); + return ESP_OK; } @@ -114,6 +126,8 @@ esp_err_t sinricpro_core_unregister_device(const char *device_id) ESP_LOGI(TAG, "Device unregistered: %s (remaining: %d)", device_id, core_state.device_count); + announce_devices(); + return ESP_OK; } prev = curr; @@ -126,6 +140,52 @@ esp_err_t sinricpro_core_unregister_device(const char *device_id) return SINRICPRO_ERR_DEVICE_NOT_FOUND; } +/** + * @brief Join the registered device ids with @p separator + * + * @return Number of bytes written, excluding the terminator + */ +static size_t build_device_ids(char *out, size_t out_len, char separator) +{ + size_t offset = 0; + + out[0] = '\0'; + + xSemaphoreTake(core_state.mutex, portMAX_DELAY); + for (sinricpro_device_t *device = core_state.devices; + device != NULL && offset + 1 < out_len; + device = device->next) { + if (offset > 0) { + out[offset++] = separator; + out[offset] = '\0'; + } + int written = snprintf(out + offset, out_len - offset, "%s", device->device_id); + if (written < 0 || (size_t)written >= out_len - offset) { + break; + } + offset += (size_t)written; + } + xSemaphoreGive(core_state.mutex); + + return offset; +} + +/** + * @brief Refresh the mDNS TXT record after the device list changed + * + * No-op before start(): the record is published there with the full list. + */ +static void announce_devices(void) +{ + if (!core_state.started) { + return; + } + + char device_ids[512]; + build_device_ids(device_ids, sizeof(device_ids), ','); + sinricpro_mdns_update(device_ids); +} + static sinricpro_device_t* find_device(const char *device_id) { sinricpro_device_t *device = core_state.devices; @@ -144,7 +204,7 @@ static sinricpro_device_t* find_device(const char *device_id) * Message Processing * ======================================================================== */ -static void handle_request(cJSON *json_message) +static void handle_request(cJSON *json_message, const sinricpro_msg_origin_t *origin) { cJSON *payload = cJSON_GetObjectItem(json_message, "payload"); if (payload == NULL) { @@ -220,10 +280,83 @@ static void handle_request(cJSON *json_message) cJSON_AddBoolToObject(response_payload, "success", success); cJSON_AddStringToObject(response_payload, "message", success ? "OK" : "Device did not handle request"); - /* Send response */ + /* Queued with the origin it must go back on, so the send path can route it + * without knowing which peer happens to be talking to us now. */ char *response_str = cJSON_PrintUnformatted(response); if (response_str) { - sinricpro_message_queue_push(core_state.send_queue, response_str); + sinricpro_message_queue_push_with_origin(core_state.send_queue, + response_str, origin); + free(response_str); + } + + cJSON_Delete(response); +} + +/** + * @brief Answer a request whose signature did not verify + * + * Answering rather than dropping is deliberate: it lets a client tell a wrong + * app secret apart from an unreachable device. It does mean the device answers + * forged LAN packets. + */ +static void handle_invalid_signature(cJSON *json_message, + const sinricpro_msg_origin_t *origin) +{ + cJSON *payload = cJSON_GetObjectItem(json_message, "payload"); + if (payload == NULL) { + return; + } + + cJSON *response = cJSON_CreateObject(); + if (response == NULL) { + return; + } + + cJSON *header = cJSON_CreateObject(); + cJSON *response_payload = cJSON_CreateObject(); + + cJSON_AddItemToObject(response, "header", header); + cJSON_AddNumberToObject(header, "payloadVersion", 2); + cJSON_AddNumberToObject(header, "signatureVersion", 1); + + cJSON_AddItemToObject(response, "payload", response_payload); + + cJSON *action = cJSON_GetObjectItem(payload, "action"); + if (cJSON_IsString(action)) { + cJSON_AddStringToObject(response_payload, "action", action->valuestring); + } + + cJSON_AddNumberToObject(response_payload, "createdAt", core_state.timestamp); + + cJSON *device_id = cJSON_GetObjectItem(payload, "deviceId"); + if (cJSON_IsString(device_id)) { + cJSON_AddStringToObject(response_payload, "deviceId", device_id->valuestring); + } + + cJSON *reply_token = cJSON_GetObjectItem(payload, "replyToken"); + if (cJSON_IsString(reply_token)) { + cJSON_AddStringToObject(response_payload, "replyToken", reply_token->valuestring); + } + + cJSON *client_id = cJSON_GetObjectItem(payload, "clientId"); + if (cJSON_IsString(client_id)) { + cJSON_AddStringToObject(response_payload, "clientId", client_id->valuestring); + } + + cJSON *instance_id = cJSON_GetObjectItem(payload, "instanceId"); + if (cJSON_IsString(instance_id)) { + cJSON_AddStringToObject(response_payload, "instanceId", instance_id->valuestring); + } + + cJSON_AddStringToObject(response_payload, "type", "response"); + cJSON_AddItemToObject(response_payload, "value", cJSON_CreateObject()); + cJSON_AddBoolToObject(response_payload, "success", false); + cJSON_AddStringToObject(response_payload, "message", "Signature is invalid"); + + char *response_str = cJSON_PrintUnformatted(response); + if (response_str) { + sinricpro_message_queue_push_with_origin(core_state.send_queue, + response_str, origin); free(response_str); } @@ -239,48 +372,59 @@ static void handle_timestamp(cJSON *json_message) } } -static void handle_received_message(const char *data, size_t length, void *context) +/** + * @brief Verify and dispatch one received message, whatever transport it came on + * + * LAN requests land in the same capability callbacks as cloud requests; there + * is no second dispatch path. + */ +static void process_incoming(const char *data, size_t length, + const sinricpro_msg_origin_t *origin) { ESP_LOGD(TAG, "Received message (len=%zu): %.*s", length, (int)length, data); - /* Parse JSON */ cJSON *json = cJSON_Parse(data); if (json == NULL) { ESP_LOGE(TAG, "Failed to parse JSON"); return; } - /* Check for timestamp message */ + xSemaphoreTake(core_state.dispatch_mutex, portMAX_DELAY); + + /* Timestamp messages are unsigned by design. */ if (cJSON_HasObjectItem(json, "timestamp")) { handle_timestamp(json); + xSemaphoreGive(core_state.dispatch_mutex); cJSON_Delete(json); return; } - /* Extract and verify signature */ cJSON *signature_obj = cJSON_GetObjectItem(json, "signature"); - if (signature_obj) { - cJSON *hmac_item = cJSON_GetObjectItem(signature_obj, "HMAC"); - if (hmac_item && cJSON_IsString(hmac_item)) { - char payload_str[2048]; - esp_err_t ret = sinricpro_extract_payload(data, payload_str, sizeof(payload_str)); - if (ret == ESP_OK) { - ret = sinricpro_verify_signature(core_state.config.app_secret, - payload_str, - hmac_item->valuestring); - if (ret != ESP_OK) { - ESP_LOGW(TAG, "Signature verification failed"); - cJSON_Delete(json); - return; - } - } - } + cJSON *hmac_item = signature_obj ? cJSON_GetObjectItem(signature_obj, "HMAC") : NULL; + + /* Verified against the received bytes: the sender's key order and spacing + * are its own, so a re-serialised object would not match. */ + const char *payload_start = NULL; + size_t payload_len = 0; + esp_err_t ret = SINRICPRO_ERR_SIGNATURE; + + if (cJSON_IsString(hmac_item) && + sinricpro_extract_payload_ref(data, &payload_start, &payload_len) == ESP_OK) { + ret = sinricpro_verify_signature_n(core_state.config.app_secret, + payload_start, payload_len, + hmac_item->valuestring); + } + + if (ret != ESP_OK) { + ESP_LOGW(TAG, "Signature verification failed"); + handle_invalid_signature(json, origin); + xSemaphoreGive(core_state.dispatch_mutex); + cJSON_Delete(json); + return; } - /* Handle message based on type */ cJSON *payload = cJSON_GetObjectItem(json, "payload"); if (payload) { - /* Update timestamp from payload */ cJSON *created_at = cJSON_GetObjectItem(payload, "createdAt"); if (created_at && cJSON_IsNumber(created_at)) { core_state.timestamp = (uint32_t)created_at->valuedouble; @@ -291,16 +435,32 @@ static void handle_received_message(const char *data, size_t length, void *conte const char *type = type_item->valuestring; if (strcmp(type, "request") == 0) { - handle_request(json); + handle_request(json, origin); } else if (strcmp(type, "response") == 0) { ESP_LOGD(TAG, "Received response (ignored)"); } } } + xSemaphoreGive(core_state.dispatch_mutex); cJSON_Delete(json); } +static void handle_received_message(const char *data, size_t length, void *context) +{ + const sinricpro_msg_origin_t origin = SINRICPRO_ORIGIN_WEBSOCKET; + + process_incoming(data, length, &origin); +} + +#ifdef CONFIG_SINRICPRO_ENABLE_LOCAL_CONTROL +static void handle_udp_message(const char *data, size_t length, + const sinricpro_msg_origin_t *origin, void *context) +{ + process_incoming(data, length, origin); +} +#endif + /* ======================================================================== * Event Sending * ======================================================================== */ @@ -310,11 +470,16 @@ esp_err_t sinricpro_core_send_event(const char *device_id, const char *cause, cJSON *value) { + /* Ownership of `value` transfers here, so it must be released on the paths + * that never attach it to a message. */ if (!core_state.started) { + cJSON_Delete(value); return SINRICPRO_ERR_NOT_STARTED; } + /* Events are cloud-only; local control reports state to the app itself. */ if (!sinricpro_ws_is_connected()) { + cJSON_Delete(value); return SINRICPRO_ERR_NOT_CONNECTED; } @@ -370,49 +535,67 @@ static void send_task_func(void *arg) while (core_state.started) { /* Wait for message in queue */ char *message = NULL; - esp_err_t ret = sinricpro_message_queue_pop(core_state.send_queue, - &message, - pdMS_TO_TICKS(1000)); - - if (ret == ESP_OK && message != NULL) { - /* Parse and add timestamp and signature */ - cJSON *json = cJSON_Parse(message); - if (json) { - cJSON *payload = cJSON_GetObjectItem(json, "payload"); - if (payload) { - /* Update createdAt with current timestamp */ - cJSON_SetNumberValue(cJSON_GetObjectItem(payload, "createdAt"), - core_state.timestamp); - - /* Calculate signature */ - char *payload_str = cJSON_PrintUnformatted(payload); - if (payload_str) { - char signature[64]; - ret = sinricpro_calculate_signature(core_state.config.app_secret, - payload_str, - signature, - sizeof(signature)); - if (ret == ESP_OK) { - cJSON *sig_obj = cJSON_CreateObject(); - cJSON_AddStringToObject(sig_obj, "HMAC", signature); - cJSON_AddItemToObject(json, "signature", sig_obj); - } - free(payload_str); - } - - /* Send via WebSocket */ - char *signed_message = cJSON_PrintUnformatted(json); - if (signed_message) { - ESP_LOGD(TAG, "Sending: %s", signed_message); - sinricpro_ws_send(signed_message, 0); - free(signed_message); - } - } - cJSON_Delete(json); - } + sinricpro_msg_origin_t origin = SINRICPRO_ORIGIN_WEBSOCKET; + esp_err_t ret = sinricpro_message_queue_pop_with_origin(core_state.send_queue, + &message, &origin, + pdMS_TO_TICKS(1000)); + + if (ret != ESP_OK || message == NULL) { + continue; + } + /* Gate per message, never on cloud state as a whole: a device that has + * never reached the cloud must still answer the LAN. */ + if (origin.transport == SINRICPRO_TRANSPORT_WEBSOCKET && + !sinricpro_ws_is_connected()) { + ESP_LOGD(TAG, "Dropping websocket message - not connected"); sinricpro_message_queue_free_message(message); + continue; + } + + cJSON *json = cJSON_Parse(message); + sinricpro_message_queue_free_message(message); + + if (json == NULL) { + ESP_LOGE(TAG, "Failed to parse queued message"); + continue; + } + + cJSON *payload = cJSON_GetObjectItem(json, "payload"); + if (payload == NULL) { + cJSON_Delete(json); + continue; + } + + /* Last mutation before signing. Nothing may touch the payload after + * this point or the signature no longer covers what is transmitted. */ + cJSON_SetNumberValue(cJSON_GetObjectItem(payload, "createdAt"), + core_state.timestamp); + + char *signed_message = sinricpro_sign_message(core_state.config.app_secret, json); + cJSON_Delete(json); + + if (signed_message == NULL) { + ESP_LOGE(TAG, "Failed to sign message"); + continue; + } + + ESP_LOGD(TAG, "Sending: %s", signed_message); + + switch (origin.transport) { +#ifdef CONFIG_SINRICPRO_ENABLE_LOCAL_CONTROL + case SINRICPRO_TRANSPORT_UDP: + /* LAN responses are never echoed to the cloud websocket. */ + sinricpro_udp_send(signed_message, origin.peer_addr, origin.peer_port); + break; +#endif + case SINRICPRO_TRANSPORT_WEBSOCKET: + default: + sinricpro_ws_send(signed_message, 0); + break; } + + free(signed_message); } ESP_LOGI(TAG, "Send task stopped"); @@ -451,10 +634,19 @@ esp_err_t sinricpro_init(const sinricpro_config_t *config) return SINRICPRO_ERR_ALREADY_STARTED; } - /* Create mutex */ + /* Create mutexes */ core_state.mutex = xSemaphoreCreateMutex(); - if (core_state.mutex == NULL) { + core_state.dispatch_mutex = xSemaphoreCreateMutex(); + if (core_state.mutex == NULL || core_state.dispatch_mutex == NULL) { ESP_LOGE(TAG, "Failed to create mutex"); + if (core_state.mutex) { + vSemaphoreDelete(core_state.mutex); + core_state.mutex = NULL; + } + if (core_state.dispatch_mutex) { + vSemaphoreDelete(core_state.dispatch_mutex); + core_state.dispatch_mutex = NULL; + } return ESP_ERR_NO_MEM; } @@ -471,6 +663,9 @@ esp_err_t sinricpro_init(const sinricpro_config_t *config) if (core_state.send_queue == NULL) { ESP_LOGE(TAG, "Failed to create send queue"); vSemaphoreDelete(core_state.mutex); + core_state.mutex = NULL; + vSemaphoreDelete(core_state.dispatch_mutex); + core_state.dispatch_mutex = NULL; return ESP_ERR_NO_MEM; } @@ -497,27 +692,35 @@ esp_err_t sinricpro_start(void) return SINRICPRO_ERR_ALREADY_STARTED; } - /* Build device IDs string */ - char device_ids[512] = {0}; - size_t offset = 0; + /* The websocket takes ';' separated ids; the mDNS TXT record takes CSV. */ + char device_ids[512]; + build_device_ids(device_ids, sizeof(device_ids), ';'); - xSemaphoreTake(core_state.mutex, portMAX_DELAY); - sinricpro_device_t *device = core_state.devices; - while (device != NULL) { - if (offset > 0) { - offset += snprintf(device_ids + offset, sizeof(device_ids) - offset, ";"); - } - offset += snprintf(device_ids + offset, sizeof(device_ids) - offset, "%s", device->device_id); - device = device->next; - } - xSemaphoreGive(core_state.mutex); - - if (strlen(device_ids) == 0) { + if (device_ids[0] == '\0') { ESP_LOGW(TAG, "No devices registered"); } ESP_LOGI(TAG, "Device IDs: %s", device_ids); + /* Started before the cloud: local control must not depend on it. */ + core_state.started = true; + + BaseType_t task_ret = xTaskCreate(send_task_func, "sinricpro_send", + 4096, NULL, 5, &core_state.send_task); + if (task_ret != pdPASS) { + ESP_LOGE(TAG, "Failed to create send task"); + core_state.started = false; + return ESP_FAIL; + } + +#ifdef CONFIG_SINRICPRO_ENABLE_LOCAL_CONTROL + if (sinricpro_udp_start(handle_udp_message, NULL) == ESP_OK) { + char mdns_ids[512]; + build_device_ids(mdns_ids, sizeof(mdns_ids), ','); + sinricpro_mdns_start(mdns_ids); + } +#endif + /* Initialize WebSocket */ sinricpro_ws_callbacks_t ws_callbacks = { .on_receive = handle_received_message, @@ -531,28 +734,18 @@ esp_err_t sinricpro_start(void) core_state.config.app_key, device_ids, &ws_callbacks); + /* An unreachable cloud is not a startup failure: the reconnect is armed and + * local control is already serving. Callers check sinricpro_is_connected(). + * Only an invalid configuration is fatal. */ if (ret != ESP_OK) { - ESP_LOGE(TAG, "Failed to initialize WebSocket: %s", esp_err_to_name(ret)); - return ret; - } - - /* Start WebSocket */ - ret = sinricpro_ws_start(); - if (ret != ESP_OK) { - ESP_LOGE(TAG, "Failed to start WebSocket: %s", esp_err_to_name(ret)); - sinricpro_ws_deinit(); - return ret; - } - - /* Create send task */ - core_state.started = true; - BaseType_t task_ret = xTaskCreate(send_task_func, "sinricpro_send", - 4096, NULL, 5, &core_state.send_task); - if (task_ret != pdPASS) { - ESP_LOGE(TAG, "Failed to create send task"); - core_state.started = false; - sinricpro_ws_deinit(); - return ESP_FAIL; + ESP_LOGE(TAG, "Failed to initialize WebSocket: %s, continuing without the cloud", + esp_err_to_name(ret)); + } else { + ret = sinricpro_ws_start(); + if (ret != ESP_OK) { + ESP_LOGE(TAG, "Failed to start WebSocket: %s, continuing without the cloud", + esp_err_to_name(ret)); + } } ESP_LOGI(TAG, "SinricPro started"); @@ -580,6 +773,11 @@ esp_err_t sinricpro_stop(void) core_state.send_task = NULL; } +#ifdef CONFIG_SINRICPRO_ENABLE_LOCAL_CONTROL + sinricpro_mdns_stop(); + sinricpro_udp_stop(); +#endif + /* Stop WebSocket */ sinricpro_ws_stop(); sinricpro_ws_deinit(); @@ -604,12 +802,17 @@ esp_err_t sinricpro_deinit(void) core_state.send_queue = NULL; } - /* Delete mutex */ + /* Delete mutexes */ if (core_state.mutex) { vSemaphoreDelete(core_state.mutex); core_state.mutex = NULL; } + if (core_state.dispatch_mutex) { + vSemaphoreDelete(core_state.dispatch_mutex); + core_state.dispatch_mutex = NULL; + } + core_state.initialized = false; ESP_LOGI(TAG, "SinricPro deinitialized"); @@ -626,6 +829,15 @@ bool sinricpro_is_connected(void) return sinricpro_ws_is_connected(); } +bool sinricpro_local_control_is_running(void) +{ +#ifdef CONFIG_SINRICPRO_ENABLE_LOCAL_CONTROL + return sinricpro_udp_is_running(); +#else + return false; +#endif +} + uint32_t sinricpro_get_timestamp(void) { return core_state.timestamp; diff --git a/src/core/sinricpro_mdns.c b/src/core/sinricpro_mdns.c new file mode 100644 index 0000000..669c446 --- /dev/null +++ b/src/core/sinricpro_mdns.c @@ -0,0 +1,165 @@ +/* + * Copyright (c) 2019-2025 Sinric. All rights reserved. + * Licensed under Creative Commons Attribution-Share Alike (CC BY-SA) + * + * This file is part of the SinricPro ESP-IDF component + * (https://github.com/sinricpro/esp-idf) + */ + +#include "sinricpro_mdns.h" + +#if defined(CONFIG_SINRICPRO_ENABLE_LOCAL_CONTROL) && \ + !defined(CONFIG_SINRICPRO_LOCAL_CONTROL_NO_MDNS) + +#include "sinricpro.h" +#include +#include +#include +#include "esp_log.h" +#include "esp_mac.h" +#include "mdns.h" + +static const char *TAG = "sinricpro_mdns"; + +#define SINRICPRO_MDNS_SERVICE "_sinricpro" +#define SINRICPRO_MDNS_PROTO "_udp" + +static struct { + bool announced; + char *device_ids; + char host_name[32]; +} mdns_state = {0}; + +/** + * @brief Build the announced host name: sinricpro- + */ +static void sinricpro_mdns_build_host_name(void) +{ + uint8_t mac[6] = {0}; + + if (esp_read_mac(mac, ESP_MAC_WIFI_STA) != ESP_OK) { + ESP_LOGW(TAG, "Could not read station MAC, host name will not be unique"); + } + + snprintf(mdns_state.host_name, sizeof(mdns_state.host_name), + "sinricpro-%02x%02x%02x%02x%02x%02x", + mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]); +} + +esp_err_t sinricpro_mdns_start(const char *device_ids) +{ + if (mdns_state.announced) { + sinricpro_mdns_update(device_ids); + return ESP_OK; + } + + /* Idempotent: returns ESP_OK if the application already started mDNS. */ + esp_err_t ret = mdns_init(); + if (ret != ESP_OK) { + ESP_LOGE(TAG, "mdns_init failed: %s, device not discoverable on the LAN", + esp_err_to_name(ret)); + return ret; + } + + sinricpro_mdns_build_host_name(); + + ret = mdns_hostname_set(mdns_state.host_name); + if (ret != ESP_OK) { + ESP_LOGE(TAG, "mdns_hostname_set(%s) failed: %s", + mdns_state.host_name, esp_err_to_name(ret)); + return ret; + } + + free(mdns_state.device_ids); + mdns_state.device_ids = strdup(device_ids ? device_ids : ""); + if (mdns_state.device_ids == NULL) { + return ESP_ERR_NO_MEM; + } + + mdns_txt_item_t txt[] = { + {"deviceIds", mdns_state.device_ids}, + {"sdk", SINRICPRO_VERSION}, + {"udp", "1"}, + }; + + ret = mdns_service_add(mdns_state.host_name, SINRICPRO_MDNS_SERVICE, + SINRICPRO_MDNS_PROTO, CONFIG_SINRICPRO_UDP_PORT, + txt, sizeof(txt) / sizeof(txt[0])); + if (ret != ESP_OK) { + ESP_LOGE(TAG, "mdns_service_add failed: %s", esp_err_to_name(ret)); + free(mdns_state.device_ids); + mdns_state.device_ids = NULL; + return ret; + } + + mdns_state.announced = true; + + ESP_LOGI(TAG, "Announced %s.%s.local. as %s.local. port=%d deviceIds=%s", + SINRICPRO_MDNS_SERVICE, SINRICPRO_MDNS_PROTO, mdns_state.host_name, + CONFIG_SINRICPRO_UDP_PORT, mdns_state.device_ids); + + return ESP_OK; +} + +void sinricpro_mdns_update(const char *device_ids) +{ + const char *ids = device_ids ? device_ids : ""; + + if (!mdns_state.announced || + (mdns_state.device_ids && strcmp(mdns_state.device_ids, ids) == 0)) { + return; + } + + char *copy = strdup(ids); + if (copy == NULL) { + return; + } + + free(mdns_state.device_ids); + mdns_state.device_ids = copy; + + esp_err_t ret = mdns_service_txt_item_set(SINRICPRO_MDNS_SERVICE, + SINRICPRO_MDNS_PROTO, + "deviceIds", + mdns_state.device_ids); + if (ret != ESP_OK) { + ESP_LOGE(TAG, "mdns deviceIds update failed: %s", esp_err_to_name(ret)); + return; + } + + ESP_LOGI(TAG, "mDNS deviceIds updated: %s", mdns_state.device_ids); +} + +void sinricpro_mdns_stop(void) +{ + if (!mdns_state.announced) { + return; + } + + mdns_service_remove(SINRICPRO_MDNS_SERVICE, SINRICPRO_MDNS_PROTO); + mdns_state.announced = false; + + free(mdns_state.device_ids); + mdns_state.device_ids = NULL; + + ESP_LOGI(TAG, "mDNS announcement withdrawn"); +} + +#else /* local control or mDNS disabled */ + +esp_err_t sinricpro_mdns_start(const char *device_ids) +{ + (void)device_ids; + return ESP_OK; +} + +void sinricpro_mdns_update(const char *device_ids) +{ + (void)device_ids; +} + +void sinricpro_mdns_stop(void) +{ +} + +#endif diff --git a/src/core/sinricpro_mdns.h b/src/core/sinricpro_mdns.h new file mode 100644 index 0000000..7987b3d --- /dev/null +++ b/src/core/sinricpro_mdns.h @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2019-2025 Sinric. All rights reserved. + * Licensed under Creative Commons Attribution-Share Alike (CC BY-SA) + * + * This file is part of the SinricPro ESP-IDF component + * (https://github.com/sinricpro/esp-idf) + */ + +#ifndef SINRICPRO_MDNS_H +#define SINRICPRO_MDNS_H + +#include "esp_err.h" + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * @brief Announce this device as a local control endpoint + * + * Publishes _sinricpro._udp.local. on CONFIG_SINRICPRO_UDP_PORT with the host + * name sinricpro- and TXT records deviceIds, sdk and udp=1, so the app can + * find the device on the LAN without asking the cloud for its address. + * + * Compiled out when CONFIG_SINRICPRO_LOCAL_CONTROL_NO_MDNS is set; the UDP + * listener still works for a client that knows the device address. + * + * @param[in] device_ids Comma-separated device ids this board answers for + * + * @return ESP_OK if the service was published, an error otherwise + */ +esp_err_t sinricpro_mdns_start(const char *device_ids); + +/** + * @brief Re-announce, but only if the device list actually changed + * + * Cheap and idempotent; refreshed on a device list change rather than on a + * timer or on every reconnect. + * + * @param[in] device_ids Comma-separated device ids this board answers for + */ +void sinricpro_mdns_update(const char *device_ids); + +/** + * @brief Withdraw the service record + * + * The mDNS responder itself is left running: it is a shared, idempotently + * initialised service and the application may be advertising on it too. + */ +void sinricpro_mdns_stop(void); + +#ifdef __cplusplus +} +#endif + +#endif /* SINRICPRO_MDNS_H */ diff --git a/src/core/sinricpro_message_queue.c b/src/core/sinricpro_message_queue.c index b83c3a7..376580b 100644 --- a/src/core/sinricpro_message_queue.c +++ b/src/core/sinricpro_message_queue.c @@ -29,6 +29,7 @@ struct sinricpro_message_queue { typedef struct { char *message; size_t length; + sinricpro_msg_origin_t origin; } queue_message_t; sinricpro_message_queue_handle_t sinricpro_message_queue_create(size_t max_size) @@ -60,6 +61,13 @@ sinricpro_message_queue_handle_t sinricpro_message_queue_create(size_t max_size) esp_err_t sinricpro_message_queue_push(sinricpro_message_queue_handle_t handle, const char *message) +{ + return sinricpro_message_queue_push_with_origin(handle, message, NULL); +} + +esp_err_t sinricpro_message_queue_push_with_origin(sinricpro_message_queue_handle_t handle, + const char *message, + const sinricpro_msg_origin_t *origin) { if (handle == NULL || message == NULL) { return ESP_ERR_INVALID_ARG; @@ -83,7 +91,8 @@ esp_err_t sinricpro_message_queue_push(sinricpro_message_queue_handle_t handle, queue_message_t queue_msg = { .message = msg_copy, - .length = msg_len + .length = msg_len, + .origin = origin ? *origin : SINRICPRO_ORIGIN_WEBSOCKET }; /* Push to queue */ @@ -102,6 +111,14 @@ esp_err_t sinricpro_message_queue_push(sinricpro_message_queue_handle_t handle, esp_err_t sinricpro_message_queue_pop(sinricpro_message_queue_handle_t handle, char **message, TickType_t timeout) +{ + return sinricpro_message_queue_pop_with_origin(handle, message, NULL, timeout); +} + +esp_err_t sinricpro_message_queue_pop_with_origin(sinricpro_message_queue_handle_t handle, + char **message, + sinricpro_msg_origin_t *origin, + TickType_t timeout) { if (handle == NULL || message == NULL) { return ESP_ERR_INVALID_ARG; @@ -114,6 +131,9 @@ esp_err_t sinricpro_message_queue_pop(sinricpro_message_queue_handle_t handle, } *message = queue_msg.message; + if (origin != NULL) { + *origin = queue_msg.origin; + } ESP_LOGD(TAG, "Message popped from queue (len=%zu, queue_size=%d)", queue_msg.length, uxQueueMessagesWaiting(handle->queue)); diff --git a/src/core/sinricpro_message_queue.h b/src/core/sinricpro_message_queue.h index 1e06860..0e351be 100644 --- a/src/core/sinricpro_message_queue.h +++ b/src/core/sinricpro_message_queue.h @@ -14,11 +14,42 @@ #include "freertos/FreeRTOS.h" #include #include +#include #ifdef __cplusplus extern "C" { #endif +/** + * @brief Transport a message arrived on, and must be answered over + */ +typedef enum { + SINRICPRO_TRANSPORT_WEBSOCKET = 0, /**< SinricPro cloud websocket */ + SINRICPRO_TRANSPORT_UDP = 1, /**< Local control over UDP */ +} sinricpro_transport_t; + +/** + * @brief Where a message came from, and where its response must go + * + * Carried per message rather than held in one slot on the listener: a response + * can leave several queue iterations after the request arrived, by which time + * another peer may have sent a packet. + * + * Held by value in the queue entry, so it adds no allocation to free. + */ +typedef struct { + sinricpro_transport_t transport; /**< Transport the message belongs to */ + uint32_t peer_addr; /**< Peer IPv4, network order (UDP only) */ + uint16_t peer_port; /**< Peer port, 0 when there is no peer */ +} sinricpro_msg_origin_t; + +/** + * @brief Origin of a message that belongs to the cloud websocket + */ +#define SINRICPRO_ORIGIN_WEBSOCKET \ + ((sinricpro_msg_origin_t){ .transport = SINRICPRO_TRANSPORT_WEBSOCKET, \ + .peer_addr = 0, .peer_port = 0 }) + /** * @brief Message queue handle (opaque) */ @@ -50,6 +81,25 @@ sinricpro_message_queue_handle_t sinricpro_message_queue_create(size_t max_size) esp_err_t sinricpro_message_queue_push(sinricpro_message_queue_handle_t handle, const char *message); +/** + * @brief Push a message together with the origin it must be answered over + * + * The message string is copied internally; the origin is stored by value. + * + * @param[in] handle Queue handle + * @param[in] message Message string to push + * @param[in] origin Transport and peer, NULL for the cloud websocket + * + * @return + * - ESP_OK: Success + * - ESP_ERR_INVALID_ARG: Invalid arguments + * - SINRICPRO_ERR_QUEUE_FULL: Queue is full + * - SINRICPRO_ERR_NO_MEMORY: Out of memory + */ +esp_err_t sinricpro_message_queue_push_with_origin(sinricpro_message_queue_handle_t handle, + const char *message, + const sinricpro_msg_origin_t *origin); + /** * @brief Pop a message from the queue * @@ -69,6 +119,28 @@ esp_err_t sinricpro_message_queue_pop(sinricpro_message_queue_handle_t handle, char **message, TickType_t timeout); +/** + * @brief Pop a message and the origin it must be answered over + * + * The caller is responsible for freeing the returned message using + * sinricpro_message_queue_free_message(), on every path including the ones + * that discard it. + * + * @param[in] handle Queue handle + * @param[out] message Pointer to receive message string + * @param[out] origin Receives the message origin (may be NULL) + * @param[in] timeout Timeout in FreeRTOS ticks (portMAX_DELAY = wait forever) + * + * @return + * - ESP_OK: Success + * - ESP_ERR_INVALID_ARG: Invalid arguments + * - ESP_ERR_TIMEOUT: Timeout waiting for message + */ +esp_err_t sinricpro_message_queue_pop_with_origin(sinricpro_message_queue_handle_t handle, + char **message, + sinricpro_msg_origin_t *origin, + TickType_t timeout); + /** * @brief Free a message returned by sinricpro_message_queue_pop() * diff --git a/src/core/sinricpro_signature.c b/src/core/sinricpro_signature.c index 49a1f38..0644130 100644 --- a/src/core/sinricpro_signature.c +++ b/src/core/sinricpro_signature.c @@ -7,6 +7,7 @@ */ #include "sinricpro_signature.h" +#include #include /* mbedTLS 2.x exposes the version/config in version.h; 3.x+ in build_info.h */ @@ -51,13 +52,15 @@ static const char *TAG = "sinricpro_signature"; * @brief Compute HMAC-SHA256 of payload using secret as the key * * @param[in] secret Secret key (NUL-terminated) - * @param[in] payload Data to sign (NUL-terminated) + * @param[in] payload Data to sign + * @param[in] payload_len Number of bytes to sign * @param[out] hmac_result Output buffer, must hold 32 bytes * * @return ESP_OK on success, ESP_FAIL on failure */ static esp_err_t sinricpro_hmac_sha256(const char *secret, const char *payload, + size_t payload_len, unsigned char hmac_result[32]) { #if defined(SINRICPRO_HMAC_BACKEND_PSA) @@ -83,7 +86,7 @@ static esp_err_t sinricpro_hmac_sha256(const char *secret, size_t mac_len = 0; status = psa_mac_compute(key_id, PSA_ALG_HMAC(PSA_ALG_SHA_256), - (const uint8_t *)payload, strlen(payload), + (const uint8_t *)payload, payload_len, hmac_result, 32, &mac_len); psa_destroy_key(key_id); if (status != PSA_SUCCESS || mac_len != 32) { @@ -113,7 +116,7 @@ static esp_err_t sinricpro_hmac_sha256(const char *secret, return ESP_FAIL; } - ret = mbedtls_md_hmac_update(&ctx, (const unsigned char *)payload, strlen(payload)); + ret = mbedtls_md_hmac_update(&ctx, (const unsigned char *)payload, payload_len); if (ret != 0) { ESP_LOGE(TAG, "mbedtls_md_hmac_update failed: %d", ret); mbedtls_md_free(&ctx); @@ -159,7 +162,7 @@ static esp_err_t sinricpro_hmac_sha256(const char *secret, ret = mbedtls_sha256_update(&ctx, pad, sizeof(pad)); } if (ret == 0) { - ret = mbedtls_sha256_update(&ctx, (const unsigned char *)payload, strlen(payload)); + ret = mbedtls_sha256_update(&ctx, (const unsigned char *)payload, payload_len); } if (ret == 0) { ret = mbedtls_sha256_finish(&ctx, inner_hash); @@ -193,22 +196,33 @@ static esp_err_t sinricpro_hmac_sha256(const char *secret, } /** - * @brief Calculate HMAC-SHA256 signature and encode as base64 + * @brief Constant-time comparison of two NUL-terminated strings * - * @param[in] secret Secret key for HMAC - * @param[in] payload Payload string to sign - * @param[out] signature Output buffer for base64-encoded signature - * @param[in] sig_len Size of signature buffer (must be >= 45 bytes) - * - * @return - * - ESP_OK: Success - * - ESP_ERR_INVALID_ARG: Invalid arguments - * - ESP_FAIL: HMAC or base64 encoding failed + * Comparing signatures with strcmp() leaks how many leading bytes matched, + * which is enough to narrow a forgery byte by byte. */ -esp_err_t sinricpro_calculate_signature(const char *secret, - const char *payload, - char *signature, - size_t sig_len) +static bool sinricpro_const_time_equal(const char *a, const char *b) +{ + size_t len_a = strlen(a); + size_t len_b = strlen(b); + + if (len_a != len_b) { + return false; + } + + unsigned char diff = 0; + for (size_t i = 0; i < len_a; i++) { + diff |= (unsigned char)a[i] ^ (unsigned char)b[i]; + } + + return diff == 0; +} + +esp_err_t sinricpro_calculate_signature_n(const char *secret, + const char *payload, + size_t payload_len, + char *signature, + size_t sig_len) { if (secret == NULL || payload == NULL || signature == NULL || sig_len < 45) { ESP_LOGE(TAG, "Invalid arguments"); @@ -218,13 +232,11 @@ esp_err_t sinricpro_calculate_signature(const char *secret, unsigned char hmac_result[32]; /* SHA256 produces 32 bytes */ size_t olen = 0; - /* Calculate HMAC-SHA256 */ - esp_err_t err = sinricpro_hmac_sha256(secret, payload, hmac_result); + esp_err_t err = sinricpro_hmac_sha256(secret, payload, payload_len, hmac_result); if (err != ESP_OK) { return err; } - /* Encode to base64 */ int ret = mbedtls_base64_encode((unsigned char *)signature, sig_len, &olen, hmac_result, sizeof(hmac_result)); if (ret != 0) { @@ -232,29 +244,29 @@ esp_err_t sinricpro_calculate_signature(const char *secret, return ESP_FAIL; } - signature[olen] = '\0'; /* Null-terminate */ - - ESP_LOGD(TAG, "Signature calculated: %s", signature); + signature[olen] = '\0'; return ESP_OK; } -/** - * @brief Verify HMAC-SHA256 signature - * - * @param[in] secret Secret key for HMAC - * @param[in] payload Payload string that was signed - * @param[in] received_signature Base64-encoded signature to verify - * - * @return - * - ESP_OK: Signature is valid - * - ESP_ERR_INVALID_ARG: Invalid arguments - * - SINRICPRO_ERR_SIGNATURE: Signature is invalid - * - ESP_FAIL: Calculation failed - */ -esp_err_t sinricpro_verify_signature(const char *secret, - const char *payload, - const char *received_signature) +esp_err_t sinricpro_calculate_signature(const char *secret, + const char *payload, + char *signature, + size_t sig_len) +{ + if (payload == NULL) { + ESP_LOGE(TAG, "Invalid arguments"); + return ESP_ERR_INVALID_ARG; + } + + return sinricpro_calculate_signature_n(secret, payload, strlen(payload), + signature, sig_len); +} + +esp_err_t sinricpro_verify_signature_n(const char *secret, + const char *payload, + size_t payload_len, + const char *received_signature) { if (secret == NULL || payload == NULL || received_signature == NULL) { ESP_LOGE(TAG, "Invalid arguments"); @@ -263,100 +275,254 @@ esp_err_t sinricpro_verify_signature(const char *secret, char calculated_signature[64]; - esp_err_t ret = sinricpro_calculate_signature(secret, payload, - calculated_signature, - sizeof(calculated_signature)); + esp_err_t ret = sinricpro_calculate_signature_n(secret, payload, payload_len, + calculated_signature, + sizeof(calculated_signature)); if (ret != ESP_OK) { ESP_LOGE(TAG, "Failed to calculate signature"); return ret; } - /* Compare signatures */ - if (strcmp(calculated_signature, received_signature) == 0) { + if (sinricpro_const_time_equal(calculated_signature, received_signature)) { ESP_LOGD(TAG, "Signature verification passed"); return ESP_OK; - } else { - ESP_LOGW(TAG, "Signature verification failed"); - ESP_LOGW(TAG, "Expected: %s", calculated_signature); - ESP_LOGW(TAG, "Received: %s", received_signature); - return SINRICPRO_ERR_SIGNATURE; } + + ESP_LOGW(TAG, "Signature verification failed"); + return SINRICPRO_ERR_SIGNATURE; } -/** - * @brief Extract payload string from JSON message - * - * Extracts the "payload" field from a JSON message string for signature - * calculation/verification. - * - * @param[in] json_message Complete JSON message string - * @param[out] payload Output buffer for extracted payload - * @param[in] payload_len Size of payload buffer - * - * @return - * - ESP_OK: Success - * - ESP_ERR_INVALID_ARG: Invalid arguments - * - ESP_FAIL: Failed to extract payload - */ -esp_err_t sinricpro_extract_payload(const char *json_message, - char *payload, - size_t payload_len) +esp_err_t sinricpro_verify_signature(const char *secret, + const char *payload, + const char *received_signature) { - if (json_message == NULL || payload == NULL || payload_len == 0) { + if (payload == NULL) { ESP_LOGE(TAG, "Invalid arguments"); return ESP_ERR_INVALID_ARG; } - /* Find "payload" field in JSON */ - const char *payload_start = strstr(json_message, "\"payload\":"); - if (payload_start == NULL) { - ESP_LOGE(TAG, "\"payload\" field not found in JSON"); - return ESP_FAIL; - } + return sinricpro_verify_signature_n(secret, payload, strlen(payload), + received_signature); +} - /* Skip to the start of the payload object */ - payload_start = strchr(payload_start, '{'); - if (payload_start == NULL) { - ESP_LOGE(TAG, "Payload object not found"); - return ESP_FAIL; +esp_err_t sinricpro_extract_payload_ref(const char *json_message, + const char **payload, + size_t *payload_len) +{ + if (json_message == NULL || payload == NULL || payload_len == NULL) { + ESP_LOGE(TAG, "Invalid arguments"); + return ESP_ERR_INVALID_ARG; } - /* Find the end of the payload object by matching braces */ - int brace_count = 0; - const char *p = payload_start; - const char *payload_end = NULL; + static const char kPayloadMarker[] = "\"payload\":"; + static const char kSignatureMarker[] = ",\"signature\""; - while (*p != '\0') { - if (*p == '{') { - brace_count++; - } else if (*p == '}') { - brace_count--; - if (brace_count == 0) { - payload_end = p + 1; /* Include closing brace */ - break; + const char *start = strstr(json_message, kPayloadMarker); + if (start == NULL) { + ESP_LOGE(TAG, "\"payload\" field not found in JSON"); + return ESP_FAIL; + } + start += sizeof(kPayloadMarker) - 1; + + /* Wire contract: the signature always follows the payload, so the payload + * is exactly the bytes between the two markers. */ + const char *end = strstr(start, kSignatureMarker); + + if (end == NULL) { + /* No signature member, or it precedes the payload: fall back to + * matching the payload object's own braces. */ + const char *p = strchr(start, '{'); + if (p == NULL) { + ESP_LOGE(TAG, "Payload object not found"); + return ESP_FAIL; + } + start = p; + + int depth = 0; + bool in_string = false; + bool escaped = false; + + for (; *p != '\0'; p++) { + if (in_string) { + if (escaped) { + escaped = false; + } else if (*p == '\\') { + escaped = true; + } else if (*p == '"') { + in_string = false; + } + continue; } + if (*p == '"') { + in_string = true; + } else if (*p == '{') { + depth++; + } else if (*p == '}') { + if (--depth == 0) { + end = p + 1; + break; + } + } + } + + if (end == NULL) { + ESP_LOGE(TAG, "Payload object end not found"); + return ESP_FAIL; } - p++; } - if (payload_end == NULL) { - ESP_LOGE(TAG, "Payload object end not found"); + if (end <= start) { + ESP_LOGE(TAG, "Empty payload"); return ESP_FAIL; } - size_t payload_size = payload_end - payload_start; + *payload = start; + *payload_len = (size_t)(end - start); + + return ESP_OK; +} + +esp_err_t sinricpro_extract_payload(const char *json_message, + char *payload, + size_t payload_len) +{ + if (payload == NULL || payload_len == 0) { + ESP_LOGE(TAG, "Invalid arguments"); + return ESP_ERR_INVALID_ARG; + } + + const char *start = NULL; + size_t len = 0; + + esp_err_t ret = sinricpro_extract_payload_ref(json_message, &start, &len); + if (ret != ESP_OK) { + return ret; + } - if (payload_size >= payload_len) { + if (len >= payload_len) { ESP_LOGE(TAG, "Payload buffer too small (need %zu, have %zu)", - payload_size + 1, payload_len); + len + 1, payload_len); return ESP_ERR_INVALID_SIZE; } - /* Copy payload to output buffer */ - memcpy(payload, payload_start, payload_size); - payload[payload_size] = '\0'; - - ESP_LOGD(TAG, "Extracted payload: %s", payload); + memcpy(payload, start, len); + payload[len] = '\0'; return ESP_OK; } + +/** + * @brief Append bytes to a growable buffer + * + * On allocation failure the buffer is freed and *buf set to NULL, so a chain + * of appends can be written without a check between every step. + */ +static bool sinricpro_str_append(char **buf, size_t *used, size_t *cap, + const char *data, size_t len) +{ + if (*buf == NULL) { + return false; + } + + if (*used + len + 1 > *cap) { + size_t new_cap = (*cap == 0) ? 256 : *cap; + while (new_cap < *used + len + 1) { + new_cap *= 2; + } + char *grown = realloc(*buf, new_cap); + if (grown == NULL) { + free(*buf); + *buf = NULL; + return false; + } + *buf = grown; + *cap = new_cap; + } + + memcpy(*buf + *used, data, len); + *used += len; + (*buf)[*used] = '\0'; + + return true; +} + +char *sinricpro_sign_message(const char *secret, cJSON *json) +{ + if (secret == NULL || json == NULL) { + return NULL; + } + + cJSON *payload = cJSON_GetObjectItem(json, "payload"); + if (payload == NULL) { + ESP_LOGE(TAG, "Message has no payload to sign"); + return NULL; + } + + /* Serialised once. Everything below splices this exact string, so nothing + * can enter the payload between signing and transmission. */ + char *payload_str = cJSON_PrintUnformatted(payload); + if (payload_str == NULL) { + return NULL; + } + + char signature[64]; + if (sinricpro_calculate_signature(secret, payload_str, signature, + sizeof(signature)) != ESP_OK) { + free(payload_str); + return NULL; + } + + size_t cap = 256; + size_t used = 0; + char *out = malloc(cap); + if (out == NULL) { + free(payload_str); + return NULL; + } + out[0] = '\0'; + + bool ok = sinricpro_str_append(&out, &used, &cap, "{", 1); + + /* Members other than payload/signature first, in their existing order. + * Keys are SDK-generated identifiers, so they need no escaping. */ + for (cJSON *child = json->child; ok && child != NULL; child = child->next) { + if (child->string == NULL || + strcmp(child->string, "payload") == 0 || + strcmp(child->string, "signature") == 0) { + continue; + } + + char *value = cJSON_PrintUnformatted(child); + if (value == NULL) { + ok = false; + break; + } + + ok = sinricpro_str_append(&out, &used, &cap, "\"", 1) && + sinricpro_str_append(&out, &used, &cap, child->string, + strlen(child->string)) && + sinricpro_str_append(&out, &used, &cap, "\":", 2) && + sinricpro_str_append(&out, &used, &cap, value, strlen(value)) && + sinricpro_str_append(&out, &used, &cap, ",", 1); + + free(value); + } + + /* Payload, then signature last: a receiver locates the payload by slicing + * between "payload": and ,"signature". */ + ok = ok && + sinricpro_str_append(&out, &used, &cap, "\"payload\":", 10) && + sinricpro_str_append(&out, &used, &cap, payload_str, strlen(payload_str)) && + sinricpro_str_append(&out, &used, &cap, ",\"signature\":{\"HMAC\":\"", 22) && + sinricpro_str_append(&out, &used, &cap, signature, strlen(signature)) && + sinricpro_str_append(&out, &used, &cap, "\"}}", 3); + + free(payload_str); + + if (!ok) { + free(out); + return NULL; + } + + return out; +} diff --git a/src/core/sinricpro_signature.h b/src/core/sinricpro_signature.h index 88af91e..a0af2b8 100644 --- a/src/core/sinricpro_signature.h +++ b/src/core/sinricpro_signature.h @@ -11,6 +11,7 @@ #include "esp_err.h" #include "sinricpro_types.h" +#include "cJSON.h" #include #ifdef __cplusplus @@ -68,6 +69,73 @@ esp_err_t sinricpro_extract_payload(const char *json_message, char *payload, size_t payload_len); +/** + * @brief Calculate a signature over an explicit byte range + * + * Same as sinricpro_calculate_signature() but the payload need not be + * NUL-terminated, so a slice of a received buffer can be signed in place. + * + * @param[in] secret Secret key for HMAC + * @param[in] payload Start of the payload bytes + * @param[in] payload_len Number of payload bytes + * @param[out] signature Output buffer for base64-encoded signature + * @param[in] sig_len Size of signature buffer (must be >= 45 bytes) + * + * @return ESP_OK, ESP_ERR_INVALID_ARG or ESP_FAIL + */ +esp_err_t sinricpro_calculate_signature_n(const char *secret, + const char *payload, + size_t payload_len, + char *signature, + size_t sig_len); + +/** + * @brief Verify a signature over an explicit byte range, in constant time + * + * @param[in] secret Secret key for HMAC + * @param[in] payload Start of the payload bytes as received + * @param[in] payload_len Number of payload bytes + * @param[in] received_signature Base64-encoded signature to verify + * + * @return ESP_OK, ESP_ERR_INVALID_ARG, SINRICPRO_ERR_SIGNATURE or ESP_FAIL + */ +esp_err_t sinricpro_verify_signature_n(const char *secret, + const char *payload, + size_t payload_len, + const char *received_signature); + +/** + * @brief Locate the payload inside a received message without copying it + * + * Points into @p json_message; nothing is allocated. The slice is taken from + * the bytes as received - re-serialising a parsed object would assume the + * sender's key order and spacing, which are its own. + * + * @param[in] json_message Complete JSON message string as received + * @param[out] payload Receives a pointer into @p json_message + * @param[out] payload_len Receives the payload length in bytes + * + * @return ESP_OK or ESP_FAIL if the payload could not be located + */ +esp_err_t sinricpro_extract_payload_ref(const char *json_message, + const char **payload, + size_t *payload_len); + +/** + * @brief Sign a message and return the exact bytes to transmit + * + * The payload is serialised once and that string is spliced into the envelope, + * so the bytes on the wire are the bytes that were signed. The signature is + * emitted last, which is what lets a receiver find the payload by slicing + * between "payload": and ,"signature". + * + * @param[in] secret Secret key for HMAC + * @param[in] json Message object containing at least a "payload" member + * + * @return Serialised signed message (caller frees), or NULL on failure + */ +char *sinricpro_sign_message(const char *secret, cJSON *json); + #ifdef __cplusplus } #endif diff --git a/src/core/sinricpro_udp.c b/src/core/sinricpro_udp.c new file mode 100644 index 0000000..9e94eb0 --- /dev/null +++ b/src/core/sinricpro_udp.c @@ -0,0 +1,307 @@ +/* + * Copyright (c) 2019-2025 Sinric. All rights reserved. + * Licensed under Creative Commons Attribution-Share Alike (CC BY-SA) + * + * This file is part of the SinricPro ESP-IDF component + * (https://github.com/sinricpro/esp-idf) + */ + +#include "sinricpro_udp.h" + +#ifdef CONFIG_SINRICPRO_ENABLE_LOCAL_CONTROL + +#include +#include +#include +#include "esp_log.h" +#include "esp_netif.h" +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" +#include "freertos/semphr.h" +#include "lwip/sockets.h" +#include "lwip/inet.h" + +static const char *TAG = "sinricpro_udp"; + +/* A request is a signed JSON envelope; the app's largest is well under 1 KB. */ +#define SINRICPRO_UDP_RX_BUFFER_SIZE 1600 + +/* Blocking recvfrom() timeout, so stop() is acted on promptly. */ +#define SINRICPRO_UDP_RECV_TIMEOUT_MS 1000 + +/* Retry interval for binding / joining while the interface has no address. */ +#define SINRICPRO_UDP_RETRY_MS 5000 + +static struct { + int sock; + bool running; + volatile bool stop_requested; + TaskHandle_t task; + SemaphoreHandle_t sock_mutex; /* serialises sendto against close() */ + sinricpro_udp_rx_cb_t on_receive; + void *context; +} udp_state = { + .sock = -1, +}; + +/** + * @brief IPv4 address of the station interface, or INADDR_ANY if it has none + * + * The join is bound to the interface that actually carries the LAN; falling + * back to INADDR_ANY lets lwIP pick the default netif rather than failing. + */ +static uint32_t sinricpro_udp_interface_addr(void) +{ + esp_netif_t *netif = esp_netif_get_handle_from_ifkey("WIFI_STA_DEF"); + esp_netif_ip_info_t ip_info; + + if (netif != NULL && esp_netif_get_ip_info(netif, &ip_info) == ESP_OK) { + return ip_info.ip.addr; + } + + return htonl(INADDR_ANY); +} + +/** + * @brief Bind the socket and join the multicast group + * + * @return The socket fd, or -1 if local control could not be brought up + */ +static int sinricpro_udp_open(void) +{ + int sock = socket(AF_INET, SOCK_DGRAM, IPPROTO_IP); + if (sock < 0) { + ESP_LOGE(TAG, "socket() failed: errno %d", errno); + return -1; + } + + int yes = 1; + if (setsockopt(sock, SOL_SOCKET, SO_REUSEADDR, &yes, sizeof(yes)) < 0) { + ESP_LOGW(TAG, "SO_REUSEADDR failed: errno %d", errno); + } + + struct timeval tv = { + .tv_sec = SINRICPRO_UDP_RECV_TIMEOUT_MS / 1000, + .tv_usec = (SINRICPRO_UDP_RECV_TIMEOUT_MS % 1000) * 1000, + }; + setsockopt(sock, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv)); + + /* Bound to INADDR_ANY, not to the group: unicast to this host on the same + * port must be received too. */ + struct sockaddr_in addr = { + .sin_family = AF_INET, + .sin_addr.s_addr = htonl(INADDR_ANY), + .sin_port = htons(CONFIG_SINRICPRO_UDP_PORT), + }; + + if (bind(sock, (struct sockaddr *)&addr, sizeof(addr)) < 0) { + ESP_LOGE(TAG, "bind() to port %d failed: errno %d, local control unavailable", + CONFIG_SINRICPRO_UDP_PORT, errno); + close(sock); + return -1; + } + + struct ip_mreq mreq = { + .imr_multiaddr.s_addr = inet_addr(CONFIG_SINRICPRO_UDP_MULTICAST_IP), + .imr_interface.s_addr = sinricpro_udp_interface_addr(), + }; + + /* A failed join leaves nothing answering the group and says nothing unless + * the return value is checked. Log the outcome either way. */ + if (setsockopt(sock, IPPROTO_IP, IP_ADD_MEMBERSHIP, &mreq, sizeof(mreq)) < 0) { + ESP_LOGE(TAG, "IP_ADD_MEMBERSHIP %s failed: errno %d, local control unavailable", + CONFIG_SINRICPRO_UDP_MULTICAST_IP, errno); + close(sock); + return -1; + } + + esp_ip4_addr_t iface = { .addr = mreq.imr_interface.s_addr }; + ESP_LOGI(TAG, "Local control listening on UDP %d, joined %s on " IPSTR, + CONFIG_SINRICPRO_UDP_PORT, CONFIG_SINRICPRO_UDP_MULTICAST_IP, + IP2STR(&iface)); + + return sock; +} + +static void sinricpro_udp_close(void) +{ + xSemaphoreTake(udp_state.sock_mutex, portMAX_DELAY); + if (udp_state.sock >= 0) { + close(udp_state.sock); + udp_state.sock = -1; + } + udp_state.running = false; + xSemaphoreGive(udp_state.sock_mutex); +} + +static void sinricpro_udp_task(void *arg) +{ + char *buffer = malloc(SINRICPRO_UDP_RX_BUFFER_SIZE); + if (buffer == NULL) { + ESP_LOGE(TAG, "Failed to allocate receive buffer, local control unavailable"); + udp_state.task = NULL; + vTaskDelete(NULL); + return; + } + + while (!udp_state.stop_requested) { + if (udp_state.sock < 0) { + int sock = sinricpro_udp_open(); + if (sock < 0) { + vTaskDelay(pdMS_TO_TICKS(SINRICPRO_UDP_RETRY_MS)); + continue; + } + xSemaphoreTake(udp_state.sock_mutex, portMAX_DELAY); + udp_state.sock = sock; + udp_state.running = true; + xSemaphoreGive(udp_state.sock_mutex); + } + + struct sockaddr_in peer; + socklen_t peer_len = sizeof(peer); + int len = recvfrom(udp_state.sock, buffer, SINRICPRO_UDP_RX_BUFFER_SIZE - 1, 0, + (struct sockaddr *)&peer, &peer_len); + + if (len < 0) { + if (errno == EAGAIN || errno == EWOULDBLOCK || errno == EINTR) { + continue; /* recv timeout, loop so stop_requested is re-checked */ + } + ESP_LOGW(TAG, "recvfrom() failed: errno %d, reopening socket", errno); + sinricpro_udp_close(); + vTaskDelay(pdMS_TO_TICKS(SINRICPRO_UDP_RETRY_MS)); + continue; + } + + if (len == 0) { + continue; + } + + buffer[len] = '\0'; + + sinricpro_msg_origin_t origin = { + .transport = SINRICPRO_TRANSPORT_UDP, + .peer_addr = peer.sin_addr.s_addr, + .peer_port = ntohs(peer.sin_port), + }; + + esp_ip4_addr_t peer_ip = { .addr = origin.peer_addr }; + ESP_LOGD(TAG, "Request from " IPSTR ":%u (%d bytes): %s", + IP2STR(&peer_ip), origin.peer_port, len, buffer); + + if (udp_state.on_receive) { + udp_state.on_receive(buffer, (size_t)len, &origin, udp_state.context); + } + } + + free(buffer); + sinricpro_udp_close(); + + ESP_LOGI(TAG, "Local control listener stopped"); + udp_state.task = NULL; + vTaskDelete(NULL); +} + +esp_err_t sinricpro_udp_start(sinricpro_udp_rx_cb_t cb, void *context) +{ + if (cb == NULL) { + return ESP_ERR_INVALID_ARG; + } + + if (udp_state.task != NULL) { + ESP_LOGW(TAG, "Local control already started"); + return ESP_ERR_INVALID_STATE; + } + + if (udp_state.sock_mutex == NULL) { + udp_state.sock_mutex = xSemaphoreCreateMutex(); + if (udp_state.sock_mutex == NULL) { + ESP_LOGE(TAG, "Failed to create socket mutex"); + return ESP_ERR_NO_MEM; + } + } + + udp_state.on_receive = cb; + udp_state.context = context; + udp_state.stop_requested = false; + + BaseType_t ret = xTaskCreate(sinricpro_udp_task, "sinricpro_udp", + CONFIG_SINRICPRO_UDP_TASK_STACK_SIZE, NULL, + CONFIG_SINRICPRO_UDP_TASK_PRIORITY, + &udp_state.task); + if (ret != pdPASS) { + ESP_LOGE(TAG, "Failed to create local control task"); + udp_state.task = NULL; + return ESP_FAIL; + } + + return ESP_OK; +} + +void sinricpro_udp_stop(void) +{ + if (udp_state.task == NULL) { + return; + } + + udp_state.stop_requested = true; + + /* One recv timeout plus slack; the task frees its own resources. */ + for (int i = 0; i < 20 && udp_state.task != NULL; i++) { + vTaskDelay(pdMS_TO_TICKS(100)); + } +} + +bool sinricpro_udp_is_running(void) +{ + return udp_state.running; +} + +esp_err_t sinricpro_udp_send(const char *message, + uint32_t peer_addr, + uint16_t peer_port) +{ + if (message == NULL) { + return ESP_ERR_INVALID_ARG; + } + + if (peer_port == 0) { + ESP_LOGW(TAG, "Message has no peer to answer, dropping"); + return ESP_ERR_INVALID_ARG; + } + + if (udp_state.sock_mutex == NULL) { + return ESP_ERR_INVALID_STATE; + } + + struct sockaddr_in dest = { + .sin_family = AF_INET, + .sin_addr.s_addr = peer_addr, + .sin_port = htons(peer_port), + }; + + xSemaphoreTake(udp_state.sock_mutex, portMAX_DELAY); + + if (udp_state.sock < 0) { + xSemaphoreGive(udp_state.sock_mutex); + ESP_LOGW(TAG, "Local control socket is closed, dropping reply"); + return ESP_ERR_INVALID_STATE; + } + + int sent = sendto(udp_state.sock, message, strlen(message), 0, + (struct sockaddr *)&dest, sizeof(dest)); + xSemaphoreGive(udp_state.sock_mutex); + + esp_ip4_addr_t peer_ip = { .addr = peer_addr }; + + if (sent < 0) { + ESP_LOGE(TAG, "Reply to " IPSTR ":%u failed: errno %d", + IP2STR(&peer_ip), peer_port, errno); + return ESP_FAIL; + } + + ESP_LOGD(TAG, "Reply to " IPSTR ":%u (%d bytes)", IP2STR(&peer_ip), peer_port, sent); + + return ESP_OK; +} + +#endif /* CONFIG_SINRICPRO_ENABLE_LOCAL_CONTROL */ diff --git a/src/core/sinricpro_udp.h b/src/core/sinricpro_udp.h new file mode 100644 index 0000000..cf9af41 --- /dev/null +++ b/src/core/sinricpro_udp.h @@ -0,0 +1,89 @@ +/* + * Copyright (c) 2019-2025 Sinric. All rights reserved. + * Licensed under Creative Commons Attribution-Share Alike (CC BY-SA) + * + * This file is part of the SinricPro ESP-IDF component + * (https://github.com/sinricpro/esp-idf) + */ + +#ifndef SINRICPRO_UDP_H +#define SINRICPRO_UDP_H + +#include "esp_err.h" +#include "sinricpro_message_queue.h" +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * @brief Called for every datagram received on the local control socket + * + * Runs on the listener task, which is also where device callbacks end up, so + * the task stack must cover signature verification, JSON parsing and the user + * callback (CONFIG_SINRICPRO_UDP_TASK_STACK_SIZE). + * + * @param[in] data NUL-terminated datagram payload + * @param[in] length Payload length in bytes + * @param[in] origin Transport and peer the datagram came from + * @param[in] context Context passed to sinricpro_udp_start() + */ +typedef void (*sinricpro_udp_rx_cb_t)(const char *data, + size_t length, + const sinricpro_msg_origin_t *origin, + void *context); + +/** + * @brief Start the local control listener + * + * Binds UDP CONFIG_SINRICPRO_UDP_PORT on INADDR_ANY (so unicast to this host + * is received too) and joins the SinricPro multicast group. If the group join + * fails the task keeps retrying, logging each attempt - a silently failed join + * is indistinguishable from the feature not existing. + * + * @param[in] cb Receive callback + * @param[in] context Opaque pointer handed back to @p cb + * + * @return + * - ESP_OK: Listener task started + * - ESP_ERR_INVALID_ARG: cb is NULL + * - ESP_ERR_INVALID_STATE: Already started + * - ESP_FAIL: Task could not be created + */ +esp_err_t sinricpro_udp_start(sinricpro_udp_rx_cb_t cb, void *context); + +/** + * @brief Stop the listener and close the socket + */ +void sinricpro_udp_stop(void); + +/** + * @brief Check whether the socket is bound and the group joined + * + * @return true if local control is serving requests + */ +bool sinricpro_udp_is_running(void); + +/** + * @brief Send a reply to the peer that made the request + * + * Sent on the listening socket. A separate send-only socket is a known dead + * end on lwIP - it reports success and puts nothing on the wire. + * + * @param[in] message Serialised, signed response + * @param[in] peer_addr Peer IPv4 in network byte order + * @param[in] peer_port Peer port + * + * @return ESP_OK, ESP_ERR_INVALID_ARG, ESP_ERR_INVALID_STATE or ESP_FAIL + */ +esp_err_t sinricpro_udp_send(const char *message, + uint32_t peer_addr, + uint16_t peer_port); + +#ifdef __cplusplus +} +#endif + +#endif /* SINRICPRO_UDP_H */ diff --git a/test/host/run.sh b/test/host/run.sh new file mode 100644 index 0000000..ad56d9b --- /dev/null +++ b/test/host/run.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +# Build and run the host tests for the signing / verification wire contract. +# +# Compiles the real src/core/sinricpro_signature.c off-target against shim +# headers (OpenSSL stands in for mbedTLS), so what is tested is the shipped +# source rather than a re-implementation of it. +# +# test/host/run.sh +# +# cJSON is taken from the example's managed_components if present; override +# with CJSON_DIR=/path/to/cJSON. +set -euo pipefail + +here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +root="$(cd "$here/../.." && pwd)" + +cjson_dir="${CJSON_DIR:-$root/examples/switch/managed_components/espressif__cjson/cJSON}" + +if [ ! -f "$cjson_dir/cJSON.c" ]; then + echo "cJSON sources not found at $cjson_dir" >&2 + echo "Build an example once (idf.py reconfigure) or set CJSON_DIR." >&2 + exit 1 +fi + +out="$(mktemp -d)" +trap 'rm -rf "$out"' EXIT + +gcc -std=c11 -Wall -Wextra -Wno-unused-parameter -O1 -g \ + -I "$here/shims" \ + -I "$root/src/core" \ + -I "$root/include" \ + -I "$cjson_dir" \ + "$here/test_signature.c" \ + "$here/shims/shims.c" \ + "$root/src/core/sinricpro_signature.c" \ + "$cjson_dir/cJSON.c" \ + -lcrypto -lm \ + -o "$out/test_signature" + +"$out/test_signature" diff --git a/test/host/shims/esp_err.h b/test/host/shims/esp_err.h new file mode 100644 index 0000000..8204320 --- /dev/null +++ b/test/host/shims/esp_err.h @@ -0,0 +1,15 @@ +/* Host-test shim: the slice/sign logic under test is platform independent. */ +#ifndef SINRICPRO_HOST_SHIM_ESP_ERR_H +#define SINRICPRO_HOST_SHIM_ESP_ERR_H + +typedef int esp_err_t; + +#define ESP_OK 0 +#define ESP_FAIL -1 +#define ESP_ERR_NO_MEM 0x101 +#define ESP_ERR_INVALID_ARG 0x102 +#define ESP_ERR_INVALID_STATE 0x103 +#define ESP_ERR_INVALID_SIZE 0x104 +#define ESP_ERR_TIMEOUT 0x107 + +#endif diff --git a/test/host/shims/esp_log.h b/test/host/shims/esp_log.h new file mode 100644 index 0000000..0471e5d --- /dev/null +++ b/test/host/shims/esp_log.h @@ -0,0 +1,10 @@ +/* Host-test shim: logging is not what these tests exercise. */ +#ifndef SINRICPRO_HOST_SHIM_ESP_LOG_H +#define SINRICPRO_HOST_SHIM_ESP_LOG_H + +#define ESP_LOGE(tag, ...) do { (void)(tag); } while (0) +#define ESP_LOGW(tag, ...) do { (void)(tag); } while (0) +#define ESP_LOGI(tag, ...) do { (void)(tag); } while (0) +#define ESP_LOGD(tag, ...) do { (void)(tag); } while (0) + +#endif diff --git a/test/host/shims/mbedtls/base64.h b/test/host/shims/mbedtls/base64.h new file mode 100644 index 0000000..1948688 --- /dev/null +++ b/test/host/shims/mbedtls/base64.h @@ -0,0 +1,10 @@ +/* Host-test shim: mbedTLS base64 surface used by sinricpro_signature.c. */ +#ifndef SINRICPRO_HOST_SHIM_MBEDTLS_BASE64_H +#define SINRICPRO_HOST_SHIM_MBEDTLS_BASE64_H + +#include + +int mbedtls_base64_encode(unsigned char *dst, size_t dlen, size_t *olen, + const unsigned char *src, size_t slen); + +#endif diff --git a/test/host/shims/mbedtls/md.h b/test/host/shims/mbedtls/md.h new file mode 100644 index 0000000..5935eb0 --- /dev/null +++ b/test/host/shims/mbedtls/md.h @@ -0,0 +1,28 @@ +/* Host-test shim: mbedTLS HMAC surface used by sinricpro_signature.c, over OpenSSL. */ +#ifndef SINRICPRO_HOST_SHIM_MBEDTLS_MD_H +#define SINRICPRO_HOST_SHIM_MBEDTLS_MD_H + +#include + +typedef enum { MBEDTLS_MD_SHA256 = 6 } mbedtls_md_type_t; + +typedef struct { mbedtls_md_type_t type; } mbedtls_md_info_t; + +typedef struct { + const mbedtls_md_info_t *info; + unsigned char *key; + size_t key_len; + unsigned char *data; + size_t data_len; + size_t data_cap; +} mbedtls_md_context_t; + +const mbedtls_md_info_t *mbedtls_md_info_from_type(mbedtls_md_type_t type); +void mbedtls_md_init(mbedtls_md_context_t *ctx); +int mbedtls_md_setup(mbedtls_md_context_t *ctx, const mbedtls_md_info_t *info, int hmac); +int mbedtls_md_hmac_starts(mbedtls_md_context_t *ctx, const unsigned char *key, size_t keylen); +int mbedtls_md_hmac_update(mbedtls_md_context_t *ctx, const unsigned char *input, size_t ilen); +int mbedtls_md_hmac_finish(mbedtls_md_context_t *ctx, unsigned char *output); +void mbedtls_md_free(mbedtls_md_context_t *ctx); + +#endif diff --git a/test/host/shims/mbedtls/version.h b/test/host/shims/mbedtls/version.h new file mode 100644 index 0000000..4ef0396 --- /dev/null +++ b/test/host/shims/mbedtls/version.h @@ -0,0 +1,8 @@ +/* Host-test shim: selects the classic md-layer backend in sinricpro_signature.c. */ +#ifndef SINRICPRO_HOST_SHIM_MBEDTLS_VERSION_H +#define SINRICPRO_HOST_SHIM_MBEDTLS_VERSION_H + +#define MBEDTLS_VERSION_NUMBER 0x03000000 +#define MBEDTLS_MD_C + +#endif diff --git a/test/host/shims/shims.c b/test/host/shims/shims.c new file mode 100644 index 0000000..46e2882 --- /dev/null +++ b/test/host/shims/shims.c @@ -0,0 +1,124 @@ +/* + * Host-test shim implementations: the mbedTLS surface sinricpro_signature.c + * uses, backed by OpenSSL, so the real source compiles unmodified off-target. + */ + +#include "mbedtls/md.h" +#include "mbedtls/base64.h" + +#include +#include +#include + +static const mbedtls_md_info_t sha256_info = { MBEDTLS_MD_SHA256 }; + +const mbedtls_md_info_t *mbedtls_md_info_from_type(mbedtls_md_type_t type) +{ + return type == MBEDTLS_MD_SHA256 ? &sha256_info : NULL; +} + +void mbedtls_md_init(mbedtls_md_context_t *ctx) +{ + memset(ctx, 0, sizeof(*ctx)); +} + +int mbedtls_md_setup(mbedtls_md_context_t *ctx, const mbedtls_md_info_t *info, int hmac) +{ + (void)hmac; + if (info == NULL) { + return -1; + } + ctx->info = info; + return 0; +} + +int mbedtls_md_hmac_starts(mbedtls_md_context_t *ctx, const unsigned char *key, size_t keylen) +{ + free(ctx->key); + ctx->key = malloc(keylen ? keylen : 1); + if (ctx->key == NULL) { + return -1; + } + memcpy(ctx->key, key, keylen); + ctx->key_len = keylen; + ctx->data_len = 0; + return 0; +} + +int mbedtls_md_hmac_update(mbedtls_md_context_t *ctx, const unsigned char *input, size_t ilen) +{ + if (ctx->data_len + ilen > ctx->data_cap) { + size_t cap = ctx->data_cap ? ctx->data_cap : 256; + while (cap < ctx->data_len + ilen) { + cap *= 2; + } + unsigned char *grown = realloc(ctx->data, cap); + if (grown == NULL) { + return -1; + } + ctx->data = grown; + ctx->data_cap = cap; + } + memcpy(ctx->data + ctx->data_len, input, ilen); + ctx->data_len += ilen; + return 0; +} + +int mbedtls_md_hmac_finish(mbedtls_md_context_t *ctx, unsigned char *output) +{ + unsigned int len = 0; + if (HMAC(EVP_sha256(), ctx->key, (int)ctx->key_len, + ctx->data, ctx->data_len, output, &len) == NULL || len != 32) { + return -1; + } + return 0; +} + +void mbedtls_md_free(mbedtls_md_context_t *ctx) +{ + free(ctx->key); + free(ctx->data); + memset(ctx, 0, sizeof(*ctx)); +} + +int mbedtls_base64_encode(unsigned char *dst, size_t dlen, size_t *olen, + const unsigned char *src, size_t slen) +{ + static const char b64[] = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + + size_t need = ((slen + 2) / 3) * 4; + + if (dst == NULL || dlen < need + 1) { + *olen = need + 1; + return -1; + } + + size_t o = 0; + size_t i = 0; + + for (; i + 2 < slen; i += 3) { + unsigned v = ((unsigned)src[i] << 16) | ((unsigned)src[i + 1] << 8) | src[i + 2]; + dst[o++] = b64[(v >> 18) & 0x3F]; + dst[o++] = b64[(v >> 12) & 0x3F]; + dst[o++] = b64[(v >> 6) & 0x3F]; + dst[o++] = b64[v & 0x3F]; + } + + if (i < slen) { + unsigned v = (unsigned)src[i] << 16; + int rem = (int)(slen - i); + if (rem == 2) { + v |= (unsigned)src[i + 1] << 8; + } + dst[o++] = b64[(v >> 18) & 0x3F]; + dst[o++] = b64[(v >> 12) & 0x3F]; + dst[o++] = rem == 2 ? b64[(v >> 6) & 0x3F] : '='; + dst[o++] = '='; + } + + dst[o] = '\0'; + *olen = o; + + return 0; +} diff --git a/test/host/test_signature.c b/test/host/test_signature.c new file mode 100644 index 0000000..ff6588d --- /dev/null +++ b/test/host/test_signature.c @@ -0,0 +1,251 @@ +/* + * Copyright (c) 2019-2025 Sinric. All rights reserved. + * Licensed under Creative Commons Attribution-Share Alike (CC BY-SA) + * + * Host tests for the wire contract that local control depends on: + * the payload is signed exactly as transmitted, and verified by slicing the + * bytes as received. Both are cross-checked against the reference vectors the + * Flutter app and the Python/Node SDKs produce. + * + * Build and run: test/host/run.sh + */ + +#include "sinricpro_signature.h" + +#include +#include +#include + +static int failures; + +#define CHECK(cond, ...) \ + do { \ + if (cond) { \ + printf(" ok " __VA_ARGS__); \ + printf("\n"); \ + } else { \ + failures++; \ + printf(" FAIL " __VA_ARGS__); \ + printf(" (%s:%d)\n", __FILE__, __LINE__); \ + } \ + } while (0) + +/* The user's test account secret, as used for the on-hardware verification. */ +static const char *SECRET = + "cc51b80f-0d7a-4c76-8f68-659f74d17f5d-4cd278be-3f4b-4878-82eb-a7629a0ea105"; + +static void test_hmac_reference_vector(void) +{ + printf("HMAC-SHA256 -> base64\n"); + + /* RFC 4231 test case 1: key = 20 x 0x0b, data = "Hi There". */ + const char key[] = "\x0b\x0b\x0b\x0b\x0b\x0b\x0b\x0b\x0b\x0b" + "\x0b\x0b\x0b\x0b\x0b\x0b\x0b\x0b\x0b\x0b"; + char sig[64]; + + esp_err_t ret = sinricpro_calculate_signature(key, "Hi There", sig, sizeof(sig)); + + CHECK(ret == ESP_OK, "signature computed"); + CHECK(strcmp(sig, "sDRMYdjbOFNcqK/OrwvxK4gdwgDJgz2nJuk3bC4yz/c=") == 0, + "RFC 4231 case 1 matches: %s", sig); +} + +static void test_slice_between_markers(void) +{ + printf("payload slicing\n"); + + /* A real request from the app: the payload is signed as these exact bytes, + * with the sender's own key order, which we must not re-derive. */ + const char *msg = + "{\"header\":{\"payloadVersion\":2,\"signatureVersion\":1}," + "\"payload\":{\"action\":\"setPowerState\",\"clientId\":\"mobile-app\"," + "\"createdAt\":1756600000,\"deviceId\":\"6a93e2c73ee15f85c47ed491\"," + "\"replyToken\":\"abc\",\"scope\":\"device\",\"type\":\"request\"," + "\"value\":{\"state\":\"On\"}}," + "\"signature\":{\"HMAC\":\"ignored\"}}"; + + const char *payload = NULL; + size_t len = 0; + + CHECK(sinricpro_extract_payload_ref(msg, &payload, &len) == ESP_OK, "payload located"); + CHECK(payload[0] == '{' && payload[len - 1] == '}', "slice is the payload object"); + CHECK(len == strlen("{\"action\":\"setPowerState\",\"clientId\":\"mobile-app\"," + "\"createdAt\":1756600000,\"deviceId\":\"6a93e2c73ee15f85c47ed491\"," + "\"replyToken\":\"abc\",\"scope\":\"device\",\"type\":\"request\"," + "\"value\":{\"state\":\"On\"}}"), + "slice length is exact (%zu)", len); + + /* A brace inside a string must not end the slice: this is the case the + * brace-matching-only extractor got wrong. */ + const char *braced = + "{\"header\":{},\"payload\":{\"message\":\"a } brace\",\"type\":\"request\"}," + "\"signature\":{\"HMAC\":\"x\"}}"; + + CHECK(sinricpro_extract_payload_ref(braced, &payload, &len) == ESP_OK, + "payload with a brace inside a string located"); + CHECK(len == strlen("{\"message\":\"a } brace\",\"type\":\"request\"}"), + "brace inside a string does not truncate the slice (%zu)", len); + + CHECK(sinricpro_extract_payload_ref("{\"header\":{}}", &payload, &len) == ESP_FAIL, + "message with no payload is rejected"); +} + +static void test_verify_uses_received_bytes(void) +{ + printf("verification\n"); + + const char *payload = + "{\"action\":\"setPowerState\",\"clientId\":\"mobile-app\",\"createdAt\":1756600000," + "\"deviceId\":\"6a93e2c73ee15f85c47ed491\",\"replyToken\":\"abc\"," + "\"scope\":\"device\",\"type\":\"request\",\"value\":{\"state\":\"On\"}}"; + + char sig[64]; + sinricpro_calculate_signature(SECRET, payload, sig, sizeof(sig)); + + char *msg = malloc(strlen(payload) + strlen(sig) + 128); + sprintf(msg, "{\"header\":{\"payloadVersion\":2,\"signatureVersion\":1}," + "\"payload\":%s,\"signature\":{\"HMAC\":\"%s\"}}", payload, sig); + + const char *sliced = NULL; + size_t len = 0; + sinricpro_extract_payload_ref(msg, &sliced, &len); + + CHECK(sinricpro_verify_signature_n(SECRET, sliced, len, sig) == ESP_OK, + "correct signature accepted"); + + CHECK(sinricpro_verify_signature_n(SECRET, sliced, len, + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=") + == SINRICPRO_ERR_SIGNATURE, + "wrong signature rejected"); + + CHECK(sinricpro_verify_signature_n("wrong-secret", sliced, len, sig) + == SINRICPRO_ERR_SIGNATURE, + "wrong secret rejected"); + + /* Truncated signature: the constant-time compare must reject on length + * rather than read past the end. */ + char truncated[8]; + memcpy(truncated, sig, 7); + truncated[7] = '\0'; + CHECK(sinricpro_verify_signature_n(SECRET, sliced, len, truncated) + == SINRICPRO_ERR_SIGNATURE, + "truncated signature rejected"); + + /* Re-ordering the payload the way a re-serialisation would must break the + * signature - which is why verification slices instead. */ + char *reordered = malloc(strlen(payload) + 128); + sprintf(reordered, + "{\"type\":\"request\",\"action\":\"setPowerState\",\"clientId\":\"mobile-app\"," + "\"createdAt\":1756600000,\"deviceId\":\"6a93e2c73ee15f85c47ed491\"," + "\"replyToken\":\"abc\",\"scope\":\"device\",\"value\":{\"state\":\"On\"}}"); + CHECK(sinricpro_verify_signature_n(SECRET, reordered, strlen(reordered), sig) + == SINRICPRO_ERR_SIGNATURE, + "re-serialised payload does not verify (so slicing is load-bearing)"); + + free(reordered); + free(msg); +} + +/* + * A request captured from the SinricPro app's own signer + * (tool/lan_probe.dart --emit, LocalControlSigner.buildRequest). If this stops + * verifying, the device has stopped interoperating with the app. + */ +static void test_app_interop_vector(void) +{ + printf("app interop vector\n"); + + const char *msg = + "{\"header\":{\"payloadVersion\":2,\"signatureVersion\":1}," + "\"payload\":{\"action\":\"setPowerState\",\"clientId\":\"mobile-app\"," + "\"createdAt\":1788163803,\"deviceId\":\"6a93e2c73ee15f85c47ed491\"," + "\"replyToken\":\"f5b9997fed487e987e5270ab83d94580\",\"scope\":\"device\"," + "\"type\":\"request\",\"value\":{\"state\":\"On\"}}," + "\"signature\":{\"HMAC\":\"EFRFuSm3rWpI0i9uTDqs8FFGwTVozsnPnTEcJgmeH/Y=\"}}"; + + const char *payload = NULL; + size_t len = 0; + + CHECK(sinricpro_extract_payload_ref(msg, &payload, &len) == ESP_OK, + "app request payload located"); + CHECK(sinricpro_verify_signature_n(SECRET, payload, len, + "EFRFuSm3rWpI0i9uTDqs8FFGwTVozsnPnTEcJgmeH/Y=") == ESP_OK, + "app-signed request verifies against the shipped Flutter signer"); +} + +static void test_sign_message_transmits_what_it_signed(void) +{ + printf("sign and splice\n"); + + cJSON *msg = cJSON_CreateObject(); + cJSON *header = cJSON_CreateObject(); + cJSON *payload = cJSON_CreateObject(); + cJSON *value = cJSON_CreateObject(); + + cJSON_AddItemToObject(msg, "header", header); + cJSON_AddNumberToObject(header, "payloadVersion", 2); + cJSON_AddNumberToObject(header, "signatureVersion", 1); + + cJSON_AddItemToObject(msg, "payload", payload); + cJSON_AddStringToObject(payload, "action", "setPowerState"); + cJSON_AddNumberToObject(payload, "createdAt", 1756600000); + cJSON_AddStringToObject(payload, "deviceId", "6a93e2c73ee15f85c47ed491"); + cJSON_AddStringToObject(payload, "instanceId", "rangeInstance1"); + cJSON_AddStringToObject(payload, "type", "response"); + cJSON_AddItemToObject(payload, "value", value); + cJSON_AddStringToObject(value, "state", "On"); + + char *wire = sinricpro_sign_message(SECRET, msg); + CHECK(wire != NULL, "message signed"); + + if (wire == NULL) { + cJSON_Delete(msg); + return; + } + + /* The client slices between these markers, so the signature must be the + * member immediately after the payload. */ + const char *sig_marker = strstr(wire, ",\"signature\""); + CHECK(sig_marker != NULL, "signature member present"); + CHECK(strstr(wire, "\"header\":") < strstr(wire, "\"payload\":"), + "header precedes payload"); + + /* The bytes on the wire are the bytes that were signed. */ + const char *sliced = NULL; + size_t len = 0; + CHECK(sinricpro_extract_payload_ref(wire, &sliced, &len) == ESP_OK, + "payload slices back out of the emitted envelope"); + + cJSON *parsed = cJSON_Parse(wire); + CHECK(parsed != NULL, "emitted envelope is valid JSON"); + + const char *hmac = cJSON_GetStringValue( + cJSON_GetObjectItem(cJSON_GetObjectItem(parsed, "signature"), "HMAC")); + CHECK(hmac != NULL, "HMAC present"); + CHECK(hmac && sinricpro_verify_signature_n(SECRET, sliced, len, hmac) == ESP_OK, + "emitted message verifies against its own transmitted payload bytes"); + + /* instanceId is inside the signed payload, not appended afterwards. */ + CHECK(strstr(sliced, "\"instanceId\":\"rangeInstance1\"") != NULL && + (size_t)(strstr(sliced, "\"instanceId\"") - sliced) < len, + "instanceId is covered by the signature"); + + cJSON_Delete(parsed); + free(wire); + cJSON_Delete(msg); +} + +int main(void) +{ + printf("SinricPro signature host tests\n\n"); + + test_hmac_reference_vector(); + test_slice_between_markers(); + test_verify_uses_received_bytes(); + test_app_interop_vector(); + test_sign_message_transmits_what_it_signed(); + + printf("\n%s\n", failures == 0 ? "all tests passed" : "TESTS FAILED"); + + return failures == 0 ? 0 : 1; +} From 091d1a6144a8d7f5e250e107b6803749213cc3df Mon Sep 17 00:00:00 2001 From: Aruna Tennakoon Date: Mon, 31 Aug 2026 22:33:09 +0700 Subject: [PATCH 2/2] fix: build workflow --- .github/workflows/build-test.yml | 61 +++++++++++++++++++ .../air_quality_sensor/sdkconfig.defaults | 4 ++ examples/blinds/sdkconfig.defaults | 4 ++ examples/contact_sensor/sdkconfig.defaults | 4 ++ examples/dimswitch/sdkconfig.defaults | 4 ++ examples/fan/sdkconfig.defaults | 4 ++ examples/garage_door/sdkconfig.defaults | 4 ++ examples/light/sdkconfig.defaults | 4 ++ examples/lock/sdkconfig.defaults | 4 ++ examples/motion_sensor/sdkconfig.defaults | 4 ++ examples/power_sensor/sdkconfig.defaults | 4 ++ examples/speaker/sdkconfig.defaults | 4 ++ .../temperature_sensor/sdkconfig.defaults | 4 ++ examples/thermostat/sdkconfig.defaults | 4 ++ examples/tv/sdkconfig.defaults | 4 ++ examples/windowac/sdkconfig.defaults | 4 ++ src/core/sinricpro_core.c | 6 ++ 17 files changed, 127 insertions(+) create mode 100644 examples/air_quality_sensor/sdkconfig.defaults create mode 100644 examples/blinds/sdkconfig.defaults create mode 100644 examples/contact_sensor/sdkconfig.defaults create mode 100644 examples/dimswitch/sdkconfig.defaults create mode 100644 examples/fan/sdkconfig.defaults create mode 100644 examples/garage_door/sdkconfig.defaults create mode 100644 examples/light/sdkconfig.defaults create mode 100644 examples/lock/sdkconfig.defaults create mode 100644 examples/motion_sensor/sdkconfig.defaults create mode 100644 examples/power_sensor/sdkconfig.defaults create mode 100644 examples/speaker/sdkconfig.defaults create mode 100644 examples/temperature_sensor/sdkconfig.defaults create mode 100644 examples/thermostat/sdkconfig.defaults create mode 100644 examples/tv/sdkconfig.defaults create mode 100644 examples/windowac/sdkconfig.defaults diff --git a/.github/workflows/build-test.yml b/.github/workflows/build-test.yml index 34eba0e..7d4cf84 100644 --- a/.github/workflows/build-test.yml +++ b/.github/workflows/build-test.yml @@ -57,6 +57,67 @@ jobs: . $IDF_PATH/export.sh idf.py size + # Local control without the mDNS announcement. Keeps the UDP listener but + # drops the mdns component, which is the configuration for a board whose app + # partition cannot take it. A gate that is never built is a gate that breaks. + build-no-mdns: + runs-on: ubuntu-latest + container: + image: espressif/idf:v5.1 + strategy: + fail-fast: false + matrix: + idf-target: [esp32c3, esp32] + example: [contact_sensor, switch] + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + submodules: 'recursive' + + - name: Clean managed components + run: | + find . -type d -name "managed_components" -exec rm -rf {} + || true + + - name: Build ${{ matrix.example }} for ${{ matrix.idf-target }} without mDNS + working-directory: examples/${{ matrix.example }} + run: | + . $IDF_PATH/export.sh + echo "CONFIG_SINRICPRO_LOCAL_CONTROL_NO_MDNS=y" >> sdkconfig.defaults + idf.py set-target ${{ matrix.idf-target }} + idf.py build + + # Local control compiled out entirely. This variant has never been proven by a + # full link, so CI is the place that does it. + build-no-local-control: + runs-on: ubuntu-latest + container: + image: espressif/idf:v5.1 + strategy: + fail-fast: false + matrix: + idf-target: [esp32, esp32c3] + example: [switch] + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + submodules: 'recursive' + + - name: Clean managed components + run: | + find . -type d -name "managed_components" -exec rm -rf {} + || true + + - name: Build ${{ matrix.example }} for ${{ matrix.idf-target }} without local control + working-directory: examples/${{ matrix.example }} + run: | + . $IDF_PATH/export.sh + echo "CONFIG_SINRICPRO_ENABLE_LOCAL_CONTROL=n" >> sdkconfig.defaults + idf.py set-target ${{ matrix.idf-target }} + idf.py build + lint: runs-on: ubuntu-latest steps: diff --git a/examples/air_quality_sensor/sdkconfig.defaults b/examples/air_quality_sensor/sdkconfig.defaults new file mode 100644 index 0000000..6dd41c9 --- /dev/null +++ b/examples/air_quality_sensor/sdkconfig.defaults @@ -0,0 +1,4 @@ +# The default 2 MB / single-app layout leaves no room once local control pulls +# in the mdns responder. +CONFIG_ESPTOOLPY_FLASHSIZE_4MB=y +CONFIG_PARTITION_TABLE_SINGLE_APP_LARGE=y diff --git a/examples/blinds/sdkconfig.defaults b/examples/blinds/sdkconfig.defaults new file mode 100644 index 0000000..6dd41c9 --- /dev/null +++ b/examples/blinds/sdkconfig.defaults @@ -0,0 +1,4 @@ +# The default 2 MB / single-app layout leaves no room once local control pulls +# in the mdns responder. +CONFIG_ESPTOOLPY_FLASHSIZE_4MB=y +CONFIG_PARTITION_TABLE_SINGLE_APP_LARGE=y diff --git a/examples/contact_sensor/sdkconfig.defaults b/examples/contact_sensor/sdkconfig.defaults new file mode 100644 index 0000000..6dd41c9 --- /dev/null +++ b/examples/contact_sensor/sdkconfig.defaults @@ -0,0 +1,4 @@ +# The default 2 MB / single-app layout leaves no room once local control pulls +# in the mdns responder. +CONFIG_ESPTOOLPY_FLASHSIZE_4MB=y +CONFIG_PARTITION_TABLE_SINGLE_APP_LARGE=y diff --git a/examples/dimswitch/sdkconfig.defaults b/examples/dimswitch/sdkconfig.defaults new file mode 100644 index 0000000..6dd41c9 --- /dev/null +++ b/examples/dimswitch/sdkconfig.defaults @@ -0,0 +1,4 @@ +# The default 2 MB / single-app layout leaves no room once local control pulls +# in the mdns responder. +CONFIG_ESPTOOLPY_FLASHSIZE_4MB=y +CONFIG_PARTITION_TABLE_SINGLE_APP_LARGE=y diff --git a/examples/fan/sdkconfig.defaults b/examples/fan/sdkconfig.defaults new file mode 100644 index 0000000..6dd41c9 --- /dev/null +++ b/examples/fan/sdkconfig.defaults @@ -0,0 +1,4 @@ +# The default 2 MB / single-app layout leaves no room once local control pulls +# in the mdns responder. +CONFIG_ESPTOOLPY_FLASHSIZE_4MB=y +CONFIG_PARTITION_TABLE_SINGLE_APP_LARGE=y diff --git a/examples/garage_door/sdkconfig.defaults b/examples/garage_door/sdkconfig.defaults new file mode 100644 index 0000000..6dd41c9 --- /dev/null +++ b/examples/garage_door/sdkconfig.defaults @@ -0,0 +1,4 @@ +# The default 2 MB / single-app layout leaves no room once local control pulls +# in the mdns responder. +CONFIG_ESPTOOLPY_FLASHSIZE_4MB=y +CONFIG_PARTITION_TABLE_SINGLE_APP_LARGE=y diff --git a/examples/light/sdkconfig.defaults b/examples/light/sdkconfig.defaults new file mode 100644 index 0000000..6dd41c9 --- /dev/null +++ b/examples/light/sdkconfig.defaults @@ -0,0 +1,4 @@ +# The default 2 MB / single-app layout leaves no room once local control pulls +# in the mdns responder. +CONFIG_ESPTOOLPY_FLASHSIZE_4MB=y +CONFIG_PARTITION_TABLE_SINGLE_APP_LARGE=y diff --git a/examples/lock/sdkconfig.defaults b/examples/lock/sdkconfig.defaults new file mode 100644 index 0000000..6dd41c9 --- /dev/null +++ b/examples/lock/sdkconfig.defaults @@ -0,0 +1,4 @@ +# The default 2 MB / single-app layout leaves no room once local control pulls +# in the mdns responder. +CONFIG_ESPTOOLPY_FLASHSIZE_4MB=y +CONFIG_PARTITION_TABLE_SINGLE_APP_LARGE=y diff --git a/examples/motion_sensor/sdkconfig.defaults b/examples/motion_sensor/sdkconfig.defaults new file mode 100644 index 0000000..6dd41c9 --- /dev/null +++ b/examples/motion_sensor/sdkconfig.defaults @@ -0,0 +1,4 @@ +# The default 2 MB / single-app layout leaves no room once local control pulls +# in the mdns responder. +CONFIG_ESPTOOLPY_FLASHSIZE_4MB=y +CONFIG_PARTITION_TABLE_SINGLE_APP_LARGE=y diff --git a/examples/power_sensor/sdkconfig.defaults b/examples/power_sensor/sdkconfig.defaults new file mode 100644 index 0000000..6dd41c9 --- /dev/null +++ b/examples/power_sensor/sdkconfig.defaults @@ -0,0 +1,4 @@ +# The default 2 MB / single-app layout leaves no room once local control pulls +# in the mdns responder. +CONFIG_ESPTOOLPY_FLASHSIZE_4MB=y +CONFIG_PARTITION_TABLE_SINGLE_APP_LARGE=y diff --git a/examples/speaker/sdkconfig.defaults b/examples/speaker/sdkconfig.defaults new file mode 100644 index 0000000..6dd41c9 --- /dev/null +++ b/examples/speaker/sdkconfig.defaults @@ -0,0 +1,4 @@ +# The default 2 MB / single-app layout leaves no room once local control pulls +# in the mdns responder. +CONFIG_ESPTOOLPY_FLASHSIZE_4MB=y +CONFIG_PARTITION_TABLE_SINGLE_APP_LARGE=y diff --git a/examples/temperature_sensor/sdkconfig.defaults b/examples/temperature_sensor/sdkconfig.defaults new file mode 100644 index 0000000..6dd41c9 --- /dev/null +++ b/examples/temperature_sensor/sdkconfig.defaults @@ -0,0 +1,4 @@ +# The default 2 MB / single-app layout leaves no room once local control pulls +# in the mdns responder. +CONFIG_ESPTOOLPY_FLASHSIZE_4MB=y +CONFIG_PARTITION_TABLE_SINGLE_APP_LARGE=y diff --git a/examples/thermostat/sdkconfig.defaults b/examples/thermostat/sdkconfig.defaults new file mode 100644 index 0000000..6dd41c9 --- /dev/null +++ b/examples/thermostat/sdkconfig.defaults @@ -0,0 +1,4 @@ +# The default 2 MB / single-app layout leaves no room once local control pulls +# in the mdns responder. +CONFIG_ESPTOOLPY_FLASHSIZE_4MB=y +CONFIG_PARTITION_TABLE_SINGLE_APP_LARGE=y diff --git a/examples/tv/sdkconfig.defaults b/examples/tv/sdkconfig.defaults new file mode 100644 index 0000000..6dd41c9 --- /dev/null +++ b/examples/tv/sdkconfig.defaults @@ -0,0 +1,4 @@ +# The default 2 MB / single-app layout leaves no room once local control pulls +# in the mdns responder. +CONFIG_ESPTOOLPY_FLASHSIZE_4MB=y +CONFIG_PARTITION_TABLE_SINGLE_APP_LARGE=y diff --git a/examples/windowac/sdkconfig.defaults b/examples/windowac/sdkconfig.defaults new file mode 100644 index 0000000..6dd41c9 --- /dev/null +++ b/examples/windowac/sdkconfig.defaults @@ -0,0 +1,4 @@ +# The default 2 MB / single-app layout leaves no room once local control pulls +# in the mdns responder. +CONFIG_ESPTOOLPY_FLASHSIZE_4MB=y +CONFIG_PARTITION_TABLE_SINGLE_APP_LARGE=y diff --git a/src/core/sinricpro_core.c b/src/core/sinricpro_core.c index 8351451..f5280c2 100644 --- a/src/core/sinricpro_core.c +++ b/src/core/sinricpro_core.c @@ -233,6 +233,12 @@ static void handle_request(cJSON *json_message, const sinricpro_msg_origin_t *or sinricpro_device_t *device = find_device(device_id); xSemaphoreGive(core_state.mutex); + if (device == NULL && origin != NULL && + origin->transport == SINRICPRO_TRANSPORT_UDP) { + ESP_LOGD(TAG, "Ignoring LAN request for unknown device: %s", device_id); + return; + } + /* Prepare response */ cJSON *response = cJSON_CreateObject(); cJSON *response_header = cJSON_CreateObject();