From df1dd472cc399801f7a0b6fef0e3200fb584fa7d Mon Sep 17 00:00:00 2001 From: Tony Date: Tue, 12 May 2026 21:29:40 +0800 Subject: [PATCH] feat(gateway): implement KNX security features including secure session handling and factory certificate management Signed-off-by: Tony --- README.md | 10 +- .../gateway_bridge/src/gateway_bridge.cpp | 264 +++++++++++++++++- .../gateway_knx/include/gateway_knx.hpp | 3 + components/gateway_knx/src/gateway_knx.cpp | 60 ++++ .../include/openknx_idf/security_storage.h | 18 ++ .../openknx_idf/src/security_storage.cpp | 155 ++++++++++ knx | 2 +- 7 files changed, 509 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index e05979c..9ab015b 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ This folder hosts the native ESP-IDF C++ rewrite of the Lua DALI gateway. - `dali_domain/`: native DALI domain facade over `dali_cpp` and raw frame sinks. - `gateway_cache/`: DALI scene/group/settings/runtime cache used by controller reconciliation and protocol bridges. - `gateway_bridge/`: per-channel bridge provisioning, command execution, protocol startup, and HTTP bridge actions. - - `openknx_idf/`: ESP-IDF port layer for the OpenKNX `gateway/knx` and `gateway/tpuart` submodules, including NVS-backed OpenKNX memory, ETS cEMI programming support, UDP multicast/unicast plumbing, and a native TP-UART interface without the Arduino framework. + - `openknx_idf/`: ESP-IDF port layer for the OpenKNX `gateway/knx` and `gateway/tpuart` submodules, including NVS-backed OpenKNX memory, development KNX security storage, ETS cEMI programming support, UDP multicast/unicast plumbing, and a native TP-UART interface without the Arduino framework. - `gateway_modbus/`: gateway-owned Modbus TCP/RTU/ASCII config, generated DALI point tables, and provisioned Modbus model override dispatch. - `gateway_bacnet/`: BACnet/IP server adapter backed by bacnet-stack, including the gateway-owned BACnet bridge model adapter. - `gateway_ble/`: NimBLE GATT bridge for BLE transport parity on `FFF1`/`FFF2`/`FFF3`, including raw DALI notifications. @@ -26,6 +26,14 @@ This folder hosts the native ESP-IDF C++ rewrite of the Lua DALI gateway. The native rewrite now wires a shared `gateway_core` bootstrap component, a multi-channel `dali_domain` wrapper over `dali_cpp`, a local vendored `dali` hardware backend from the LuatOS ESP-IDF port with raw receive fan-out, an initial `gateway_runtime` service that provides persistent settings, device info, Lua-compatible command framing helpers, and Lua-style query command deduplication, plus a `gateway_controller` service that starts the gateway command task, dispatches core Lua gateway opcodes, and owns internal scene/group state. The gateway app also includes a `gateway_ble` NimBLE bridge that advertises a Lua-compatible GATT service and forwards `FFF3` framed notifications, incoming `FFF1`/`FFF2`/`FFF3` writes, and native raw DALI frame notifications into the matching raw channel, a `gateway_network` service that provides the native HTTP `/info`, `GET`/`POST /dali/cmd`, `/led/1`, `/led/0`, `/jq.js`, UDP control-plane router on port `2020`, W5500 SPI Ethernet with DHCP, Wi-Fi STA lifecycle, ESP-Touch smartconfig credential provisioning, the Lua-style `LAMMIN_Gateway` setup AP on `192.168.3.1`, ESP-NOW setup ingress for Lua-compatible `connReq`/`connAck`/`echo`/`cmd`/`data`/`uart` packets, native raw DALI frame forwarding back to connected setup peers, and BOOT-button Wi-Fi credential clearing, and an optional `gateway_485_control` bridge that claims UART0 for Lua-compatible framed command ingress plus `0x22` notification egress when the console is moved off UART0. Startup behavior is configured in `main/Kconfig.projbuild`: BLE and wired Ethernet are enabled by default, W5500 initialization and startup probe failures are ignored by default for boards without populated Ethernet hardware by fully disabling Ethernet for that boot, Wi-Fi STA, smartconfig, and ESP-NOW setup mode are disabled by default, the built-in USB Serial/JTAG interface stays in debug mode unless the optional USB setup bridge mode is selected, and the UART0 control bridge stays disabled unless the deployment explicitly repurposes UART0 away from the ESP-IDF console. Runtime settings and internal scene/group data are cached in RAM after load, skip unchanged flash writes, and batch Wi-Fi credential commits to reduce flash stalls on ESP32-S3 boards where flash and PSRAM share the SPI bus. The gateway app exposes per-channel PHY selection through `main/Kconfig.projbuild`; each channel can be disabled, bound to the native DALI GPIO HAL, or bound to a UART1/UART2 serial PHY. The checked-in `sdkconfig` is aligned with the app's custom 16 MB partition table so the Wi-Fi/BLE/network-enabled image fits the OTA app slots. +## KNX Security + +KNX Data Secure and KNXnet/IP Secure support are controlled by `GATEWAY_KNX_DATA_SECURE_SUPPORTED` and `GATEWAY_KNX_IP_SECURE_SUPPORTED`. The current KNXnet/IP Secure flag reserves and reports secure service capability, while runtime secure-session transport is still reported as not implemented until that path is wired. + +When `GATEWAY_KNX_SECURITY_DEV_ENDPOINTS` is enabled, the bridge HTTP action surface exposes development-only operations for reading, writing, generating, and resetting the factory setup key, exporting the factory certificate payload, and clearing local KNX security failure diagnostics. These endpoints require explicit confirmation fields in the JSON body and should stay disabled in production builds. The default development storage mode is plain NVS via `GATEWAY_KNX_SECURITY_PLAIN_NVS`; production builds should replace that with encrypted NVS, flash encryption, and secure boot before handling real commissioning keys. + +The normal bridge status response includes a `knx.security` object with compile-time capability flags, storage mode, factory setup key metadata, factory certificate metadata, and security failure counters/log entries. Secret FDSK strings are returned only by the explicit development actions, not by passive status polling. + ## Modbus Modbus TCP, RTU, and ASCII are owned by `gateway/components/gateway_modbus` and started through the per-channel bridge service. The gateway keeps the existing bridge config JSON shape with a top-level `modbus` object containing `transport`, `host`, `port`, and `unitID`, and now adds nested serial UART settings for RTU/ASCII. Parsing and runtime behavior live in the gateway project rather than in `dali_cpp`. diff --git a/components/gateway_bridge/src/gateway_bridge.cpp b/components/gateway_bridge/src/gateway_bridge.cpp index 7856b2a..401c4ec 100644 --- a/components/gateway_bridge/src/gateway_bridge.cpp +++ b/components/gateway_bridge/src/gateway_bridge.cpp @@ -82,6 +82,11 @@ struct DaliKnxStatusUpdate { using BridgeDiscoveryInventory = std::map; +extern "C" uint8_t knx_platform_copy_security_failures(uint8_t* counters, size_t countersLen, + uint8_t* records, size_t recordSize, + size_t maxRecords) __attribute__((weak)); +extern "C" void knx_platform_clear_security_failures() __attribute__((weak)); + class LockGuard { public: explicit LockGuard(SemaphoreHandle_t lock) : lock_(lock) { @@ -150,11 +155,90 @@ cJSON* FactoryFdskInfoToCjson(const openknx::FactoryFdskInfo& fdsk_info, return root; } +cJSON* FactoryCertificateToCjson(const openknx::FactoryCertificatePayload& certificate, + bool include_secret_strings) { + cJSON* root = cJSON_CreateObject(); + if (root == nullptr) { + return nullptr; + } + cJSON_AddBoolToObject(root, "available", certificate.available); + if (!certificate.available) { + return root; + } + cJSON_AddStringToObject(root, "productIdentity", certificate.productIdentity.c_str()); + cJSON_AddStringToObject(root, "manufacturerId", certificate.manufacturerId.c_str()); + cJSON_AddStringToObject(root, "applicationNumber", certificate.applicationNumber.c_str()); + cJSON_AddStringToObject(root, "applicationVersion", certificate.applicationVersion.c_str()); + cJSON_AddStringToObject(root, "serialNumber", certificate.serialNumber.c_str()); + cJSON_AddStringToObject(root, "storage", certificate.storage.c_str()); + cJSON_AddStringToObject(root, "createdAt", certificate.createdAt.c_str()); + cJSON_AddStringToObject(root, "checksum", certificate.checksum.c_str()); + cJSON_AddNumberToObject(root, "payloadLength", + static_cast(certificate.fdskLabel.size() + + certificate.fdskQrCode.size())); + if (include_secret_strings) { + cJSON_AddStringToObject(root, "fdskLabel", certificate.fdskLabel.c_str()); + cJSON_AddStringToObject(root, "fdskQrCode", certificate.fdskQrCode.c_str()); + } + return root; +} + +cJSON* SecurityFailuresToCjson() { + cJSON* root = cJSON_CreateObject(); + if (root == nullptr) { + return nullptr; + } + cJSON* counters_json = cJSON_CreateArray(); + cJSON* log_json = cJSON_CreateArray(); + std::array counters{}; + std::array records{}; + uint8_t count = 0; +#if defined(CONFIG_GATEWAY_KNX_DATA_SECURE_SUPPORTED) + if (knx_platform_copy_security_failures != nullptr) { + count = knx_platform_copy_security_failures(counters.data(), counters.size(), records.data(), 8, 8); + } +#endif + if (counters_json != nullptr) { + for (uint8_t value : counters) { + cJSON_AddItemToArray(counters_json, cJSON_CreateNumber(static_cast(value))); + } + cJSON_AddItemToObject(root, "counters", counters_json); + } + if (log_json != nullptr) { + for (uint8_t index = 0; index < count && index < 8; ++index) { + const size_t offset = static_cast(index) * 8U; + cJSON* entry = cJSON_CreateObject(); + if (entry == nullptr) { + continue; + } + cJSON_AddNumberToObject(entry, "class", records[offset]); + cJSON_AddNumberToObject(entry, "detail", records[offset + 1]); + cJSON_AddNumberToObject(entry, "source", + static_cast((records[offset + 2] << 8) | records[offset + 3])); + cJSON_AddNumberToObject(entry, "destination", + static_cast((records[offset + 4] << 8) | records[offset + 5])); + cJSON_AddNumberToObject(entry, "count", records[offset + 6]); + cJSON_AddNumberToObject(entry, "loadState", records[offset + 7]); + cJSON_AddItemToArray(log_json, entry); + } + cJSON_AddItemToObject(root, "log", log_json); + } + return root; +} + const char* JsonString(const cJSON* parent, const char* name) { const cJSON* item = cJSON_GetObjectItemCaseSensitive(parent, name); return cJSON_IsString(item) && item->valuestring != nullptr ? item->valuestring : nullptr; } +[[maybe_unused]] bool JsonBool(const cJSON* parent, const char* name, bool fallback = false) { + const cJSON* item = cJSON_GetObjectItemCaseSensitive(parent, name); + if (cJSON_IsBool(item)) { + return cJSON_IsTrue(item); + } + return fallback; +} + std::optional JsonInt(const cJSON* parent, const char* name) { const cJSON* item = cJSON_GetObjectItemCaseSensitive(parent, name); if (!cJSON_IsNumber(item)) { @@ -2127,7 +2211,12 @@ struct GatewayBridgeService::ChannelRuntime { #else cJSON_AddBoolToObject(security_json, "knxnetIpSecureCompiled", false); #endif - cJSON_AddBoolToObject(security_json, "knxnetIpSecureImplemented", false); +#if defined(CONFIG_GATEWAY_KNX_IP_SECURE_SUPPORTED) + cJSON_AddBoolToObject(security_json, "knxnetIpSecureServicesRecognized", true); +#else + cJSON_AddBoolToObject(security_json, "knxnetIpSecureServicesRecognized", false); +#endif + cJSON_AddBoolToObject(security_json, "knxnetIpSecureImplemented", false); #if defined(CONFIG_GATEWAY_KNX_SECURITY_DEV_ENDPOINTS) cJSON_AddBoolToObject(security_json, "developmentEndpointsEnabled", true); #else @@ -2146,6 +2235,15 @@ struct GatewayBridgeService::ChannelRuntime { if (fdsk_json != nullptr) { cJSON_AddItemToObject(security_json, "factorySetupKey", fdsk_json); } + cJSON* certificate_json = FactoryCertificateToCjson( + openknx::BuildFactoryCertificatePayload(), false); + if (certificate_json != nullptr) { + cJSON_AddItemToObject(security_json, "factoryCertificate", certificate_json); + } + cJSON* failures_json = SecurityFailuresToCjson(); + if (failures_json != nullptr) { + cJSON_AddItemToObject(security_json, "failures", failures_json); + } #endif cJSON_AddItemToObject(knx_json, "security", security_json); } @@ -4163,6 +4261,170 @@ GatewayBridgeHttpResponse GatewayBridgeService::handlePost( #else return ErrorResponse(ESP_ERR_NOT_SUPPORTED, "KNX security development endpoints are disabled"); +#endif + } + if (action == "knx_security_generate_factory_key") { +#if defined(CONFIG_GATEWAY_KNX_SECURITY_DEV_ENDPOINTS) && \ + defined(CONFIG_GATEWAY_KNX_DATA_SECURE_SUPPORTED) + cJSON* body_root = body.empty() ? nullptr : cJSON_ParseWithLength(body.data(), body.size()); + if (body_root == nullptr) { + return ErrorResponse(ESP_ERR_INVALID_ARG, "confirmation JSON is required"); + } + const char* confirm = JsonString(body_root, "confirm"); + const bool include_secret = JsonBool(body_root, "includeSecret", false); + const bool confirmed = confirm != nullptr && + std::string_view(confirm) == "generate-factory-setup-key"; + cJSON_Delete(body_root); + if (!confirmed) { + return ErrorResponse(ESP_ERR_INVALID_ARG, "factory setup key generation confirmation is required"); + } + openknx::FactoryFdskInfo info; + if (!openknx::GenerateFactoryFdsk(&info)) { + return ErrorResponse(ESP_FAIL, "failed to generate factory setup key"); + } + cJSON* response = cJSON_CreateObject(); + if (response == nullptr) { + return ErrorResponse(ESP_ERR_NO_MEM, "failed to allocate security response"); + } + cJSON* fdsk_json = FactoryFdskInfoToCjson(info, include_secret); + if (fdsk_json != nullptr) { + cJSON_AddItemToObject(response, "factorySetupKey", fdsk_json); + } + return JsonOk(response); +#else + return ErrorResponse(ESP_ERR_NOT_SUPPORTED, + "KNX security development endpoints are disabled"); +#endif + } + if (action == "knx_security_write_factory_key") { +#if defined(CONFIG_GATEWAY_KNX_SECURITY_DEV_ENDPOINTS) && \ + defined(CONFIG_GATEWAY_KNX_DATA_SECURE_SUPPORTED) + cJSON* body_root = body.empty() ? nullptr : cJSON_ParseWithLength(body.data(), body.size()); + if (body_root == nullptr) { + return ErrorResponse(ESP_ERR_INVALID_ARG, "factory setup key JSON is required"); + } + const char* confirm = JsonString(body_root, "confirm"); + const char* key_hex = JsonString(body_root, "keyHex"); + const bool include_secret = JsonBool(body_root, "includeSecret", false); + const bool confirmed = confirm != nullptr && + std::string_view(confirm) == "write-factory-setup-key"; + if (!confirmed || key_hex == nullptr) { + cJSON_Delete(body_root); + return ErrorResponse(ESP_ERR_INVALID_ARG, + "factory setup key write confirmation and keyHex are required"); + } + const std::string key_value(key_hex); + cJSON_Delete(body_root); + openknx::FactoryFdskInfo info; + if (!openknx::WriteFactoryFdskHex(key_value, &info)) { + return ErrorResponse(ESP_ERR_INVALID_ARG, "invalid factory setup key hex value"); + } + cJSON* response = cJSON_CreateObject(); + if (response == nullptr) { + return ErrorResponse(ESP_ERR_NO_MEM, "failed to allocate security response"); + } + cJSON* fdsk_json = FactoryFdskInfoToCjson(info, include_secret); + if (fdsk_json != nullptr) { + cJSON_AddItemToObject(response, "factorySetupKey", fdsk_json); + } + return JsonOk(response); +#else + return ErrorResponse(ESP_ERR_NOT_SUPPORTED, + "KNX security development endpoints are disabled"); +#endif + } + if (action == "knx_security_reset_factory_key") { +#if defined(CONFIG_GATEWAY_KNX_SECURITY_DEV_ENDPOINTS) && \ + defined(CONFIG_GATEWAY_KNX_DATA_SECURE_SUPPORTED) + cJSON* body_root = body.empty() ? nullptr : cJSON_ParseWithLength(body.data(), body.size()); + if (body_root == nullptr) { + return ErrorResponse(ESP_ERR_INVALID_ARG, "confirmation JSON is required"); + } + const char* confirm = JsonString(body_root, "confirm"); + const bool confirmed = confirm != nullptr && + std::string_view(confirm) == "reset-factory-setup-key"; + cJSON_Delete(body_root); + if (!confirmed) { + return ErrorResponse(ESP_ERR_INVALID_ARG, "factory setup key reset confirmation is required"); + } + openknx::FactoryFdskInfo info; + if (!openknx::ResetFactoryFdskCache(&info)) { + return ErrorResponse(ESP_FAIL, "failed to reload factory setup key"); + } + cJSON* response = cJSON_CreateObject(); + if (response == nullptr) { + return ErrorResponse(ESP_ERR_NO_MEM, "failed to allocate security response"); + } + cJSON* fdsk_json = FactoryFdskInfoToCjson(info, false); + if (fdsk_json != nullptr) { + cJSON_AddItemToObject(response, "factorySetupKey", fdsk_json); + } + return JsonOk(response); +#else + return ErrorResponse(ESP_ERR_NOT_SUPPORTED, + "KNX security development endpoints are disabled"); +#endif + } + if (action == "knx_security_export_factory_certificate") { +#if defined(CONFIG_GATEWAY_KNX_SECURITY_DEV_ENDPOINTS) && \ + defined(CONFIG_GATEWAY_KNX_DATA_SECURE_SUPPORTED) + cJSON* body_root = body.empty() ? nullptr : cJSON_ParseWithLength(body.data(), body.size()); + if (body_root == nullptr) { + return ErrorResponse(ESP_ERR_INVALID_ARG, "confirmation JSON is required"); + } + const char* confirm = JsonString(body_root, "confirm"); + const bool confirmed = confirm != nullptr && + std::string_view(confirm) == "export-factory-certificate"; + cJSON_Delete(body_root); + if (!confirmed) { + return ErrorResponse(ESP_ERR_INVALID_ARG, "factory certificate export confirmation is required"); + } + cJSON* response = cJSON_CreateObject(); + if (response == nullptr) { + return ErrorResponse(ESP_ERR_NO_MEM, "failed to allocate security response"); + } + cJSON* certificate_json = FactoryCertificateToCjson( + openknx::BuildFactoryCertificatePayload(), true); + if (certificate_json == nullptr) { + cJSON_Delete(response); + return ErrorResponse(ESP_ERR_NO_MEM, "failed to allocate factory certificate response"); + } + cJSON_AddItemToObject(response, "factoryCertificate", certificate_json); + return JsonOk(response); +#else + return ErrorResponse(ESP_ERR_NOT_SUPPORTED, + "KNX security development endpoints are disabled"); +#endif + } + if (action == "knx_security_clear_failures") { +#if defined(CONFIG_GATEWAY_KNX_SECURITY_DEV_ENDPOINTS) && \ + defined(CONFIG_GATEWAY_KNX_DATA_SECURE_SUPPORTED) + cJSON* body_root = body.empty() ? nullptr : cJSON_ParseWithLength(body.data(), body.size()); + if (body_root == nullptr) { + return ErrorResponse(ESP_ERR_INVALID_ARG, "confirmation JSON is required"); + } + const char* confirm = JsonString(body_root, "confirm"); + const bool confirmed = confirm != nullptr && + std::string_view(confirm) == "clear-security-failures"; + cJSON_Delete(body_root); + if (!confirmed) { + return ErrorResponse(ESP_ERR_INVALID_ARG, "security failure clear confirmation is required"); + } + if (knx_platform_clear_security_failures != nullptr) { + knx_platform_clear_security_failures(); + } + cJSON* response = cJSON_CreateObject(); + if (response == nullptr) { + return ErrorResponse(ESP_ERR_NO_MEM, "failed to allocate security response"); + } + cJSON* failures_json = SecurityFailuresToCjson(); + if (failures_json != nullptr) { + cJSON_AddItemToObject(response, "failures", failures_json); + } + return JsonOk(response); +#else + return ErrorResponse(ESP_ERR_NOT_SUPPORTED, + "KNX security development endpoints are disabled"); #endif } if (action == "bacnet_start") { diff --git a/components/gateway_knx/include/gateway_knx.hpp b/components/gateway_knx/include/gateway_knx.hpp index 424f431..d515ac8 100644 --- a/components/gateway_knx/include/gateway_knx.hpp +++ b/components/gateway_knx/include/gateway_knx.hpp @@ -216,8 +216,11 @@ class GatewayKnxTpIpRouter { void handleConnectionStateRequest(const uint8_t* body, size_t len, const ::sockaddr_in& remote); void handleDisconnectRequest(const uint8_t* body, size_t len, const ::sockaddr_in& remote); + void handleSecureService(uint16_t service, const uint8_t* body, size_t len, + const ::sockaddr_in& remote); void sendTunnellingAck(uint8_t channel_id, uint8_t sequence, uint8_t status, const ::sockaddr_in& remote); + void sendSecureSessionStatus(uint8_t status, const ::sockaddr_in& remote); void sendTunnelIndication(const uint8_t* data, size_t len); void sendConnectionStateResponse(uint8_t channel_id, uint8_t status, const ::sockaddr_in& remote); diff --git a/components/gateway_knx/src/gateway_knx.cpp b/components/gateway_knx/src/gateway_knx.cpp index e88a816..9603e92 100644 --- a/components/gateway_knx/src/gateway_knx.cpp +++ b/components/gateway_knx/src/gateway_knx.cpp @@ -35,6 +35,12 @@ constexpr uint16_t kServiceDisconnectResponse = 0x020a; constexpr uint16_t kServiceTunnellingRequest = 0x0420; constexpr uint16_t kServiceTunnellingAck = 0x0421; constexpr uint16_t kServiceRoutingIndication = 0x0530; +constexpr uint16_t kServiceSecureWrapper = 0x0950; +constexpr uint16_t kServiceSecureSessionRequest = 0x0951; +constexpr uint16_t kServiceSecureSessionResponse = 0x0952; +constexpr uint16_t kServiceSecureSessionAuth = 0x0953; +constexpr uint16_t kServiceSecureSessionStatus = 0x0954; +constexpr uint16_t kServiceSecureGroupSync = 0x0955; constexpr uint8_t kKnxNetIpHeaderSize = 0x06; constexpr uint8_t kKnxNetIpVersion10 = 0x10; constexpr uint8_t kKnxNoError = 0x00; @@ -42,6 +48,8 @@ constexpr uint8_t kKnxErrorConnectionId = 0x21; constexpr uint8_t kKnxErrorConnectionType = 0x22; constexpr uint8_t kKnxErrorNoMoreConnections = 0x24; constexpr uint8_t kKnxErrorSequenceNumber = 0x04; +constexpr uint8_t kKnxSecureStatusAuthFailed = 0x01; +constexpr uint8_t kKnxSecureStatusUnauthenticated = 0x02; constexpr uint8_t kKnxConnectionTypeTunnel = 0x04; constexpr uint8_t kKnxTunnelLayerLink = 0x02; constexpr uint8_t kTpUartResetRequest = 0x01; @@ -507,6 +515,20 @@ bool ParseKnxNetIpHeader(const uint8_t* data, size_t len, uint16_t* service, return *total_len >= 6 && *total_len <= len; } +bool IsKnxNetIpSecureService(uint16_t service) { + switch (service) { + case kServiceSecureWrapper: + case kServiceSecureSessionRequest: + case kServiceSecureSessionResponse: + case kServiceSecureSessionAuth: + case kServiceSecureSessionStatus: + case kServiceSecureGroupSync: + return true; + default: + return false; + } +} + bool IsExtendedTpFrame(const uint8_t* data, size_t len) { return len > 0 && (data[0] & 0xD3) == 0x10; } @@ -1875,6 +1897,10 @@ void GatewayKnxTpIpRouter::handleUdpDatagram(const uint8_t* data, size_t len, } const uint8_t* body = data + 6; const size_t body_len = total_len - 6; + if (IsKnxNetIpSecureService(service)) { + handleSecureService(service, body, body_len, remote); + return; + } switch (service) { case kServiceRoutingIndication: if (config_.multicast_enabled) { @@ -2001,6 +2027,34 @@ void GatewayKnxTpIpRouter::handleDisconnectRequest(const uint8_t* body, size_t l sendDisconnectResponse(channel_id, status, remote); } +void GatewayKnxTpIpRouter::handleSecureService(uint16_t service, const uint8_t* body, + size_t len, const sockaddr_in& remote) { +#if defined(CONFIG_GATEWAY_KNX_IP_SECURE_SUPPORTED) + switch (service) { + case kServiceSecureSessionRequest: + case kServiceSecureSessionAuth: + ESP_LOGW(kTag, "KNXnet/IP Secure service 0x%04x rejected: secure sessions are not provisioned", service); + sendSecureSessionStatus(kKnxSecureStatusAuthFailed, remote); + break; + case kServiceSecureWrapper: + ESP_LOGW(kTag, "KNXnet/IP Secure wrapper rejected: no authenticated secure session"); + sendSecureSessionStatus(kKnxSecureStatusUnauthenticated, remote); + break; + case kServiceSecureGroupSync: + ESP_LOGD(kTag, "KNXnet/IP Secure group sync ignored until secure routing is provisioned"); + break; + default: + ESP_LOGD(kTag, "KNXnet/IP Secure service 0x%04x ignored", service); + break; + } +#else + (void)service; + (void)body; + (void)len; + (void)remote; +#endif +} + void GatewayKnxTpIpRouter::sendTunnellingAck(uint8_t channel_id, uint8_t sequence, uint8_t status, const sockaddr_in& remote) { const std::vector body{0x04, channel_id, sequence, status}; @@ -2008,6 +2062,12 @@ void GatewayKnxTpIpRouter::sendTunnellingAck(uint8_t channel_id, uint8_t sequenc SendAll(udp_sock_, packet.data(), packet.size(), remote); } +void GatewayKnxTpIpRouter::sendSecureSessionStatus(uint8_t status, const sockaddr_in& remote) { + const std::vector body{status, 0x00}; + const auto packet = KnxNetIpPacket(kServiceSecureSessionStatus, body); + SendAll(udp_sock_, packet.data(), packet.size(), remote); +} + void GatewayKnxTpIpRouter::sendTunnelIndication(const uint8_t* data, size_t len) { if (!tunnel_connected_ || udp_sock_ < 0 || data == nullptr || len == 0) { return; diff --git a/components/openknx_idf/include/openknx_idf/security_storage.h b/components/openknx_idf/include/openknx_idf/security_storage.h index fbabaa5..5faea8f 100644 --- a/components/openknx_idf/include/openknx_idf/security_storage.h +++ b/components/openknx_idf/include/openknx_idf/security_storage.h @@ -13,7 +13,25 @@ struct FactoryFdskInfo { std::string qrCode; }; +struct FactoryCertificatePayload { + bool available{false}; + std::string productIdentity; + std::string manufacturerId; + std::string applicationNumber; + std::string applicationVersion; + std::string serialNumber; + std::string fdskLabel; + std::string fdskQrCode; + std::string storage; + std::string createdAt; + std::string checksum; +}; + bool LoadFactoryFdsk(uint8_t* data, size_t len); FactoryFdskInfo LoadFactoryFdskInfo(); +bool GenerateFactoryFdsk(FactoryFdskInfo* info = nullptr); +bool WriteFactoryFdskHex(const std::string& hex_key, FactoryFdskInfo* info = nullptr); +bool ResetFactoryFdskCache(FactoryFdskInfo* info = nullptr); +FactoryCertificatePayload BuildFactoryCertificatePayload(); } // namespace gateway::openknx diff --git a/components/openknx_idf/src/security_storage.cpp b/components/openknx_idf/src/security_storage.cpp index af8fa9e..4a5165f 100644 --- a/components/openknx_idf/src/security_storage.cpp +++ b/components/openknx_idf/src/security_storage.cpp @@ -3,6 +3,7 @@ #include "esp_log.h" #include "esp_mac.h" #include "esp_random.h" +#include "esp_timer.h" #include "nvs.h" #include "nvs_flash.h" @@ -10,6 +11,7 @@ #include #include #include +#include #include namespace { @@ -20,6 +22,11 @@ constexpr const char* kFactoryFdskKey = "factory_fdsk"; constexpr size_t kFdskSize = 16; constexpr size_t kSerialSize = 6; constexpr size_t kFdskQrSize = 36; +constexpr const char* kProductIdentity = "REG1-Dali"; +constexpr const char* kManufacturerId = "00A4"; +constexpr const char* kApplicationNumber = "01"; +constexpr const char* kApplicationVersion = "05"; +constexpr const char* kDevelopmentStorage = "plain_nvs_development"; constexpr uint8_t kCrc4Tab[16] = { 0x0, 0x3, 0x6, 0x5, 0xc, 0xf, 0xa, 0x9, 0xb, 0x8, 0xd, 0xe, 0x7, 0x4, 0x1, 0x2, @@ -27,6 +34,8 @@ constexpr uint8_t kCrc4Tab[16] = { constexpr char kBase32Alphabet[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; constexpr char kHexAlphabet[] = "0123456789ABCDEF"; +extern "C" void knx_platform_clear_cached_fdsk() __attribute__((weak)); + bool ensureNvsReady() { const esp_err_t err = nvs_flash_init(); if (err == ESP_ERR_NVS_NO_FREE_PAGES || err == ESP_ERR_NVS_NEW_VERSION_FOUND) { @@ -54,6 +63,75 @@ void generateKey(uint8_t* data) { } while (!plausibleKey(data)); } +void clearOpenKnxFdskCache() { + if (knx_platform_clear_cached_fdsk != nullptr) { + knx_platform_clear_cached_fdsk(); + } +} + +int fromHexDigit(char value) { + if (value >= '0' && value <= '9') { + return value - '0'; + } + if (value >= 'a' && value <= 'f') { + return value - 'a' + 10; + } + if (value >= 'A' && value <= 'F') { + return value - 'A' + 10; + } + return -1; +} + +bool parseHexKey(const std::string& value, uint8_t* out) { + std::string digits; + digits.reserve(value.size()); + for (char ch : value) { + if (ch == ':' || ch == '-' || ch == ' ' || ch == '\t' || ch == '\n' || ch == '\r') { + continue; + } + if (fromHexDigit(ch) < 0) { + return false; + } + digits.push_back(ch); + } + if (digits.size() != kFdskSize * 2U) { + return false; + } + for (size_t index = 0; index < kFdskSize; ++index) { + const int hi = fromHexDigit(digits[index * 2U]); + const int lo = fromHexDigit(digits[index * 2U + 1U]); + if (hi < 0 || lo < 0) { + return false; + } + out[index] = static_cast((hi << 4) | lo); + } + return plausibleKey(out); +} + +bool storeFactoryFdsk(const uint8_t* data) { + if (data == nullptr || !plausibleKey(data) || !ensureNvsReady()) { + return false; + } + + nvs_handle_t handle = 0; + esp_err_t err = nvs_open(kNamespace, NVS_READWRITE, &handle); + if (err != ESP_OK) { + ESP_LOGW(kTag, "failed to open KNX security NVS namespace: %s", esp_err_to_name(err)); + return false; + } + err = nvs_set_blob(handle, kFactoryFdskKey, data, kFdskSize); + if (err == ESP_OK) { + err = nvs_commit(handle); + } + nvs_close(handle); + if (err != ESP_OK) { + ESP_LOGW(kTag, "failed to store KNX factory FDSK: %s", esp_err_to_name(err)); + return false; + } + clearOpenKnxFdskCache(); + return true; +} + uint8_t crc4Array(const uint8_t* data, size_t len) { uint8_t crc = 0; for (size_t i = 0; i < len; ++i) { @@ -121,6 +199,21 @@ std::string formatFdskLabel(const std::string& qr_code) { return label; } +std::string fnv1aHex(const std::string& value) { + uint32_t hash = 2166136261u; + for (unsigned char ch : value) { + hash ^= ch; + hash *= 16777619u; + } + std::array bytes{ + static_cast((hash >> 24) & 0xff), + static_cast((hash >> 16) & 0xff), + static_cast((hash >> 8) & 0xff), + static_cast(hash & 0xff), + }; + return toHex(bytes.data(), bytes.size()); +} + } // namespace namespace gateway::openknx { @@ -174,6 +267,68 @@ FactoryFdskInfo LoadFactoryFdskInfo() { return info; } +bool GenerateFactoryFdsk(FactoryFdskInfo* info) { + std::array key{}; + generateKey(key.data()); + const bool stored = storeFactoryFdsk(key.data()); + std::fill(key.begin(), key.end(), 0); + if (!stored) { + return false; + } + if (info != nullptr) { + *info = LoadFactoryFdskInfo(); + } + return true; +} + +bool WriteFactoryFdskHex(const std::string& hex_key, FactoryFdskInfo* info) { + std::array key{}; + if (!parseHexKey(hex_key, key.data())) { + return false; + } + const bool stored = storeFactoryFdsk(key.data()); + std::fill(key.begin(), key.end(), 0); + if (!stored) { + return false; + } + if (info != nullptr) { + *info = LoadFactoryFdskInfo(); + } + return true; +} + +bool ResetFactoryFdskCache(FactoryFdskInfo* info) { + clearOpenKnxFdskCache(); + const auto loaded = LoadFactoryFdskInfo(); + if (info != nullptr) { + *info = loaded; + } + return loaded.available; +} + +FactoryCertificatePayload BuildFactoryCertificatePayload() { + FactoryCertificatePayload payload; + const auto info = LoadFactoryFdskInfo(); + if (!info.available) { + return payload; + } + payload.available = true; + payload.productIdentity = kProductIdentity; + payload.manufacturerId = kManufacturerId; + payload.applicationNumber = kApplicationNumber; + payload.applicationVersion = kApplicationVersion; + payload.serialNumber = info.serialNumber; + payload.fdskLabel = info.label; + payload.fdskQrCode = info.qrCode; + payload.storage = kDevelopmentStorage; + payload.createdAt = "uptime_us:" + std::to_string(esp_timer_get_time()); + payload.checksum = fnv1aHex(payload.productIdentity + "|" + payload.manufacturerId + "|" + + payload.applicationNumber + "|" + payload.applicationVersion + "|" + + payload.serialNumber + "|" + payload.fdskLabel + "|" + + payload.fdskQrCode + "|" + payload.createdAt); + return payload; +} + } // namespace gateway::openknx extern "C" bool knx_platform_get_fdsk(uint8_t* data, size_t len) { diff --git a/knx b/knx index 1549366..5d7d6e5 160000 --- a/knx +++ b/knx @@ -1 +1 @@ -Subproject commit 1549366447435aa24cfe063b4e95e7d2ca8de899 +Subproject commit 5d7d6e573bacc8217487843013afd72f8bfdd52c