diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 3f5a34bc73..4b73a22e97 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -193,6 +193,13 @@ PSRAM_XIP_VARIANTS = { # expose the HMAC peripheral (SOC_HMAC_SUPPORTED in soc_caps.h). The original # ESP32 and ESP32-C2 do not have it. New variants with an HMAC peripheral # should be added here. +# Variants whose ROM exports a full-format vsnprintf but no vasprintf +# (esp32c6.rom.newlib-normal.ld). There, the newlib printf engine is only +# linked because esp_http_client calls vasprintf; see vasprintf_stubs.cpp. +# The other variants either export both (classic ESP32, nano-format only) or +# neither, so the engine is already in the image and the wrap saves nothing. +ROM_VSNPRINTF_WITHOUT_VASPRINTF_VARIANTS = {VARIANT_ESP32C6} + NVS_ENCRYPTION_HMAC_VARIANTS = { VARIANT_ESP32S2, VARIANT_ESP32S3, @@ -1732,6 +1739,8 @@ CONF_DISABLE_USB_SERIAL_JTAG_SECONDARY = "disable_usb_serial_jtag_secondary" CONF_DISABLE_DEV_NULL_VFS = "disable_dev_null_vfs" CONF_DISABLE_MBEDTLS_PEER_CERT = "disable_mbedtls_peer_cert" CONF_DISABLE_MBEDTLS_PKCS7 = "disable_mbedtls_pkcs7" +CONF_DISABLE_MBEDTLS_TLS_SERVER = "disable_mbedtls_tls_server" +CONF_DISABLE_MBEDTLS_TLS_EXTRAS = "disable_mbedtls_tls_extras" CONF_DISABLE_REGI2C_IN_IRAM = "disable_regi2c_in_iram" CONF_DISABLE_FATFS = "disable_fatfs" CONF_ADC_ONESHOT_IN_IRAM = "adc_oneshot_in_iram" @@ -1746,6 +1755,8 @@ KEY_VFS_TERMIOS_REQUIRED = "vfs_termios_required" KEY_USB_SERIAL_JTAG_SECONDARY_REQUIRED = "usb_serial_jtag_secondary_required" KEY_MBEDTLS_PEER_CERT_REQUIRED = "mbedtls_peer_cert_required" KEY_MBEDTLS_PKCS7_REQUIRED = "mbedtls_pkcs7_required" +KEY_MBEDTLS_TLS_SERVER_REQUIRED = "mbedtls_tls_server_required" +KEY_MBEDTLS_TLS_EXTRAS_REQUIRED = "mbedtls_tls_extras_required" KEY_FATFS_REQUIRED = "fatfs_required" KEY_MBEDTLS_SHA512_REQUIRED = "mbedtls_sha512_required" KEY_ADC_ONESHOT_IRAM_REQUIRED = "adc_oneshot_iram_required" @@ -1830,6 +1841,26 @@ def require_mbedtls_pkcs7() -> None: CORE.data[KEY_ESP32][KEY_MBEDTLS_PKCS7_REQUIRED] = True +def require_mbedtls_tls_server() -> None: + """Mark that the mbedTLS server-side TLS/DTLS handshake is required. + + Call this from components that accept TLS connections (OpenThread's DTLS + commissioner does). This prevents CONFIG_MBEDTLS_TLS_CLIENT_ONLY from + being selected. + """ + CORE.data[KEY_ESP32][KEY_MBEDTLS_TLS_SERVER_REQUIRED] = True + + +def require_mbedtls_tls_extras() -> None: + """Mark that the TLS features disabled by ``disable_mbedtls_tls_extras`` are required. + + Call this from components that need AES-CCM, deterministic ECDSA signing, + static RSA/ECDH key exchange, TLS renegotiation or session tickets. + OpenThread uses CCM and deterministic ECDSA directly. + """ + CORE.data[KEY_ESP32][KEY_MBEDTLS_TLS_EXTRAS_REQUIRED] = True + + def require_mbedtls_sha512() -> None: """Mark that mbedTLS SHA-384/SHA-512 support is required by a component. @@ -1987,6 +2018,8 @@ FRAMEWORK_SCHEMA = cv.Schema( cv.Optional(CONF_DISABLE_DEV_NULL_VFS, default=True): cv.boolean, cv.Optional(CONF_DISABLE_MBEDTLS_PEER_CERT, default=True): cv.boolean, cv.Optional(CONF_DISABLE_MBEDTLS_PKCS7, default=True): cv.boolean, + cv.Optional(CONF_DISABLE_MBEDTLS_TLS_SERVER, default=True): cv.boolean, + cv.Optional(CONF_DISABLE_MBEDTLS_TLS_EXTRAS, default=True): cv.boolean, cv.Optional(CONF_DISABLE_REGI2C_IN_IRAM, default=True): cv.boolean, cv.Optional(CONF_ADC_ONESHOT_IN_IRAM, default=False): cv.boolean, cv.Optional(CONF_DISABLE_FATFS, default=True): cv.boolean, @@ -2302,6 +2335,47 @@ async def _reconcile_certificate_bundle_sdkconfig() -> None: set_idf_sdkconfig_default("CONFIG_MBEDTLS_CERTIFICATE_BUNDLE_DEFAULT_CMN", True) +# TLS features an HTTPS/MQTT client talking to a modern server never +# negotiates. Static RSA and static ECDH key exchange have no forward secrecy +# and are gone in TLS 1.3, renegotiation is deprecated, esp-tls never enables +# session tickets, AES-CCM ciphersuites are not offered by web servers, and +# the EC key parsing extras and deterministic ECDSA only matter when signing +# with a private key. Together they cost ~11 KB of flash whenever TLS is +# linked (http_request, mqtt). +MBEDTLS_TLS_EXTRA_OPTIONS = ( + "CONFIG_MBEDTLS_KEY_EXCHANGE_RSA", + "CONFIG_MBEDTLS_KEY_EXCHANGE_ECDH_ECDSA", + "CONFIG_MBEDTLS_KEY_EXCHANGE_ECDH_RSA", + "CONFIG_MBEDTLS_SSL_RENEGOTIATION", + "CONFIG_MBEDTLS_CLIENT_SSL_SESSION_TICKETS", + "CONFIG_MBEDTLS_SERVER_SSL_SESSION_TICKETS", + "CONFIG_MBEDTLS_CCM_C", + "CONFIG_MBEDTLS_PK_PARSE_EC_EXTENDED", + "CONFIG_MBEDTLS_PK_PARSE_EC_COMPRESSED", + "CONFIG_MBEDTLS_ECDSA_DETERMINISTIC", +) + + +@coroutine_with_priority(CoroPriority.FINAL) +async def _reconcile_mbedtls_tls_sdkconfig( + disable_tls_server: bool, disable_tls_extras: bool +) -> None: + """Trim mbedTLS to what a TLS client needs unless a component asked otherwise. + + Runs at FINAL priority so every require_mbedtls_tls_server() and + require_mbedtls_tls_extras() call has happened. Only the server-side + handshake (~7 KB) is a separate option; nothing in ESPHome accepts TLS + connections, but OpenThread's DTLS commissioner does. + """ + data = CORE.data[KEY_ESP32] + if disable_tls_server and not data.get(KEY_MBEDTLS_TLS_SERVER_REQUIRED, False): + add_idf_sdkconfig_option("CONFIG_MBEDTLS_TLS_CLIENT_ONLY", True) + add_idf_sdkconfig_option("CONFIG_MBEDTLS_TLS_SERVER_AND_CLIENT", False) + if disable_tls_extras and not data.get(KEY_MBEDTLS_TLS_EXTRAS_REQUIRED, False): + for option in MBEDTLS_TLS_EXTRA_OPTIONS: + add_idf_sdkconfig_option(option, False) + + @coroutine_with_priority(CoroPriority.FINAL) async def _reconcile_network_sdkconfig() -> None: """Reconcile WiFi/Ethernet/Bluetooth/coexistence sdkconfig flags. @@ -2566,6 +2640,17 @@ async def to_code(config): else: for symbol in ("vprintf", "printf", "fprintf", "vfprintf"): cg.add_build_flag(f"-Wl,--wrap={symbol}") + # esp_http_client calls vasprintf, which on the ESP32-C6 is the only + # reference to newlib's full printf engine (~20 KB: _svfprintf_r, + # _dtoa_r and their helpers); every other caller resolves to the + # ROM. See vasprintf_stubs.cpp. The --undefined flag is needed + # because libsrc.a is scanned before the IDF libraries that + # reference the symbol, so the stub would otherwise never be pulled + # from the archive. + if variant in ROM_VSNPRINTF_WITHOUT_VASPRINTF_VARIANTS: + cg.add_define("USE_ESP32_VASPRINTF_STUB") + cg.add_build_flag("-Wl,--wrap=vasprintf") + cg.add_build_flag("-Wl,--undefined=__wrap_vasprintf") else: cg.add_build_flag("-DUSE_ARDUINO") cg.add_build_flag("-DUSE_ESP32_FRAMEWORK_ARDUINO") @@ -2991,6 +3076,13 @@ async def to_code(config): # FINAL priority: runs after every require_certificate_bundle() call CORE.add_job(_reconcile_certificate_bundle_sdkconfig) + # FINAL priority: runs after every require_mbedtls_tls_*() call + CORE.add_job( + _reconcile_mbedtls_tls_sdkconfig, + advanced[CONF_DISABLE_MBEDTLS_TLS_SERVER], + advanced[CONF_DISABLE_MBEDTLS_TLS_EXTRAS], + ) + # FINAL: require_*() calls can come from to_code at or below this priority, so an # inline read would be iteration-order-dependent; reconcile once after every job ran. CORE.add_job( diff --git a/esphome/components/esp32/vasprintf_stubs.cpp b/esphome/components/esp32/vasprintf_stubs.cpp new file mode 100644 index 0000000000..f53b2d2c77 --- /dev/null +++ b/esphome/components/esp32/vasprintf_stubs.cpp @@ -0,0 +1,52 @@ +/* + * Linker wrap stub for vasprintf() on variants whose ROM exports a + * full-format vsnprintf() but no vasprintf() (ESP32-C6, newlib only). + * + * On those chips every snprintf/vsnprintf call in the image resolves to + * the ROM, so the newlib printf engine (_svfprintf_r, _dtoa_r and their + * helpers, ~20 KB) is not linked at all until something references a + * printf-family function the ROM lacks. esp_http_client does exactly that + * through vasprintf() in its header and auth helpers, so adding + * http_request to a build costs the whole engine on top of the HTTP and + * TLS code itself. + * + * This stub reimplements vasprintf() on top of the ROM vsnprintf(), which + * keeps the engine out of the image. It is only compiled in when codegen + * defines USE_ESP32_VASPRINTF_STUB, which is gated on the variant's ROM + * linker script and on the same newlib condition as printf_stubs.cpp. + */ + +#include "esphome/core/defines.h" + +#if defined(USE_ESP_IDF) && defined(USE_ESP32_VASPRINTF_STUB) + +#include +#include +#include + +namespace esphome::esp32 {} + +// NOLINTBEGIN(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming) +extern "C" { + +int __wrap_vasprintf(char **strp, const char *fmt, va_list ap) { + va_list ap_copy; + va_copy(ap_copy, ap); + int len = vsnprintf(nullptr, 0, fmt, ap_copy); + va_end(ap_copy); + if (len < 0) { + return len; + } + char *buf = static_cast(malloc(static_cast(len) + 1)); + if (buf == nullptr) { + return -1; + } + vsnprintf(buf, static_cast(len) + 1, fmt, ap); + *strp = buf; + return len; +} + +} // extern "C" +// NOLINTEND(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming) + +#endif // USE_ESP_IDF && USE_ESP32_VASPRINTF_STUB diff --git a/esphome/components/openthread/__init__.py b/esphome/components/openthread/__init__.py index ab69f5d9ae..52ed7ef9b4 100644 --- a/esphome/components/openthread/__init__.py +++ b/esphome/components/openthread/__init__.py @@ -13,6 +13,8 @@ from esphome.components.esp32 import ( get_esp32_variant, include_builtin_idf_component, only_on_variant, + require_mbedtls_tls_extras, + require_mbedtls_tls_server, require_vfs_select, ) from esphome.components.mdns import MDNSComponent, enable_mdns_storage @@ -109,6 +111,12 @@ def set_sdkconfig_options(config: ConfigType) -> None: add_idf_sdkconfig_option("CONFIG_OPENTHREAD_ENABLED", True) + # OpenThread's DTLS commissioner is a TLS server, and its crypto platform + # uses AES-CCM and deterministic ECDSA directly. Keep the esp32 component + # from trimming them out of mbedTLS. + require_mbedtls_tls_server() + require_mbedtls_tls_extras() + if not config.get(CONF_TLV): if pan_id := config.get(CONF_PAN_ID): add_idf_sdkconfig_option("CONFIG_OPENTHREAD_NETWORK_PANID", pan_id) diff --git a/esphome/core/defines.h b/esphome/core/defines.h index c3b16d833a..9144e65576 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -298,6 +298,7 @@ // ESP32-specific feature flags #ifdef USE_ESP32 #define USE_ESP32_CRASH_HANDLER +#define USE_ESP32_VASPRINTF_STUB #define USE_ESP32_INTERNAL_GPIO #define USE_MQTT_IDF_ENQUEUE #define USE_ESPHOME_TASK_LOG_BUFFER diff --git a/tests/component_tests/esp32/config/mbedtls_tls_default.yaml b/tests/component_tests/esp32/config/mbedtls_tls_default.yaml new file mode 100644 index 0000000000..b29e5de2bd --- /dev/null +++ b/tests/component_tests/esp32/config/mbedtls_tls_default.yaml @@ -0,0 +1,14 @@ +esphome: + name: test + +esp32: + board: esp32dev + framework: + type: esp-idf + +wifi: + ssid: "test_ssid" + password: "test_password" + +http_request: + verify_ssl: true diff --git a/tests/component_tests/esp32/config/mbedtls_tls_openthread.yaml b/tests/component_tests/esp32/config/mbedtls_tls_openthread.yaml new file mode 100644 index 0000000000..62ca893d2c --- /dev/null +++ b/tests/component_tests/esp32/config/mbedtls_tls_openthread.yaml @@ -0,0 +1,19 @@ +esphome: + name: test + +esp32: + variant: esp32c6 + framework: + type: esp-idf + +network: + enable_ipv6: true + +openthread: + channel: 13 + network_name: OpenThread-8f28 + network_key: 0xdfd34f0f05cad978ec4e32b0413038ff + pan_id: 0x8f28 + ext_pan_id: 0xd63e8e3e495ebbc3 + pskc: 0xc23a76e98f1a6483639b1ac1271e2e27 + mesh_local_prefix: fd53:145f:ed22:ad81::/64 diff --git a/tests/component_tests/esp32/config/mbedtls_tls_opt_out.yaml b/tests/component_tests/esp32/config/mbedtls_tls_opt_out.yaml new file mode 100644 index 0000000000..e675848391 --- /dev/null +++ b/tests/component_tests/esp32/config/mbedtls_tls_opt_out.yaml @@ -0,0 +1,17 @@ +esphome: + name: test + +esp32: + board: esp32dev + framework: + type: esp-idf + advanced: + disable_mbedtls_tls_server: false + disable_mbedtls_tls_extras: false + +wifi: + ssid: "test_ssid" + password: "test_password" + +http_request: + verify_ssl: true diff --git a/tests/component_tests/esp32/config/vasprintf_stub_c6.yaml b/tests/component_tests/esp32/config/vasprintf_stub_c6.yaml new file mode 100644 index 0000000000..8fa28e7c0f --- /dev/null +++ b/tests/component_tests/esp32/config/vasprintf_stub_c6.yaml @@ -0,0 +1,7 @@ +esphome: + name: test + +esp32: + variant: esp32c6 + framework: + type: esp-idf diff --git a/tests/component_tests/esp32/config/vasprintf_stub_c6_full_printf.yaml b/tests/component_tests/esp32/config/vasprintf_stub_c6_full_printf.yaml new file mode 100644 index 0000000000..075c3913b5 --- /dev/null +++ b/tests/component_tests/esp32/config/vasprintf_stub_c6_full_printf.yaml @@ -0,0 +1,9 @@ +esphome: + name: test + +esp32: + variant: esp32c6 + framework: + type: esp-idf + advanced: + enable_full_printf: true diff --git a/tests/component_tests/esp32/test_esp32.py b/tests/component_tests/esp32/test_esp32.py index 759020c732..491b8501bb 100644 --- a/tests/component_tests/esp32/test_esp32.py +++ b/tests/component_tests/esp32/test_esp32.py @@ -11,9 +11,12 @@ import pytest from esphome.components.esp32 import ( KEY_FATFS_REQUIRED, + KEY_MBEDTLS_TLS_EXTRAS_REQUIRED, + KEY_MBEDTLS_TLS_SERVER_REQUIRED, KEY_VFS_DIR_REQUIRED, KEY_VFS_SELECT_REQUIRED, KEY_VFS_TERMIOS_REQUIRED, + MBEDTLS_TLS_EXTRA_OPTIONS, VARIANT_ESP32, VARIANTS, NetworkSdkconfigData, @@ -1339,3 +1342,67 @@ def test_esp32_s31_gpio_validation( with caplog.at_level("WARNING"): validate_supports(pin) assert "GPIO36 is a strapping PIN" in caplog.text + + +_TLS_SERVER_OPTIONS = ( + "CONFIG_MBEDTLS_TLS_CLIENT_ONLY", + "CONFIG_MBEDTLS_TLS_SERVER_AND_CLIENT", +) + + +@pytest.mark.parametrize( + ("config_file", "server", "extras"), + [ + pytest.param("mbedtls_tls_default.yaml", (True, False), False, id="default"), + pytest.param("mbedtls_tls_opt_out.yaml", (None, None), None, id="opt_out"), + pytest.param( + "mbedtls_tls_openthread.yaml", (None, None), None, id="openthread" + ), + ], +) +def test_mbedtls_tls_trim_sdkconfig( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], + config_file: str, + server: tuple[bool | None, bool | None], + extras: bool | None, +) -> None: + """Client-only TLS and the unused-feature trims apply unless opted out or required.""" + generate_main(component_config_path(config_file)) + sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] + assert tuple(sdkconfig.get(name) for name in _TLS_SERVER_OPTIONS) == server + assert {sdkconfig.get(name) for name in MBEDTLS_TLS_EXTRA_OPTIONS} == {extras} + + +def test_mbedtls_tls_openthread_requires_server_and_extras( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """The OpenThread hooks mark the DTLS server and CCM/deterministic ECDSA as required.""" + generate_main(component_config_path("mbedtls_tls_openthread.yaml")) + assert CORE.data[KEY_ESP32][KEY_MBEDTLS_TLS_SERVER_REQUIRED] is True + assert CORE.data[KEY_ESP32][KEY_MBEDTLS_TLS_EXTRAS_REQUIRED] is True + + +_VASPRINTF_STUB_FLAGS = {"-Wl,--wrap=vasprintf", "-Wl,--undefined=__wrap_vasprintf"} + + +@pytest.mark.parametrize( + ("config_file", "expected"), + [ + pytest.param("vasprintf_stub_c6.yaml", True, id="c6"), + pytest.param("vasprintf_stub_c6_full_printf.yaml", False, id="c6_full_printf"), + pytest.param("exclusion_reincludes.yaml", False, id="esp32"), + ], +) +def test_vasprintf_stub_only_on_rom_vsnprintf_variants( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], + config_file: str, + expected: bool, +) -> None: + """The vasprintf wrap is emitted only where the ROM lacks vasprintf but has vsnprintf.""" + generate_main(component_config_path(config_file)) + assert (CORE.build_flags >= _VASPRINTF_STUB_FLAGS) is expected + defines = {define.name for define in CORE.defines} + assert ("USE_ESP32_VASPRINTF_STUB" in defines) is expected diff --git a/tests/components/esp32/test.esp32-idf.yaml b/tests/components/esp32/test.esp32-idf.yaml index 523e614e24..7f31fe59c6 100644 --- a/tests/components/esp32/test.esp32-idf.yaml +++ b/tests/components/esp32/test.esp32-idf.yaml @@ -17,6 +17,8 @@ esp32: disable_dev_null_vfs: true disable_mbedtls_peer_cert: true disable_mbedtls_pkcs7: true + disable_mbedtls_tls_server: true + disable_mbedtls_tls_extras: true disable_regi2c_in_iram: true disable_fatfs: true sram1_as_iram: true