From 72413494b0bdbfc1b1b71c440313855a0d76dd8a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 7 Oct 2026 05:29:17 -1000 Subject: [PATCH] [esp32] Disable mbedTLS TLS and TLS-only crypto when no component needs them (#18877) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> --- esphome/components/audio/__init__.py | 5 +- esphome/components/esp32/__init__.py | 319 +++++++++++----- esphome/components/esp32/const.py | 1 + esphome/components/http_request/__init__.py | 6 +- esphome/components/mqtt/__init__.py | 5 +- esphome/components/nextion/display.py | 5 +- esphome/components/openthread/__init__.py | 9 +- esphome/components/web_server_idf/__init__.py | 5 +- .../web_server_idf/web_server_idf.cpp | 12 +- esphome/components/wifi/__init__.py | 9 +- esphome/components/zigbee/zigbee_esp32.py | 4 + .../esp32/config/mbedtls_tls_wifi_eap.yaml | 3 - .../esp32/config/tls_keep_opt_out.yaml | 9 + .../esp32/config/tls_sdkconfig_esp_tls.yaml | 9 + .../config/tls_sdkconfig_tls_enabled_n.yaml | 9 + tests/component_tests/esp32/test_esp32.py | 349 +++++++++++++++++- 16 files changed, 626 insertions(+), 133 deletions(-) create mode 100644 tests/component_tests/esp32/config/tls_keep_opt_out.yaml create mode 100644 tests/component_tests/esp32/config/tls_sdkconfig_esp_tls.yaml create mode 100644 tests/component_tests/esp32/config/tls_sdkconfig_tls_enabled_n.yaml diff --git a/esphome/components/audio/__init__.py b/esphome/components/audio/__init__.py index b882aaa6b7..1480faa8c3 100644 --- a/esphome/components/audio/__init__.py +++ b/esphome/components/audio/__init__.py @@ -6,7 +6,7 @@ import esphome.codegen as cg from esphome.components.esp32 import ( add_idf_component, add_idf_sdkconfig_option, - include_builtin_idf_component, + request_http_client, require_certificate_bundle, ) import esphome.config_validation as cv @@ -334,8 +334,7 @@ def _emit_memory_pair(value: str | None, psram_key: str, internal_key: str) -> N async def to_code(config: ConfigType) -> None: - # Re-enable ESP-IDF's HTTP client (excluded by default to save compile time) - include_builtin_idf_component("esp_http_client") + request_http_client() # HTTPS streams verify the server against the root certificate bundle require_certificate_bundle() diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index ce1a66a4d2..df72c590a3 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -1,6 +1,6 @@ from collections.abc import Callable, Iterable import contextlib -from dataclasses import dataclass +from dataclasses import dataclass, field import itertools import logging import os @@ -76,6 +76,7 @@ from .const import ( KEY_FLASH_SIZE, KEY_FULL_CERT_BUNDLE, KEY_IDF_VERSION, + KEY_MBEDTLS_SDKCONFIG, KEY_NETWORK_SDKCONFIG, KEY_PATH, KEY_REF, @@ -237,7 +238,7 @@ DEFAULT_EXCLUDED_IDF_COMPONENTS = ( "cmock", # Unit testing mock framework - ESPHome doesn't use IDF's testing "console", # Console REPL - unused by ESPHome; espressif/mdns pulls it back when configured "driver", # Legacy driver shim - only needed by esp32_touch, esp32_can for legacy headers - "esp-tls", # TLS wrapper - re-included by http_request, mqtt, web_server_idf + "esp-tls", # TLS wrapper - re-included by request_tls() "esp_adc", # ADC driver - only needed by adc component "esp_coex", # WiFi/BT coexistence - re-included by esp32_ble_tracker, zigbee; esp_wifi/bt pull it back "esp_driver_cam", # Camera driver - the esp32-camera managed component pulls it back @@ -812,6 +813,97 @@ def request_software_coexistence() -> None: include_builtin_idf_component("esp_coex") +@dataclass +class MbedtlsSdkconfigData: + """Inputs for the mbedTLS sdkconfig flags, reconciled at FINAL. + + Components call the require_mbedtls_*() helpers (and request_tls(), which + sets tls_required and also un-excludes the esp-tls component) rather than + writing the CONFIG_MBEDTLS_* flags directly; _reconcile_mbedtls_sdkconfig() + decides the final values once every to_code has run. + """ + + ecp_required: bool = False # ECDH/ECDSA without TLS (openthread SRP host key) + tls_required: bool = False # mbedTLS TLS role needed without the esp-tls wrapper + tls_server_required: bool = False # server-side TLS/DTLS handshake + tls_extras_required: set[str] = field(default_factory=set) # kept TLS extras + peer_cert_required: bool = False # keep the peer certificate after the handshake + pkcs7_required: bool = False # PKCS#7 parsing + sha512_required: bool = False # SHA-384/SHA-512 + # esp32 advanced disable_mbedtls_* options + disable_tls: bool = True + disable_tls_server: bool = True + disable_tls_extras: bool = True + disable_peer_cert: bool = True + disable_pkcs7: bool = True + + +def _mbedtls_sdkconfig() -> MbedtlsSdkconfigData: + data = CORE.data[KEY_ESP32] + if KEY_MBEDTLS_SDKCONFIG not in data: + data[KEY_MBEDTLS_SDKCONFIG] = MbedtlsSdkconfigData() + return data[KEY_MBEDTLS_SDKCONFIG] + + +# IDF components that reference esp_tls symbols from their own code, so +# re-including any of them is an implicit TLS request. +_ESP_TLS_LINKING_COMPONENTS = ( + "esp-tls", + "esp_http_client", + "esp_https_ota", + "esp_https_server", + "esp_local_ctrl", + "mqtt", +) + + +def _mbedtls_tls_required() -> bool: + """TLS stays in the build: requested, or an esp_tls-linking component was re-included. + + The exclusion-set signal keeps external components working whose only + obligation before request_tls() existed was include_builtin_idf_component() + of esp-tls or of a component that links it (esp_http_client, IDF mqtt). + """ + if _mbedtls_sdkconfig().tls_required: + return True + excluded = CORE.data[KEY_ESP32][KEY_EXCLUDE_COMPONENTS] + return any(name not in excluded for name in _ESP_TLS_LINKING_COMPONENTS) + + +def _mbedtls_tls_compiled_out() -> bool: + """True when this build removes the TLS stack from mbedTLS entirely.""" + return ( + not CORE.using_arduino + and _mbedtls_sdkconfig().disable_tls + and not _mbedtls_tls_required() + ) + + +def require_mbedtls_tls() -> None: + """Keep the mbedTLS TLS stack without compiling the esp-tls wrapper. + + For code that talks to mbedTLS directly (wpa_supplicant's EAP client). + Components that use esp_tls call request_tls() instead. + """ + _mbedtls_sdkconfig().tls_required = True + + +def request_tls() -> None: + """Request the mbedTLS TLS stack and the esp-tls wrapper. + + Without a request TLS and its ECP/PEM-write/CRL/CSR crypto compile out; + hashes, AES and RSA stay available. + """ + require_mbedtls_tls() + include_builtin_idf_component("esp-tls") + + +def request_http_client() -> None: + """Request ESP-IDF's HTTP client; it links esp_tls even for plain http.""" + include_builtin_idf_component("esp_http_client") + request_tls() + + def add_idf_component( *, name: str, @@ -1847,6 +1939,7 @@ CONF_DISABLE_OCD_AWARE = "disable_ocd_aware" 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_TLS = "disable_mbedtls_tls" 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" @@ -1862,12 +1955,7 @@ KEY_VFS_TERMIOS_REQUIRED = "vfs_termios_required" # Feature requirement tracking - components can call require_* functions to re-enable # These are stored in CORE.data[KEY_ESP32] dict 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" KEY_LIBC_PICOLIBC_NEWLIB_COMPAT_REQUIRED = "libc_picolibc_newlib_compat_required" @@ -1906,6 +1994,9 @@ def require_certificate_bundle() -> None: certificates (http_request, audio streaming) call this so the bundle is compiled and gen_crt_bundle runs only when something uses it. """ + # esp_crt_bundle.c lives in the mbedtls component and calls + # mbedtls_ssl_conf_*, so a bundle needs the TLS role but not esp-tls. + require_mbedtls_tls() CORE.data[KEY_ESP32][KEY_CERT_BUNDLE] = True @@ -1931,33 +2022,37 @@ def require_usb_serial_jtag_secondary() -> None: CORE.data[KEY_ESP32][KEY_USB_SERIAL_JTAG_SECONDARY_REQUIRED] = True -def require_mbedtls_peer_cert() -> None: - """Mark that mbedTLS peer certificate retention is required by a component. +def require_mbedtls_ecp() -> None: + """Keep mbedTLS elliptic curve support (ECDH/ECDSA) without requesting TLS. - Call this from components that need access to the peer certificate after - the TLS handshake is complete. This prevents CONFIG_MBEDTLS_SSL_KEEP_PEER_CERTIFICATE - from being disabled. + Call this from components that sign or verify with ECDSA outside a TLS + handshake (openthread's SRP host key). WiFi, Bluetooth and secure boot + select it through Kconfig on their own. """ - CORE.data[KEY_ESP32][KEY_MBEDTLS_PEER_CERT_REQUIRED] = True + _mbedtls_sdkconfig().ecp_required = True + + +def require_mbedtls_peer_cert() -> None: + """Keep the peer certificate after the TLS handshake (CONFIG_MBEDTLS_SSL_KEEP_PEER_CERTIFICATE). + + A user sdkconfig_options value takes precedence. + """ + _mbedtls_sdkconfig().peer_cert_required = True def require_mbedtls_pkcs7() -> None: - """Mark that mbedTLS PKCS#7 support is required by a component. - - Call this from components that need PKCS#7 certificate validation. - This prevents CONFIG_MBEDTLS_PKCS7_C from being disabled. - """ - CORE.data[KEY_ESP32][KEY_MBEDTLS_PKCS7_REQUIRED] = True + """Keep mbedTLS PKCS#7 support (CONFIG_MBEDTLS_PKCS7_C). A user sdkconfig_options value takes precedence.""" + _mbedtls_sdkconfig().pkcs7_required = True def require_mbedtls_tls_server() -> None: - """Mark that the mbedTLS server-side TLS/DTLS handshake is required. + """Widen the TLS role to include the server-side handshake. - Call this from components that accept TLS connections (OpenThread's DTLS - commissioner does). This prevents CONFIG_MBEDTLS_TLS_CLIENT_ONLY from - being selected. + Only affects builds where TLS is compiled in; it prevents + CONFIG_MBEDTLS_TLS_CLIENT_ONLY from being selected. A component that + actually opens or accepts TLS/DTLS sessions must also call request_tls(). """ - CORE.data[KEY_ESP32][KEY_MBEDTLS_TLS_SERVER_REQUIRED] = True + _mbedtls_sdkconfig().tls_server_required = True def require_mbedtls_tls_extras(options: Iterable[str] | None = None) -> None: @@ -1970,18 +2065,14 @@ def require_mbedtls_tls_extras(options: Iterable[str] | None = None) -> None: servers ESPHome cannot vet (wpa_supplicant's EAP client). A user-supplied sdkconfig_options value is never overridden either. """ - required = CORE.data[KEY_ESP32].setdefault(KEY_MBEDTLS_TLS_EXTRAS_REQUIRED, set()) - required.update(MBEDTLS_TLS_EXTRA_OPTIONS if options is None else options) + _mbedtls_sdkconfig().tls_extras_required.update( + MBEDTLS_TLS_EXTRA_OPTIONS if options is None else options + ) def require_mbedtls_sha512() -> None: - """Mark that mbedTLS SHA-384/SHA-512 support is required by a component. - - Call this from components that need to verify TLS certificates or signatures - using SHA-384 or SHA-512 algorithms. This prevents CONFIG_MBEDTLS_SHA384_C - and CONFIG_MBEDTLS_SHA512_C from being disabled. - """ - CORE.data[KEY_ESP32][KEY_MBEDTLS_SHA512_REQUIRED] = True + """Keep mbedTLS SHA-384/SHA-512 (CONFIG_MBEDTLS_SHA384_C / CONFIG_MBEDTLS_SHA512_C).""" + _mbedtls_sdkconfig().sha512_required = True def idf_version() -> cv.Version: @@ -2135,6 +2226,7 @@ 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, 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, @@ -2500,34 +2592,103 @@ MBEDTLS_TLS_ROLE_OPTIONS = ( ) -@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. +# User sdkconfig_options that mean "keep TLS on" when set to y. The +# OpenThread entries compile its DTLS secure transport in, which links +# mbedtls_ssl_*. +_MBEDTLS_TLS_ON_OPTIONS = ( + "CONFIG_MBEDTLS_TLS_ENABLED", + "CONFIG_MBEDTLS_TLS_SERVER_AND_CLIENT", + "CONFIG_MBEDTLS_TLS_SERVER_ONLY", + "CONFIG_MBEDTLS_TLS_CLIENT_ONLY", + "CONFIG_OPENTHREAD_COMMISSIONER", + "CONFIG_OPENTHREAD_JOINER", + "CONFIG_OPENTHREAD_BORDER_AGENT_ENABLE", + # Border router defaults the border agent (and its DTLS) on. + "CONFIG_OPENTHREAD_BORDER_ROUTER", + # Enterprise WiFi selects TLS back on (see the TLS-off block). + "CONFIG_ESP_WIFI_ENTERPRISE_SUPPORT", +) +# Any user option under these prefixes only makes sense with TLS compiled in. +_TLS_OPTION_PREFIXES = ("CONFIG_ESP_TLS_", "CONFIG_MBEDTLS_SSL_", "CONFIG_ESP_HTTPS_") - 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. A user-supplied - sdkconfig_options value always wins; for the TLS role choice, any member - the user set leaves the whole choice alone so the pair cannot conflict. + +def _user_sdkconfig_wants_tls(options: dict[str, Any]) -> bool: + """True when sdkconfig_options turn TLS on or tune something under it; an `n` is never a request.""" + return any( + (name in _MBEDTLS_TLS_ON_OPTIONS and value == "y") + or (name == "CONFIG_MBEDTLS_TLS_DISABLED" and value == "n") + or (name.startswith(_TLS_OPTION_PREFIXES) and value != "n") + for name, value in options.items() + ) + + +@coroutine_with_priority(CoroPriority.FINAL) +async def _reconcile_mbedtls_sdkconfig() -> None: + """Reconcile the mbedTLS sdkconfig flags after every request_tls() / require_mbedtls_*() call. + + mbedtls cannot be excluded from an IDF build (bootloader_support needs its + SHA-256), but with no TLS user the ssl_*.c sources and the TLS-only crypto + compile to empty objects. When TLS stays in, it is trimmed to the client + role and the legacy handshake extras are dropped. User sdkconfig_options + win; a user-chosen TLS role leaves the whole choice alone. """ - data = CORE.data[KEY_ESP32] - sdkconfig = data[KEY_SDKCONFIG_OPTIONS] - if ( - disable_tls_server - and not data.get(KEY_MBEDTLS_TLS_SERVER_REQUIRED, False) - and not any(option in sdkconfig for option in MBEDTLS_TLS_ROLE_OPTIONS) + data = _mbedtls_sdkconfig() + idf6 = idf_version() >= cv.Version(6, 0, 0) + opts = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] + + if _mbedtls_tls_compiled_out(): + # IDF 6 made CONFIG_MBEDTLS_TLS_ENABLED a normal bool; on IDF 5 it has + # no prompt and is only reachable through the "None" TLS role choice. + if idf6: + set_idf_sdkconfig_default("CONFIG_MBEDTLS_TLS_ENABLED", False) + else: + set_idf_sdkconfig_default("CONFIG_MBEDTLS_TLS_DISABLED", True) + # Enterprise WiFi selects TLS back on; wifi writes this itself, but + # esp_wifi can also be in the build without a wifi: block (openthread). + set_idf_sdkconfig_default("CONFIG_ESP_WIFI_ENTERPRISE_SUPPORT", False) + # WiFi (ESP_WIFI_MBEDTLS_CRYPTO) and Bluetooth deliberately stay on + # the select-wins path: an unconditional request from request_wifi() + # would defeat the ECP trim for users who disable that select. + if not data.ecp_required: + set_idf_sdkconfig_default("CONFIG_MBEDTLS_ECP_C", False) + set_idf_sdkconfig_default("CONFIG_MBEDTLS_PEM_WRITE_C", False) + set_idf_sdkconfig_default("CONFIG_MBEDTLS_X509_CRL_PARSE_C", False) + set_idf_sdkconfig_default("CONFIG_MBEDTLS_X509_CSR_PARSE_C", False) + elif ( + # TLS stays in: trim it to the client role unless a component accepts + # TLS connections or the user already chose a role. + data.disable_tls_server + and not data.tls_server_required + and not any(option in opts for option in MBEDTLS_TLS_ROLE_OPTIONS) ): 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: - required = data.get(KEY_MBEDTLS_TLS_EXTRAS_REQUIRED, set()) + + # The extras run either way: CCM and deterministic ECDSA are plain + # crypto, not TLS-gated, so they matter even with TLS compiled out. + if data.disable_tls_extras: for option in MBEDTLS_TLS_EXTRA_OPTIONS: - if option not in required: + if option not in data.tls_extras_required: set_idf_sdkconfig_default(option, False) + # Keeping the peer certificate costs ~4KB heap per connection. + if data.peer_cert_required: + set_idf_sdkconfig_default("CONFIG_MBEDTLS_SSL_KEEP_PEER_CERTIFICATE", True) + elif data.disable_peer_cert: + set_idf_sdkconfig_default("CONFIG_MBEDTLS_SSL_KEEP_PEER_CERTIFICATE", False) + + if data.pkcs7_required: + set_idf_sdkconfig_default("CONFIG_MBEDTLS_PKCS7_C", True) + elif data.disable_pkcs7: + set_idf_sdkconfig_default("CONFIG_MBEDTLS_PKCS7_C", False) + + # SHA-384 shares the SHA-512 compression function, so both go together. + # Only IDF 6.0's PSA engine links a ~3KB software fallback for them; on + # IDF 5 they are a single hardware-only option with no code size cost. + if idf6 and not data.sha512_required: + set_idf_sdkconfig_default("CONFIG_MBEDTLS_SHA384_C", False) + set_idf_sdkconfig_default("CONFIG_MBEDTLS_SHA512_C", False) + @coroutine_with_priority(CoroPriority.FINAL) async def _reconcile_network_sdkconfig() -> None: @@ -3150,6 +3311,10 @@ async def to_code(config): for key, flag in SIGNING_SCHEMES.items(): add_idf_sdkconfig_option(flag, scheme == key) + if scheme in (SIGNING_SCHEME_ECDSA256, SIGNING_SCHEME_ECDSA_V1): + # SECURE_SIGNED_APPS selects ECP in Kconfig anyway; requesting it + # keeps the resolved sdkconfig consistent with what ESPHome wrote. + require_mbedtls_ecp() if CONF_SIGNING_KEY in signed_ota: # Private key mode — auto-sign binaries during build @@ -3220,38 +3385,6 @@ async def to_code(config): if advanced[CONF_DISABLE_DEV_NULL_VFS]: add_idf_sdkconfig_option("CONFIG_VFS_INITIALIZE_DEV_NULL", False) - # Disable keeping peer certificate after TLS handshake - # Saves ~4KB heap per connection, but prevents certificate inspection after handshake - # Components that need it can call require_mbedtls_peer_cert() - if CORE.data[KEY_ESP32].get(KEY_MBEDTLS_PEER_CERT_REQUIRED, False): - add_idf_sdkconfig_option("CONFIG_MBEDTLS_SSL_KEEP_PEER_CERTIFICATE", True) - elif advanced[CONF_DISABLE_MBEDTLS_PEER_CERT]: - add_idf_sdkconfig_option("CONFIG_MBEDTLS_SSL_KEEP_PEER_CERTIFICATE", False) - - # Disable PKCS#7 support in mbedTLS - # Only needed for specific certificate validation scenarios - # Components that need it can call require_mbedtls_pkcs7() - if CORE.data[KEY_ESP32].get(KEY_MBEDTLS_PKCS7_REQUIRED, False): - # Component called require_mbedtls_pkcs7() - enable regardless of user setting - add_idf_sdkconfig_option("CONFIG_MBEDTLS_PKCS7_C", True) - elif advanced[CONF_DISABLE_MBEDTLS_PKCS7]: - add_idf_sdkconfig_option("CONFIG_MBEDTLS_PKCS7_C", False) - - # Disable SHA-384 and SHA-512 in mbedTLS - # ESPHome doesn't use either algorithm. SHA-384 shares the same - # compression function as SHA-512 (mbedtls_internal_sha512_process), - # so both must be disabled to eliminate the ~3KB software fallback - # that IDF 6.0's PSA parallel engine always links in. - # On IDF < 6.0 these are a single config and hardware-only (no - # software fallback), so there was no code size cost to leaving - # them enabled. - # Components that need SHA-384/SHA-512 can call require_mbedtls_sha512() - if idf_version() >= cv.Version(6, 0, 0) and not CORE.data[KEY_ESP32].get( - KEY_MBEDTLS_SHA512_REQUIRED, False - ): - add_idf_sdkconfig_option("CONFIG_MBEDTLS_SHA384_C", False) - add_idf_sdkconfig_option("CONFIG_MBEDTLS_SHA512_C", False) - # FINAL priority: runs after every require_libc_picolibc_newlib_compat() call CORE.add_job(_set_libc_picolibc_newlib_compat) @@ -3261,12 +3394,14 @@ 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 priority: runs after every request_tls() / require_mbedtls_*() call + mbedtls = _mbedtls_sdkconfig() + mbedtls.disable_tls = advanced[CONF_DISABLE_MBEDTLS_TLS] + mbedtls.disable_tls_server = advanced[CONF_DISABLE_MBEDTLS_TLS_SERVER] + mbedtls.disable_tls_extras = advanced[CONF_DISABLE_MBEDTLS_TLS_EXTRAS] + mbedtls.disable_peer_cert = advanced[CONF_DISABLE_MBEDTLS_PEER_CERT] + mbedtls.disable_pkcs7 = advanced[CONF_DISABLE_MBEDTLS_PKCS7] + CORE.add_job(_reconcile_mbedtls_sdkconfig) # 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. @@ -3299,6 +3434,8 @@ async def to_code(config): # so it still gets the CMN variant pinned. if conf[CONF_SDKCONFIG_OPTIONS].get("CONFIG_MBEDTLS_CERTIFICATE_BUNDLE") == "y": require_certificate_bundle() + if _user_sdkconfig_wants_tls(conf[CONF_SDKCONFIG_OPTIONS]): + request_tls() # Components from YAML are added in a separate coroutine with FINAL priority # Schedule it to run after all other components diff --git a/esphome/components/esp32/const.py b/esphome/components/esp32/const.py index a0c9809c50..33751ea7d5 100644 --- a/esphome/components/esp32/const.py +++ b/esphome/components/esp32/const.py @@ -23,6 +23,7 @@ KEY_EXTRA_BUILD_FILES = "extra_build_files" KEY_CERT_BUNDLE = "cert_bundle" KEY_FULL_CERT_BUNDLE = "full_cert_bundle" KEY_NETWORK_SDKCONFIG = "network_sdkconfig" +KEY_MBEDTLS_SDKCONFIG = "mbedtls_sdkconfig" VARIANT_ESP32 = "ESP32" VARIANT_ESP32C2 = "ESP32C2" diff --git a/esphome/components/http_request/__init__.py b/esphome/components/http_request/__init__.py index de35d52a40..5faf989686 100644 --- a/esphome/components/http_request/__init__.py +++ b/esphome/components/http_request/__init__.py @@ -202,11 +202,7 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_watchdog_timeout(timeout_ms)) if CORE.is_esp32: - # Re-enable ESP-IDF's HTTP client (excluded by default to save compile time). - # esp-tls is re-enabled too because http_request includes - # directly and esp_http_client only pulls it in as a private dependency. - esp32.include_builtin_idf_component("esp_http_client") - esp32.include_builtin_idf_component("esp-tls") + esp32.request_http_client() cg.add(var.set_buffer_size_rx(config[CONF_BUFFER_SIZE_RX])) cg.add(var.set_buffer_size_tx(config[CONF_BUFFER_SIZE_TX])) diff --git a/esphome/components/mqtt/__init__.py b/esphome/components/mqtt/__init__.py index 9418614346..65a7cf409b 100644 --- a/esphome/components/mqtt/__init__.py +++ b/esphome/components/mqtt/__init__.py @@ -5,6 +5,7 @@ from esphome.components.esp32 import ( add_idf_component, idf_version, include_builtin_idf_component, + request_tls, ) from esphome.config_helpers import ( filter_source_files_from_defines, @@ -358,8 +359,8 @@ async def to_code(config): add_idf_component(name="espressif/mqtt", ref="1.0.0") else: include_builtin_idf_component("mqtt") - # mqtt_client.h drags in esp_tls types; esp-tls is excluded by default - include_builtin_idf_component("esp-tls") + # esp-mqtt links transport_ssl.c (esp_tls) even for plain MQTT + request_tls() cg.add_define("USE_MQTT") cg.add_global(mqtt_ns.using) diff --git a/esphome/components/nextion/display.py b/esphome/components/nextion/display.py index 73a56e0a42..18f0a73928 100644 --- a/esphome/components/nextion/display.py +++ b/esphome/components/nextion/display.py @@ -271,10 +271,7 @@ async def to_code(config): ) if CORE.is_esp32: - # Re-enable ESP-IDF's HTTP client (excluded by default to save compile time) - # and esp-tls, whose sdkconfig options below need the component present - esp32.include_builtin_idf_component("esp_http_client") - esp32.include_builtin_idf_component("esp-tls") + esp32.request_http_client() esp32.add_idf_sdkconfig_option("CONFIG_ESP_TLS_INSECURE", True) esp32.add_idf_sdkconfig_option( "CONFIG_ESP_TLS_SKIP_SERVER_CERT_VERIFY", True diff --git a/esphome/components/openthread/__init__.py b/esphome/components/openthread/__init__.py index ac14efc8e6..80b28a2628 100644 --- a/esphome/components/openthread/__init__.py +++ b/esphome/components/openthread/__init__.py @@ -13,6 +13,7 @@ from esphome.components.esp32 import ( get_esp32_variant, include_builtin_idf_component, only_on_variant, + require_mbedtls_ecp, require_mbedtls_tls_extras, require_mbedtls_tls_server, require_vfs_select, @@ -112,9 +113,9 @@ 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. + # Commissioner/joiner Kconfigs default off, so no mbedtls_ssl_* is linked; + # setting one under sdkconfig_options keeps TLS in the build automatically. + # The crypto platform uses AES-CCM and deterministic ECDSA directly. require_mbedtls_tls_server() require_mbedtls_tls_extras( ("CONFIG_MBEDTLS_CCM_C", "CONFIG_MBEDTLS_ECDSA_DETERMINISTIC") @@ -293,6 +294,8 @@ async def to_code(config: ConfigType) -> None: # Re-enable openthread IDF component (excluded by default) if CORE.is_esp32: include_builtin_idf_component("openthread") + # OPENTHREAD_CONFIG_ECDSA_ENABLE: the SRP client host key uses mbedtls_ecdsa_* + require_mbedtls_ecp() cg.add_define("USE_OPENTHREAD") if config.get(CONF_FORCE_DATASET): diff --git a/esphome/components/web_server_idf/__init__.py b/esphome/components/web_server_idf/__init__.py index 5eac63212f..b9d079b53f 100644 --- a/esphome/components/web_server_idf/__init__.py +++ b/esphome/components/web_server_idf/__init__.py @@ -17,9 +17,8 @@ CONFIG_SCHEMA = cv.All( async def to_code(config: ConfigType) -> None: # Increase the maximum supported size of headers section in HTTP request packet to be processed by the server set_idf_sdkconfig_default("CONFIG_HTTPD_MAX_REQ_HDR_LEN", 1024) - # Re-enable esp-tls (excluded by default to save compile time); - # web_server_idf.cpp includes for digest auth - include_builtin_idf_component("esp-tls") + # Re-enable ESP-IDF's HTTP server (excluded by default to save compile time). + # Basic auth uses mbedtls_base64_encode directly, so no TLS stack is needed. include_builtin_idf_component("esp_http_server") diff --git a/esphome/components/web_server_idf/web_server_idf.cpp b/esphome/components/web_server_idf/web_server_idf.cpp index 9798410e23..a35ad65214 100644 --- a/esphome/components/web_server_idf/web_server_idf.cpp +++ b/esphome/components/web_server_idf/web_server_idf.cpp @@ -12,7 +12,7 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" -#include "esp_tls_crypto.h" +#include #include #include @@ -554,14 +554,18 @@ bool AsyncWebServerRequest::authenticate(const char *username, const char *passw constexpr size_t max_digest_len = 350; char digest[max_digest_len]; size_t out; - esp_crypto_base64_encode(reinterpret_cast(digest), max_digest_len, &out, - reinterpret_cast(user_info), user_info_len); + // The buffer bound above makes failure unreachable; reject rather than + // compare against an unwritten digest if that ever changes. + if (mbedtls_base64_encode(reinterpret_cast(digest), max_digest_len, &out, + reinterpret_cast(user_info), user_info_len) != 0) { + return false; + } // Constant-time comparison to avoid timing side channels. // No early return on length mismatch — the length difference is folded // into the accumulator so any mismatch is rejected. const char *provided = auth_str + auth_prefix_len; - size_t digest_len = out; // length from esp_crypto_base64_encode + size_t digest_len = out; // Derive provided_len from the already-sized std::string rather than // rescanning with strlen (avoids attacker-controlled scan length). size_t provided_len = auth.value().size() - auth_prefix_len; diff --git a/esphome/components/wifi/__init__.py b/esphome/components/wifi/__init__.py index 27ebdbc976..f357bf75b3 100644 --- a/esphome/components/wifi/__init__.py +++ b/esphome/components/wifi/__init__.py @@ -12,6 +12,7 @@ from esphome.components.esp32 import ( get_esp32_variant, only_on_variant, request_wifi, + require_mbedtls_tls, require_mbedtls_tls_extras, ) from esphome.components.network import ( @@ -683,10 +684,10 @@ async def to_code(config): if CORE.is_esp32: add_idf_sdkconfig_option("CONFIG_ESP_WIFI_ENTERPRISE_SUPPORT", has_eap) if has_eap: - # wpa_supplicant's EAP client negotiates with whatever the RADIUS - # server offers, and a failed handshake leaves the device off the - # network, so keep every mbedTLS client feature the esp32 platform - # would otherwise trim. + # The supplicant's Kconfig select cannot override the IDF 5 TLS + # role choice; the EAP client talks to mbedTLS directly (no + # esp-tls) and needs every trimmed extra. + require_mbedtls_tls() require_mbedtls_tls_extras() # Only define USE_WIFI_MANUAL_IP if any AP uses manual IP diff --git a/esphome/components/zigbee/zigbee_esp32.py b/esphome/components/zigbee/zigbee_esp32.py index 463a398033..103e901b13 100644 --- a/esphome/components/zigbee/zigbee_esp32.py +++ b/esphome/components/zigbee/zigbee_esp32.py @@ -10,6 +10,7 @@ from esphome.components.esp32 import ( add_idf_sdkconfig_option, add_partition, include_builtin_idf_component, + require_mbedtls_ecp, require_mbedtls_tls_extras, require_vfs_select, ) @@ -384,6 +385,9 @@ async def esp32_to_code(config: ConfigType) -> "MockObj": name="espressif/esp-zigbee-lib", ref="2.0.4", ) + # The esp-zigbee-lib blobs reference mbedtls_ecp_* (Zigbee Direct, install + # code ECDH); keep ECP without relying on esp_wifi's Kconfig select. + require_mbedtls_ecp() # Zigbee's crypto platform uses AES-CCM and deterministic ECDSA directly. # Keep the esp32 component from trimming them out of mbedTLS. diff --git a/tests/component_tests/esp32/config/mbedtls_tls_wifi_eap.yaml b/tests/component_tests/esp32/config/mbedtls_tls_wifi_eap.yaml index 6c78e06265..ab77a1808e 100644 --- a/tests/component_tests/esp32/config/mbedtls_tls_wifi_eap.yaml +++ b/tests/component_tests/esp32/config/mbedtls_tls_wifi_eap.yaml @@ -12,6 +12,3 @@ wifi: identity: "user@example.org" username: "user" password: "secret" - -http_request: - verify_ssl: true diff --git a/tests/component_tests/esp32/config/tls_keep_opt_out.yaml b/tests/component_tests/esp32/config/tls_keep_opt_out.yaml new file mode 100644 index 0000000000..58b6692a8d --- /dev/null +++ b/tests/component_tests/esp32/config/tls_keep_opt_out.yaml @@ -0,0 +1,9 @@ +esphome: + name: test + +esp32: + board: esp32dev + framework: + type: esp-idf + advanced: + disable_mbedtls_tls: false diff --git a/tests/component_tests/esp32/config/tls_sdkconfig_esp_tls.yaml b/tests/component_tests/esp32/config/tls_sdkconfig_esp_tls.yaml new file mode 100644 index 0000000000..888a62b849 --- /dev/null +++ b/tests/component_tests/esp32/config/tls_sdkconfig_esp_tls.yaml @@ -0,0 +1,9 @@ +esphome: + name: test + +esp32: + board: esp32dev + framework: + type: esp-idf + sdkconfig_options: + CONFIG_ESP_TLS_INSECURE: y diff --git a/tests/component_tests/esp32/config/tls_sdkconfig_tls_enabled_n.yaml b/tests/component_tests/esp32/config/tls_sdkconfig_tls_enabled_n.yaml new file mode 100644 index 0000000000..5bd4e97130 --- /dev/null +++ b/tests/component_tests/esp32/config/tls_sdkconfig_tls_enabled_n.yaml @@ -0,0 +1,9 @@ +esphome: + name: test + +esp32: + board: esp32dev + framework: + type: esp-idf + sdkconfig_options: + CONFIG_MBEDTLS_TLS_ENABLED: n diff --git a/tests/component_tests/esp32/test_esp32.py b/tests/component_tests/esp32/test_esp32.py index b41234db1a..04416d7f57 100644 --- a/tests/component_tests/esp32/test_esp32.py +++ b/tests/component_tests/esp32/test_esp32.py @@ -11,25 +11,30 @@ from typing import Any import pytest from esphome.components.esp32 import ( + _ESP_TLS_LINKING_COMPONENTS, + DEFAULT_EXCLUDED_IDF_COMPONENTS, ESP32_FLASH_CHIPS, 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, + MbedtlsSdkconfigData, NetworkSdkconfigData, RawSdkconfigValue, _ota_downgrade_protection_errors, + _reconcile_mbedtls_sdkconfig, _reconcile_network_sdkconfig, _reconcile_vfs_fatfs_sdkconfig, + _user_sdkconfig_wants_tls, ) from esphome.components.esp32.const import ( KEY_ESP32, KEY_EXCLUDE_COMPONENTS, + KEY_IDF_VERSION, + KEY_MBEDTLS_SDKCONFIG, KEY_NETWORK_SDKCONFIG, KEY_SDKCONFIG_OPTIONS, KEY_VARIANT, @@ -322,8 +327,8 @@ def test_esp32_configuration_errors( ("esp_driver_i2c", "esp_driver_ledc", "esp_driver_gptimer"), id="i2c_ledc_ac_dimmer", ), - # esp-tls has three owners; a per-owner config makes a dropped - # re-include from any single one fail the test. + # esp-tls comes back through request_tls(); a per-owner config makes + # a dropped request from any single one fail the test. pytest.param( "exclusion_reincludes_http_request.yaml", ("esp-tls", "esp_http_client"), @@ -337,8 +342,9 @@ def test_esp32_configuration_errors( id="mqtt", ), pytest.param( + # Basic auth uses mbedtls_base64_encode directly, so no esp-tls. "exclusion_reincludes_web_server.yaml", - ("esp-tls", "esp_http_server"), + ("esp_http_server",), id="web_server_idf", ), pytest.param( @@ -480,6 +486,303 @@ def test_user_sdkconfig_certificate_bundle_wins( assert sdkconfig.get("CONFIG_MBEDTLS_CERTIFICATE_BUNDLE_DEFAULT_FULL") is False +_TLS_OFF_CRYPTO = { + "CONFIG_MBEDTLS_ECP_C": False, + "CONFIG_MBEDTLS_PEM_WRITE_C": False, + "CONFIG_MBEDTLS_X509_CRL_PARSE_C": False, + "CONFIG_MBEDTLS_X509_CSR_PARSE_C": False, +} +_TLS_OFF_IDF5 = { + "CONFIG_MBEDTLS_TLS_DISABLED": True, + "CONFIG_ESP_WIFI_ENTERPRISE_SUPPORT": False, + **_TLS_OFF_CRYPTO, +} +_TLS_OFF_IDF6 = { + "CONFIG_MBEDTLS_TLS_ENABLED": False, + "CONFIG_ESP_WIFI_ENTERPRISE_SUPPORT": False, + **_TLS_OFF_CRYPTO, +} +_PEER_CERT_PKCS7_OFF = { + "CONFIG_MBEDTLS_SSL_KEEP_PEER_CERTIFICATE": False, + "CONFIG_MBEDTLS_PKCS7_C": False, +} +_TLS_EXTRAS_OFF = dict.fromkeys(MBEDTLS_TLS_EXTRA_OPTIONS, False) +_TLS_CLIENT_ONLY = { + "CONFIG_MBEDTLS_TLS_CLIENT_ONLY": True, + "CONFIG_MBEDTLS_TLS_SERVER_AND_CLIENT": False, +} +_IDF5 = cv.Version(5, 5, 5) +_IDF6 = cv.Version(6, 0, 0) + + +@pytest.mark.parametrize( + ("framework", "idf", "data", "preset", "expected", "excluded"), + [ + pytest.param( + PlatformFramework.ESP32_IDF, + _IDF5, + MbedtlsSdkconfigData(), + {}, + {**_TLS_OFF_IDF5, **_TLS_EXTRAS_OFF, **_PEER_CERT_PKCS7_OFF}, + set(_ESP_TLS_LINKING_COMPONENTS), + id="idf5_no_tls_user", + ), + pytest.param( + # An external component that only re-included esp-tls keeps TLS. + PlatformFramework.ESP32_IDF, + _IDF5, + MbedtlsSdkconfigData(), + {}, + {**_TLS_CLIENT_ONLY, **_TLS_EXTRAS_OFF, **_PEER_CERT_PKCS7_OFF}, + set(_ESP_TLS_LINKING_COMPONENTS) - {"esp-tls"}, + id="idf_esp_tls_reincluded", + ), + pytest.param( + # esp_http_client links esp_tls itself, so re-including it counts too. + PlatformFramework.ESP32_IDF, + _IDF5, + MbedtlsSdkconfigData(), + {}, + {**_TLS_CLIENT_ONLY, **_TLS_EXTRAS_OFF, **_PEER_CERT_PKCS7_OFF}, + set(_ESP_TLS_LINKING_COMPONENTS) - {"esp_http_client"}, + id="idf_http_client_reincluded", + ), + pytest.param( + PlatformFramework.ESP32_IDF, + _IDF6, + MbedtlsSdkconfigData(), + {}, + { + **_TLS_OFF_IDF6, + **_TLS_EXTRAS_OFF, + **_PEER_CERT_PKCS7_OFF, + "CONFIG_MBEDTLS_SHA384_C": False, + "CONFIG_MBEDTLS_SHA512_C": False, + }, + set(_ESP_TLS_LINKING_COMPONENTS), + id="idf6_drops_sha512", + ), + pytest.param( + PlatformFramework.ESP32_IDF, + _IDF6, + MbedtlsSdkconfigData(sha512_required=True), + {}, + {**_TLS_OFF_IDF6, **_TLS_EXTRAS_OFF, **_PEER_CERT_PKCS7_OFF}, + set(_ESP_TLS_LINKING_COMPONENTS), + id="idf6_sha512_required", + ), + pytest.param( + PlatformFramework.ESP32_IDF, + _IDF5, + MbedtlsSdkconfigData(ecp_required=True), + {}, + { + **{ + k: v + for k, v in _TLS_OFF_IDF5.items() + if k != "CONFIG_MBEDTLS_ECP_C" + }, + **_TLS_EXTRAS_OFF, + **_PEER_CERT_PKCS7_OFF, + }, + set(_ESP_TLS_LINKING_COMPONENTS), + id="idf_ecp_without_tls", + ), + pytest.param( + PlatformFramework.ESP32_IDF, + _IDF5, + MbedtlsSdkconfigData(), + {"CONFIG_MBEDTLS_ECP_C": RawSdkconfigValue("y")}, + { + **_TLS_OFF_IDF5, + "CONFIG_MBEDTLS_ECP_C": RawSdkconfigValue("y"), + **_TLS_EXTRAS_OFF, + **_PEER_CERT_PKCS7_OFF, + }, + set(_ESP_TLS_LINKING_COMPONENTS), + id="idf_user_ecp_wins", + ), + pytest.param( + PlatformFramework.ESP32_IDF, + _IDF5, + MbedtlsSdkconfigData(peer_cert_required=True, pkcs7_required=True), + {}, + { + **_TLS_OFF_IDF5, + **_TLS_EXTRAS_OFF, + "CONFIG_MBEDTLS_SSL_KEEP_PEER_CERTIFICATE": True, + "CONFIG_MBEDTLS_PKCS7_C": True, + }, + set(_ESP_TLS_LINKING_COMPONENTS), + id="idf_peer_cert_pkcs7_required", + ), + pytest.param( + PlatformFramework.ESP32_IDF, + _IDF5, + MbedtlsSdkconfigData(disable_peer_cert=False, disable_pkcs7=False), + {}, + {**_TLS_OFF_IDF5, **_TLS_EXTRAS_OFF}, + set(_ESP_TLS_LINKING_COMPONENTS), + id="idf_advanced_disables_off", + ), + pytest.param( + # advanced: disable_mbedtls_tls: false keeps TLS with no requester. + PlatformFramework.ESP32_IDF, + _IDF5, + MbedtlsSdkconfigData(disable_tls=False), + {}, + {**_TLS_CLIENT_ONLY, **_TLS_EXTRAS_OFF, **_PEER_CERT_PKCS7_OFF}, + set(_ESP_TLS_LINKING_COMPONENTS), + id="idf_disable_tls_opt_out", + ), + pytest.param( + # require_mbedtls_tls() keeps TLS with every wrapper still excluded. + PlatformFramework.ESP32_IDF, + _IDF5, + MbedtlsSdkconfigData(tls_required=True), + {}, + {**_TLS_CLIENT_ONLY, **_TLS_EXTRAS_OFF, **_PEER_CERT_PKCS7_OFF}, + set(_ESP_TLS_LINKING_COMPONENTS), + id="idf_require_mbedtls_tls", + ), + pytest.param( + # TLS kept: a required server role blocks the client-only trim. + PlatformFramework.ESP32_IDF, + _IDF5, + MbedtlsSdkconfigData(tls_server_required=True), + {}, + {**_TLS_EXTRAS_OFF, **_PEER_CERT_PKCS7_OFF}, + set(_ESP_TLS_LINKING_COMPONENTS) - {"esp-tls"}, + id="idf_tls_server_required", + ), + pytest.param( + PlatformFramework.ESP32_ARDUINO, + _IDF5, + MbedtlsSdkconfigData(), + {}, + {**_TLS_CLIENT_ONLY, **_TLS_EXTRAS_OFF, **_PEER_CERT_PKCS7_OFF}, + set(_ESP_TLS_LINKING_COMPONENTS), + id="arduino_keeps_tls", + ), + ], +) +def test_reconcile_mbedtls_sdkconfig( + set_core_config: SetCoreConfigCallable, + framework: PlatformFramework, + idf: cv.Version, + data: MbedtlsSdkconfigData, + preset: dict[str, Any], + expected: dict[str, Any], + excluded: set[str], +) -> None: + """The FINAL-priority reconciler turns TLS off only when nothing requested it; + user sdkconfig_options always win.""" + set_core_config(framework) + CORE.data[KEY_ESP32] = { + KEY_IDF_VERSION: idf, + KEY_SDKCONFIG_OPTIONS: dict(preset), + KEY_MBEDTLS_SDKCONFIG: data, + KEY_EXCLUDE_COMPONENTS: excluded, + } + + asyncio.run(_reconcile_mbedtls_sdkconfig()) + + assert CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] == expected + + +def test_esp_tls_linking_components_are_excluded_by_default() -> None: + """The fallback scan is only a real signal while every name is excluded by default.""" + assert set(_ESP_TLS_LINKING_COMPONENTS) <= set(DEFAULT_EXCLUDED_IDF_COMPONENTS) + + +@pytest.mark.parametrize( + ("options", "wants_tls"), + [ + pytest.param({}, False, id="empty"), + pytest.param({"CONFIG_MBEDTLS_TLS_SERVER_AND_CLIENT": "y"}, True, id="role_y"), + pytest.param({"CONFIG_MBEDTLS_TLS_ENABLED": "n"}, False, id="enabled_n"), + pytest.param({"CONFIG_MBEDTLS_TLS_DISABLED": "n"}, True, id="disabled_n"), + pytest.param({"CONFIG_ESP_TLS_INSECURE": "y"}, True, id="esp_tls_prefix"), + pytest.param( + {"CONFIG_MBEDTLS_SSL_KEEP_PEER_CERTIFICATE": "n"}, + False, + id="prefix_n_is_not_a_request", + ), + pytest.param({"CONFIG_ESP_HTTPS_OTA_ALLOW_HTTP": "y"}, True, id="https_prefix"), + pytest.param({"CONFIG_OPENTHREAD_COMMISSIONER": "y"}, True, id="ot_dtls_y"), + pytest.param({"CONFIG_OPENTHREAD_JOINER": "n"}, False, id="ot_dtls_n"), + pytest.param({"CONFIG_OPENTHREAD_BORDER_ROUTER": "y"}, True, id="ot_br_y"), + pytest.param( + {"CONFIG_ESP_WIFI_ENTERPRISE_SUPPORT": "y"}, True, id="wifi_enterprise_y" + ), + pytest.param({"CONFIG_LWIP_IPV6": "y"}, False, id="unrelated"), + ], +) +def test_user_sdkconfig_wants_tls(options: dict[str, Any], wants_tls: bool) -> None: + """The sdkconfig escape hatch reads values, never bare key presence.""" + assert _user_sdkconfig_wants_tls(options) is wants_tls + + +@pytest.mark.parametrize( + ("config_file", "tls_off", "ecp_off", "esp_tls_excluded"), + [ + pytest.param("network_ethernet_only.yaml", True, True, True, id="ethernet_api"), + pytest.param( + "exclusion_reincludes_web_server.yaml", + True, + True, + True, + id="web_server_idf", + ), + pytest.param( + "exclusion_reincludes_http_request.yaml", + False, + False, + False, + id="http_request", + ), + pytest.param("exclusion_reincludes_mqtt.yaml", False, False, False, id="mqtt"), + pytest.param( + "exclusion_reincludes_nextion.yaml", False, False, False, id="nextion" + ), + pytest.param("mbedtls_tls_wifi_eap.yaml", False, False, True, id="wifi_eap"), + # zigbee requests ECP for the esp-zigbee-lib blobs, without TLS. + pytest.param("tls_zigbee_c6.yaml", True, False, True, id="zigbee"), + # A raw bundle keeps the TLS role but no longer compiles esp-tls. + pytest.param( + "certificate_bundle_sdkconfig.yaml", False, False, True, id="raw_bundle" + ), + pytest.param( + "tls_sdkconfig_esp_tls.yaml", False, False, False, id="raw_esp_tls" + ), + # A role option set to n is not a request. + pytest.param( + "tls_sdkconfig_tls_enabled_n.yaml", True, True, True, id="raw_tls_enabled_n" + ), + # ECDSA signed OTA requests ECP itself (SECURE_SIGNED_APPS selects it too). + pytest.param( + "signed_ota_ecdsa256_c6.yaml", True, False, True, id="signed_ota_ecdsa" + ), + ], +) +def test_tls_disabled_sdkconfig( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], + config_file: str, + tls_off: bool, + ecp_off: bool, + esp_tls_excluded: bool, +) -> None: + """TLS is compiled out unless a component or a raw sdkconfig option asks for it.""" + generate_main(component_config_path(config_file)) + sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] + assert (sdkconfig.get("CONFIG_MBEDTLS_TLS_DISABLED") is True) is tls_off + assert sdkconfig.get("CONFIG_MBEDTLS_ECP_C") is (False if ecp_off else None) + assert ( + "esp-tls" in CORE.data[KEY_ESP32][KEY_EXCLUDE_COMPONENTS] + ) is esp_tls_excluded + + def test_execute_from_psram_s3_sdkconfig( generate_main: Callable[[str | Path], str], component_config_path: Callable[[str], Path], @@ -1496,9 +1799,14 @@ def test_mbedtls_tls_openthread_keeps_only_what_it_uses( generate_main: Callable[[str | Path], str], component_config_path: Callable[[str], Path], ) -> None: - """The OpenThread config keeps the DTLS server, CCM and deterministic ECDSA; the rest is trimmed.""" + """Nothing in the OpenThread config links TLS, so the stack is compiled out + and no TLS role is written; the extras trim still runs because CCM and + deterministic ECDSA are plain crypto, and OpenThread keeps those two.""" generate_main(component_config_path("mbedtls_tls_openthread.yaml")) sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] + assert sdkconfig.get("CONFIG_MBEDTLS_TLS_DISABLED") is True + # require_mbedtls_ecp() keeps ECP for the SRP host key while TLS is off + assert "CONFIG_MBEDTLS_ECP_C" not in sdkconfig assert tuple(sdkconfig.get(name) for name in _TLS_SERVER_OPTIONS) == (None, None) for name in MBEDTLS_TLS_EXTRA_OPTIONS: assert sdkconfig.get(name) is (None if name in _CCM_ECDSA_EXTRAS else False) @@ -1508,14 +1816,31 @@ def test_mbedtls_tls_zigbee_keeps_only_what_it_uses( generate_main: Callable[[str | Path], str], component_config_path: Callable[[str], Path], ) -> None: - """The Zigbee config keeps CCM and deterministic ECDSA; the rest is trimmed.""" + """Nothing in the Zigbee config links TLS, so the stack is compiled out and + no role is written; the extras trim still runs and Zigbee keeps CCM and + deterministic ECDSA.""" generate_main(component_config_path("tls_zigbee_c6.yaml")) sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] - assert tuple(sdkconfig.get(name) for name in _TLS_SERVER_OPTIONS) == (True, False) + assert sdkconfig.get("CONFIG_MBEDTLS_TLS_DISABLED") is True + assert tuple(sdkconfig.get(name) for name in _TLS_SERVER_OPTIONS) == (None, None) for name in MBEDTLS_TLS_EXTRA_OPTIONS: assert sdkconfig.get(name) is (None if name in _CCM_ECDSA_EXTRAS else False) +def test_mbedtls_tls_opt_out_keeps_stack_and_trims_role( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """disable_mbedtls_tls: false keeps TLS with no requester; the client-only + and extras trims then still apply.""" + generate_main(component_config_path("tls_keep_opt_out.yaml")) + sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] + assert "CONFIG_MBEDTLS_TLS_DISABLED" not in sdkconfig + assert "CONFIG_MBEDTLS_ECP_C" not in sdkconfig + assert sdkconfig.get("CONFIG_MBEDTLS_TLS_CLIENT_ONLY") is True + assert sdkconfig.get("CONFIG_MBEDTLS_SSL_RENEGOTIATION") is False + + def test_mbedtls_tls_user_sdkconfig_wins( generate_main: Callable[[str | Path], str], component_config_path: Callable[[str], Path], @@ -1541,8 +1866,9 @@ def test_mbedtls_tls_openthread_requires_server_and_extras( ) -> 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] == _CCM_ECDSA_EXTRAS + mbedtls = CORE.data[KEY_ESP32][KEY_MBEDTLS_SDKCONFIG] + assert mbedtls.tls_server_required is True + assert mbedtls.tls_extras_required == _CCM_ECDSA_EXTRAS def test_mbedtls_tls_zigbee_requires_extras( @@ -1551,7 +1877,8 @@ def test_mbedtls_tls_zigbee_requires_extras( ) -> None: """The Zigbee hooks mark the CCM/deterministic ECDSA as required.""" generate_main(component_config_path("tls_zigbee_c6.yaml")) - assert CORE.data[KEY_ESP32][KEY_MBEDTLS_TLS_EXTRAS_REQUIRED] == _CCM_ECDSA_EXTRAS + mbedtls = CORE.data[KEY_ESP32][KEY_MBEDTLS_SDKCONFIG] + assert mbedtls.tls_extras_required == _CCM_ECDSA_EXTRAS _VASPRINTF_STUB_FLAGS = {"-Wl,--wrap=vasprintf", "-Wl,--undefined=__wrap_vasprintf"}