From e58115d30367a44f2b9b91a0cc946ddf11eebdae Mon Sep 17 00:00:00 2001 From: Tony Date: Tue, 12 May 2026 12:48:18 +0800 Subject: [PATCH] feat(gateway): add KNX Data Secure support and related configurations Signed-off-by: Tony --- apps/gateway/main/Kconfig.projbuild | 35 ++++ apps/gateway/sdkconfig | 4 + apps/gateway/sdkconfig.defaults | 3 +- .../gateway_bridge/src/gateway_bridge.cpp | 84 ++++++++ components/openknx_idf/CMakeLists.txt | 13 ++ .../include/openknx_idf/openknx_idf.h | 1 + .../include/openknx_idf/security_storage.h | 19 ++ .../openknx_idf/src/security_storage.cpp | 181 ++++++++++++++++++ knx | 2 +- 9 files changed, 340 insertions(+), 2 deletions(-) create mode 100644 components/openknx_idf/include/openknx_idf/security_storage.h create mode 100644 components/openknx_idf/src/security_storage.cpp diff --git a/apps/gateway/main/Kconfig.projbuild b/apps/gateway/main/Kconfig.projbuild index a512424..e40baff 100644 --- a/apps/gateway/main/Kconfig.projbuild +++ b/apps/gateway/main/Kconfig.projbuild @@ -621,6 +621,41 @@ config GATEWAY_START_KNX_BRIDGE_ENABLED Starts the KNXnet/IP tunneling/multicast listener at boot. Disabled by default so UDP port 3671 is opened only after provisioning or explicit start. +config GATEWAY_KNX_DATA_SECURE_SUPPORTED + bool "Enable KNX Data Secure support" + depends on GATEWAY_KNX_BRIDGE_SUPPORTED + default n + help + Compiles the OpenKNX SecurityInterfaceObject and SecureApplicationLayer + into the ETS runtime. This is the application-layer security path used + for secure KNX group-object and ETS tool traffic. + +config GATEWAY_KNX_IP_SECURE_SUPPORTED + bool "Enable KNXnet/IP Secure support" + depends on GATEWAY_KNX_BRIDGE_SUPPORTED + default n + help + Builds gateway support for KNXnet/IP Secure tunneling and routing. The + secure session transport is implemented by the gateway-owned KNX/IP + router and is separate from KNX Data Secure APDU handling. + +config GATEWAY_KNX_SECURITY_DEV_ENDPOINTS + bool "Enable KNX security development HTTP endpoints" + depends on GATEWAY_KNX_DATA_SECURE_SUPPORTED || GATEWAY_KNX_IP_SECURE_SUPPORTED + default n + help + Exposes development-only HTTP actions for reading, writing, generating, + and resetting KNX security material. Disable this for production builds. + +config GATEWAY_KNX_SECURITY_PLAIN_NVS + bool "Store KNX security material in plain NVS" + depends on GATEWAY_KNX_DATA_SECURE_SUPPORTED || GATEWAY_KNX_IP_SECURE_SUPPORTED + default y + help + Stores development KNX security material in normal NVS. This is useful + during bring-up, but production builds should replace it with encrypted + NVS, flash encryption, and secure boot before exposing real keys. + config GATEWAY_KNX_MAIN_GROUP int "KNX DALI main group" depends on GATEWAY_KNX_BRIDGE_SUPPORTED diff --git a/apps/gateway/sdkconfig b/apps/gateway/sdkconfig index 59a32a8..3911172 100644 --- a/apps/gateway/sdkconfig +++ b/apps/gateway/sdkconfig @@ -688,6 +688,10 @@ CONFIG_GATEWAY_BACNET_BRIDGE_SUPPORTED=y # CONFIG_GATEWAY_START_BACNET_BRIDGE_ENABLED is not set CONFIG_GATEWAY_KNX_BRIDGE_SUPPORTED=y CONFIG_GATEWAY_START_KNX_BRIDGE_ENABLED=y +CONFIG_GATEWAY_KNX_DATA_SECURE_SUPPORTED=y +# CONFIG_GATEWAY_KNX_IP_SECURE_SUPPORTED is not set +# CONFIG_GATEWAY_KNX_SECURITY_DEV_ENDPOINTS is not set +CONFIG_GATEWAY_KNX_SECURITY_PLAIN_NVS=y CONFIG_GATEWAY_KNX_MAIN_GROUP=0 CONFIG_GATEWAY_KNX_TUNNEL_ENABLED=y CONFIG_GATEWAY_KNX_MULTICAST_ENABLED=y diff --git a/apps/gateway/sdkconfig.defaults b/apps/gateway/sdkconfig.defaults index 0397ed7..84ea107 100644 --- a/apps/gateway/sdkconfig.defaults +++ b/apps/gateway/sdkconfig.defaults @@ -15,4 +15,5 @@ CONFIG_ETH_USE_SPI_ETHERNET=y CONFIG_ETH_SPI_ETHERNET_W5500=y CONFIG_GATEWAY_ETHERNET_SUPPORTED=y CONFIG_GATEWAY_START_ETHERNET_ENABLED=y -CONFIG_GATEWAY_ETHERNET_IGNORE_INIT_FAILURE=y \ No newline at end of file +CONFIG_GATEWAY_ETHERNET_IGNORE_INIT_FAILURE=y +CONFIG_GATEWAY_KNX_DATA_SECURE_SUPPORTED=y diff --git a/components/gateway_bridge/src/gateway_bridge.cpp b/components/gateway_bridge/src/gateway_bridge.cpp index 94219b8..183b368 100644 --- a/components/gateway_bridge/src/gateway_bridge.cpp +++ b/components/gateway_bridge/src/gateway_bridge.cpp @@ -16,6 +16,7 @@ #include "gateway_modbus.hpp" #include "gateway_provisioning.hpp" #include "openknx_idf/ets_memory_loader.h" +#include "openknx_idf/security_storage.h" #include "cJSON.h" #include "driver/uart.h" @@ -121,6 +122,25 @@ GatewayBridgeHttpResponse ErrorResponse(esp_err_t err, const char* message) { return GatewayBridgeHttpResponse{err, body}; } +cJSON* FactoryFdskInfoToCjson(const openknx::FactoryFdskInfo& fdsk_info, + bool include_secret_strings) { + cJSON* root = cJSON_CreateObject(); + if (root == nullptr) { + return nullptr; + } + cJSON_AddBoolToObject(root, "available", fdsk_info.available); + if (fdsk_info.available) { + cJSON_AddStringToObject(root, "serialNumber", fdsk_info.serialNumber.c_str()); + cJSON_AddNumberToObject(root, "labelLength", static_cast(fdsk_info.label.size())); + cJSON_AddNumberToObject(root, "qrCodeLength", static_cast(fdsk_info.qrCode.size())); + if (include_secret_strings) { + cJSON_AddStringToObject(root, "label", fdsk_info.label.c_str()); + cJSON_AddStringToObject(root, "qrCode", fdsk_info.qrCode.c_str()); + } + } + 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; @@ -2041,6 +2061,40 @@ struct GatewayBridgeService::ChannelRuntime { cJSON_AddStringToObject(knx_json, "lastError", knx_last_error.empty() ? router_error.c_str() : knx_last_error.c_str()); + cJSON* security_json = cJSON_CreateObject(); + if (security_json != nullptr) { +#if defined(CONFIG_GATEWAY_KNX_DATA_SECURE_SUPPORTED) + cJSON_AddBoolToObject(security_json, "dataSecureCompiled", true); +#else + cJSON_AddBoolToObject(security_json, "dataSecureCompiled", false); +#endif +#if defined(CONFIG_GATEWAY_KNX_IP_SECURE_SUPPORTED) + cJSON_AddBoolToObject(security_json, "knxnetIpSecureCompiled", true); +#else + cJSON_AddBoolToObject(security_json, "knxnetIpSecureCompiled", false); +#endif + cJSON_AddBoolToObject(security_json, "knxnetIpSecureImplemented", false); +#if defined(CONFIG_GATEWAY_KNX_SECURITY_DEV_ENDPOINTS) + cJSON_AddBoolToObject(security_json, "developmentEndpointsEnabled", true); +#else + cJSON_AddBoolToObject(security_json, "developmentEndpointsEnabled", false); +#endif +#if defined(CONFIG_GATEWAY_KNX_SECURITY_PLAIN_NVS) + cJSON_AddBoolToObject(security_json, "plainNvsStorage", true); + cJSON_AddStringToObject(security_json, "storage", "plain_nvs_development"); +#else + cJSON_AddBoolToObject(security_json, "plainNvsStorage", false); + cJSON_AddStringToObject(security_json, "storage", "none"); +#endif +#if defined(CONFIG_GATEWAY_KNX_DATA_SECURE_SUPPORTED) + const auto fdsk_info = openknx::LoadFactoryFdskInfo(); + cJSON* fdsk_json = FactoryFdskInfoToCjson(fdsk_info, false); + if (fdsk_json != nullptr) { + cJSON_AddItemToObject(security_json, "factorySetupKey", fdsk_json); + } +#endif + cJSON_AddItemToObject(knx_json, "security", security_json); + } if (effective_knx.has_value()) { cJSON_AddBoolToObject(knx_json, "daliRouterEnabled", effective_knx->dali_router_enabled); cJSON_AddBoolToObject(knx_json, "ipRouterEnabled", effective_knx->ip_router_enabled); @@ -4008,6 +4062,36 @@ GatewayBridgeHttpResponse GatewayBridgeService::handlePost( } return handleGet("knx", gateway_id.value()); } + if (action == "knx_security_read_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) == "read-factory-setup-key"; + cJSON_Delete(body_root); + if (!confirmed) { + return ErrorResponse(ESP_ERR_INVALID_ARG, "factory setup key read confirmation is required"); + } + cJSON* response = cJSON_CreateObject(); + if (response == nullptr) { + return ErrorResponse(ESP_ERR_NO_MEM, "failed to allocate security response"); + } + cJSON* fdsk_json = FactoryFdskInfoToCjson(openknx::LoadFactoryFdskInfo(), true); + if (fdsk_json == nullptr) { + cJSON_Delete(response); + return ErrorResponse(ESP_ERR_NO_MEM, "failed to allocate factory setup key response"); + } + 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 == "bacnet_start") { const esp_err_t err = runtime->startBacnet(); if (err != ESP_OK) { diff --git a/components/openknx_idf/CMakeLists.txt b/components/openknx_idf/CMakeLists.txt index 322b8ac..02c02e0 100644 --- a/components/openknx_idf/CMakeLists.txt +++ b/components/openknx_idf/CMakeLists.txt @@ -13,6 +13,12 @@ file(GLOB OPENKNX_SRCS "${OPENKNX_ROOT}/src/knx/*.cpp" ) +if(CONFIG_GATEWAY_KNX_DATA_SECURE_SUPPORTED) + list(APPEND OPENKNX_SRCS + "${OPENKNX_ROOT}/src/knx/aes.c" + ) +endif() + set(TPUART_SRCS "${TPUART_ROOT}/src/TPUart/DataLinkLayer.cpp" "${TPUART_ROOT}/src/TPUart/Receiver.cpp" @@ -31,6 +37,7 @@ idf_component_register( "src/esp_idf_platform.cpp" "src/ets_device_runtime.cpp" "src/ets_memory_loader.cpp" + "src/security_storage.cpp" "src/tpuart_uart_interface.cpp" ${OPENKNX_SRCS} ${TPUART_SRCS} @@ -42,11 +49,13 @@ idf_component_register( esp_driver_gpio esp_driver_uart esp_netif + esp_system esp_timer esp_wifi freertos log lwip + mbedtls nvs_flash ) @@ -58,6 +67,10 @@ target_compile_definitions(${COMPONENT_LIB} PUBLIC USE_CEMI_SERVER ) +if(CONFIG_GATEWAY_KNX_DATA_SECURE_SUPPORTED) + target_compile_definitions(${COMPONENT_LIB} PUBLIC USE_DATASECURE) +endif() + target_compile_options(${COMPONENT_LIB} PRIVATE -Wno-unused-parameter ) diff --git a/components/openknx_idf/include/openknx_idf/openknx_idf.h b/components/openknx_idf/include/openknx_idf/openknx_idf.h index e8023a3..e1ed4c4 100644 --- a/components/openknx_idf/include/openknx_idf/openknx_idf.h +++ b/components/openknx_idf/include/openknx_idf/openknx_idf.h @@ -3,6 +3,7 @@ #include "openknx_idf/ets_memory_loader.h" #include "openknx_idf/ets_device_runtime.h" #include "openknx_idf/esp_idf_platform.h" +#include "openknx_idf/security_storage.h" #include "openknx_idf/tpuart_uart_interface.h" #include "knx/bau07B0.h" diff --git a/components/openknx_idf/include/openknx_idf/security_storage.h b/components/openknx_idf/include/openknx_idf/security_storage.h new file mode 100644 index 0000000..fbabaa5 --- /dev/null +++ b/components/openknx_idf/include/openknx_idf/security_storage.h @@ -0,0 +1,19 @@ +#pragma once + +#include +#include +#include + +namespace gateway::openknx { + +struct FactoryFdskInfo { + bool available{false}; + std::string serialNumber; + std::string label; + std::string qrCode; +}; + +bool LoadFactoryFdsk(uint8_t* data, size_t len); +FactoryFdskInfo LoadFactoryFdskInfo(); + +} // namespace gateway::openknx diff --git a/components/openknx_idf/src/security_storage.cpp b/components/openknx_idf/src/security_storage.cpp new file mode 100644 index 0000000..af8fa9e --- /dev/null +++ b/components/openknx_idf/src/security_storage.cpp @@ -0,0 +1,181 @@ +#include "openknx_idf/security_storage.h" + +#include "esp_log.h" +#include "esp_mac.h" +#include "esp_random.h" +#include "nvs.h" +#include "nvs_flash.h" + +#include +#include +#include +#include +#include + +namespace { + +constexpr const char* kTag = "openknx_sec"; +constexpr const char* kNamespace = "knx_sec"; +constexpr const char* kFactoryFdskKey = "factory_fdsk"; +constexpr size_t kFdskSize = 16; +constexpr size_t kSerialSize = 6; +constexpr size_t kFdskQrSize = 36; +constexpr uint8_t kCrc4Tab[16] = { + 0x0, 0x3, 0x6, 0x5, 0xc, 0xf, 0xa, 0x9, + 0xb, 0x8, 0xd, 0xe, 0x7, 0x4, 0x1, 0x2, +}; +constexpr char kBase32Alphabet[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; +constexpr char kHexAlphabet[] = "0123456789ABCDEF"; + +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) { + if (nvs_flash_erase() != ESP_OK) { + return false; + } + return nvs_flash_init() == ESP_OK; + } + return err == ESP_OK || err == ESP_ERR_INVALID_STATE; +} + +bool plausibleKey(const uint8_t* data) { + const bool all_zero = std::all_of(data, data + kFdskSize, [](uint8_t value) { + return value == 0x00; + }); + const bool all_ff = std::all_of(data, data + kFdskSize, [](uint8_t value) { + return value == 0xff; + }); + return !all_zero && !all_ff; +} + +void generateKey(uint8_t* data) { + do { + esp_fill_random(data, kFdskSize); + } while (!plausibleKey(data)); +} + +uint8_t crc4Array(const uint8_t* data, size_t len) { + uint8_t crc = 0; + for (size_t i = 0; i < len; ++i) { + crc = kCrc4Tab[crc ^ (data[i] >> 4)]; + crc = kCrc4Tab[crc ^ (data[i] & 0x0f)]; + } + return crc; +} + +std::string toBase32NoPadding(const uint8_t* data, size_t len) { + std::string result; + result.reserve(((len * 8) + 4) / 5); + + uint32_t buffer = 0; + int bits_left = 0; + for (size_t i = 0; i < len; ++i) { + buffer = (buffer << 8) | data[i]; + bits_left += 8; + while (bits_left >= 5) { + const uint8_t index = static_cast((buffer >> (bits_left - 5)) & 0x1f); + result.push_back(kBase32Alphabet[index]); + bits_left -= 5; + } + } + + if (bits_left > 0) { + const uint8_t index = static_cast((buffer << (5 - bits_left)) & 0x1f); + result.push_back(kBase32Alphabet[index]); + } + return result; +} + +std::string toHex(const uint8_t* data, size_t len) { + std::string result; + result.reserve(len * 2); + for (size_t i = 0; i < len; ++i) { + result.push_back(kHexAlphabet[(data[i] >> 4) & 0x0f]); + result.push_back(kHexAlphabet[data[i] & 0x0f]); + } + return result; +} + +std::string generateFdskQrCode(const uint8_t* serial, const uint8_t* key) { + std::array buffer{}; + std::copy(serial, serial + kSerialSize, buffer.begin()); + std::copy(key, key + kFdskSize, buffer.begin() + kSerialSize); + buffer[kSerialSize + kFdskSize] = static_cast((crc4Array(buffer.data(), buffer.size() - 1) << 4) & 0xff); + + std::string encoded = toBase32NoPadding(buffer.data(), buffer.size()); + if (encoded.size() > kFdskQrSize) { + encoded.resize(kFdskQrSize); + } + return encoded; +} + +std::string formatFdskLabel(const std::string& qr_code) { + std::string label; + label.reserve(qr_code.size() + (qr_code.size() / 6)); + for (size_t i = 0; i < qr_code.size(); ++i) { + if (i != 0 && (i % 6) == 0) { + label.push_back('-'); + } + label.push_back(qr_code[i]); + } + return label; +} + +} // namespace + +namespace gateway::openknx { + +bool LoadFactoryFdsk(uint8_t* data, size_t len) { + if (data == nullptr || len < kFdskSize || !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; + } + + size_t stored_size = kFdskSize; + err = nvs_get_blob(handle, kFactoryFdskKey, data, &stored_size); + if (err == ESP_OK && stored_size == kFdskSize && plausibleKey(data)) { + nvs_close(handle); + return true; + } + + generateKey(data); + 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 generated KNX factory FDSK: %s", esp_err_to_name(err)); + return false; + } + return true; +} + +FactoryFdskInfo LoadFactoryFdskInfo() { + FactoryFdskInfo info; + std::array key{}; + std::array serial{}; + if (!LoadFactoryFdsk(key.data(), key.size()) || + esp_read_mac(serial.data(), ESP_MAC_WIFI_STA) != ESP_OK) { + return info; + } + + info.available = true; + info.serialNumber = toHex(serial.data(), serial.size()); + info.qrCode = generateFdskQrCode(serial.data(), key.data()); + info.label = formatFdskLabel(info.qrCode); + return info; +} + +} // namespace gateway::openknx + +extern "C" bool knx_platform_get_fdsk(uint8_t* data, size_t len) { + return gateway::openknx::LoadFactoryFdsk(data, len); +} diff --git a/knx b/knx index 339d847..5da3b1f 160000 --- a/knx +++ b/knx @@ -1 +1 @@ -Subproject commit 339d8472e79d1c7f652a54f1097f0a476e5f1b80 +Subproject commit 5da3b1f30ec2b4f3c52938edbb5d7abe328b3a8f