From 2560a4436e574fadac0b159377ca6bae022991a1 Mon Sep 17 00:00:00 2001 From: TheSwert Date: Wed, 7 Oct 2026 15:28:10 -0400 Subject: [PATCH] [audio_http][core] Add ca_certificate_path to audio_http media source (#19392) --- .../audio_http/audio_http_media_source.cpp | 9 +++ .../audio_http/audio_http_media_source.h | 8 +++ esphome/components/audio_http/media_source.py | 53 ++++++++++++++ esphome/core/defines.h | 1 + .../audio_http/test-custom-ca.esp32-idf.yaml | 6 ++ .../audio_http/test-inherit-ca.esp32-idf.yaml | 13 ++++ tests/components/audio_http/test_ca.pem | 10 +++ .../unit_tests/components/test_audio_http.py | 71 +++++++++++++++++++ 8 files changed, 171 insertions(+) create mode 100644 tests/components/audio_http/test-custom-ca.esp32-idf.yaml create mode 100644 tests/components/audio_http/test-inherit-ca.esp32-idf.yaml create mode 100644 tests/components/audio_http/test_ca.pem create mode 100644 tests/unit_tests/components/test_audio_http.py diff --git a/esphome/components/audio_http/audio_http_media_source.cpp b/esphome/components/audio_http/audio_http_media_source.cpp index fb8620f7d9..6cea4d7311 100644 --- a/esphome/components/audio_http/audio_http_media_source.cpp +++ b/esphome/components/audio_http/audio_http_media_source.cpp @@ -33,6 +33,9 @@ void AudioHTTPMediaSource::dump_config() { " Persistent Ring Buffer: %s\n" " Decoder Task Stack in PSRAM: %s", this->buffer_size_, YESNO(this->persistent_ring_buffer_), YESNO(this->decoder_task_stack_in_psram_)); +#ifdef USE_AUDIO_HTTP_CA_CERTIFICATE + ESP_LOGCONFIG(TAG, " Custom CA Certificate: %s", YESNO(this->http_ca_certificate_ != nullptr)); +#endif } void AudioHTTPMediaSource::setup() { @@ -52,6 +55,12 @@ void AudioHTTPMediaSource::setup() { config.reader_stack_size = READER_TASK_STACK_SIZE; config.decoder_stack_size = DECODER_TASK_STACK_SIZE; config.decoder_stack_in_psram = this->decoder_task_stack_in_psram_; +#ifdef USE_AUDIO_HTTP_CA_CERTIFICATE + // micro-decoder verifies HTTPS against this PEM only, skipping the built-in certificate bundle. + if (this->http_ca_certificate_ != nullptr) { + config.http_ca_certificate = this->http_ca_certificate_; + } +#endif this->decoder_ = std::make_unique(config); if (this->decoder_ == nullptr) { diff --git a/esphome/components/audio_http/audio_http_media_source.h b/esphome/components/audio_http/audio_http_media_source.h index a97025e53e..e75dcf7a67 100644 --- a/esphome/components/audio_http/audio_http_media_source.h +++ b/esphome/components/audio_http/audio_http_media_source.h @@ -34,6 +34,11 @@ class AudioHTTPMediaSource final : public Component, void set_buffer_size(size_t buffer_size) { this->buffer_size_ = buffer_size; } void set_task_stack_in_psram(bool task_stack_in_psram) { this->decoder_task_stack_in_psram_ = task_stack_in_psram; } void set_persistent_ring_buffer(bool persistent) { this->persistent_ring_buffer_ = persistent; } +#ifdef USE_AUDIO_HTTP_CA_CERTIFICATE + /// PEM-encoded CA certificate(s) used as the sole trust anchor for HTTPS playback URLs, + /// replacing the built-in certificate bundle. + void set_http_ca_certificate(const char *ca_certificate) { this->http_ca_certificate_ = ca_certificate; } +#endif // MediaSource interface implementation bool play_uri(const std::string &uri) override; @@ -56,6 +61,9 @@ class AudioHTTPMediaSource final : public Component, std::atomic pause_{false}; bool decoder_task_stack_in_psram_{false}; bool persistent_ring_buffer_{false}; +#ifdef USE_AUDIO_HTTP_CA_CERTIFICATE + const char *http_ca_certificate_{nullptr}; +#endif }; } // namespace esphome::audio_http diff --git a/esphome/components/audio_http/media_source.py b/esphome/components/audio_http/media_source.py index 14543957e9..3c23517b92 100644 --- a/esphome/components/audio_http/media_source.py +++ b/esphome/components/audio_http/media_source.py @@ -1,13 +1,22 @@ +import logging +from pathlib import Path + import esphome.codegen as cg from esphome.components import audio, media_source, psram import esphome.config_validation as cv from esphome.const import CONF_BUFFER_SIZE, CONF_ID, CONF_TASK_STACK_IN_PSRAM +import esphome.final_validate as fv from esphome.types import ConfigType +_LOGGER = logging.getLogger(__name__) + CODEOWNERS = ["@kahrendt"] AUTO_LOAD = ["audio"] CONF_PERSISTENT_RING_BUFFER = "persistent_ring_buffer" +CONF_CA_CERTIFICATE_PATH = "ca_certificate_path" +CONF_HTTP_REQUEST = "http_request" +CONF_VERIFY_SSL = "verify_ssl" audio_http_ns = cg.esphome_ns.namespace("audio_http") AudioHTTPMediaSource = audio_http_ns.class_( @@ -20,6 +29,38 @@ def _request_micro_decoder(config: ConfigType) -> ConfigType: return config +def _inherit_ca_certificate_path(config: ConfigType) -> ConfigType: + # Default to the CA certificate configured on the http_request component so + # HTTPS playback verifies against the same trust anchor without repeating + # the option on every source. Needed because audio_http sources are often + # declared by device packages and cannot be extended from the device config. + # The PEM replaces the built-in certificate bundle as the sole trust anchor, + # so make the inheritance visible in the log. + if CONF_CA_CERTIFICATE_PATH in config: + return config + fconf = fv.full_config.get() + if CONF_HTTP_REQUEST not in fconf: + return config + http_request_config = fconf[CONF_HTTP_REQUEST] + # Mirror http_request's own semantics: it only applies its CA when SSL + # verification is enabled, so neither does the inheritance. + if not http_request_config.get(CONF_VERIFY_SSL, True): + return config + if ca_cert_path := http_request_config.get(CONF_CA_CERTIFICATE_PATH): + _LOGGER.info( + "audio_http source '%s' is inheriting ca_certificate_path from the " + "http_request component; HTTPS playback will verify against that PEM " + "instead of the built-in certificate bundle", + config[CONF_ID].id, + ) + # Already validated by http_request's schema; the value is a resolved Path. + config[CONF_CA_CERTIFICATE_PATH] = ca_cert_path + return config + + +FINAL_VALIDATE_SCHEMA = _inherit_ca_certificate_path + + CONFIG_SCHEMA = cv.All( media_source.media_source_schema( AudioHTTPMediaSource, @@ -31,6 +72,7 @@ CONFIG_SCHEMA = cv.All( ), cv.Optional(CONF_TASK_STACK_IN_PSRAM): psram.validate_task_stack_in_psram, cv.Optional(CONF_PERSISTENT_RING_BUFFER, default=False): cv.boolean, + cv.Optional(CONF_CA_CERTIFICATE_PATH): cv.file_, } ) .extend(cv.COMPONENT_SCHEMA), @@ -47,5 +89,16 @@ async def to_code(config: ConfigType) -> None: if config.get(CONF_TASK_STACK_IN_PSRAM): cg.add(var.set_task_stack_in_psram(True)) psram.request_external_task_stack() + cg.add(var.set_buffer_size(config[CONF_BUFFER_SIZE])) cg.add(var.set_persistent_ring_buffer(config[CONF_PERSISTENT_RING_BUFFER])) + + # Embed the certificate content, like http_request does. Passed to + # micro_decoder's DecoderConfig::http_ca_certificate, which then uses it as + # the sole trust anchor for HTTPS playback URLs instead of the certificate + # bundle. + if ca_cert_path := config.get(CONF_CA_CERTIFICATE_PATH): + cg.add_define("USE_AUDIO_HTTP_CA_CERTIFICATE") + with Path(ca_cert_path).open(encoding="utf-8") as f: + ca_cert_content = f.read() + cg.add(var.set_http_ca_certificate(ca_cert_content)) diff --git a/esphome/core/defines.h b/esphome/core/defines.h index c8a10f3d2d..0a1d16a2b9 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -256,6 +256,7 @@ #define AUDIO_FILE_MAX_FILES 4 #define USE_AUDIO_DAC #define USE_AUDIO_FLAC_SUPPORT +#define USE_AUDIO_HTTP_CA_CERTIFICATE #define USE_AUDIO_MP3_SUPPORT #define USE_AUDIO_OPUS_SUPPORT #define USE_AUDIO_WAV_SUPPORT diff --git a/tests/components/audio_http/test-custom-ca.esp32-idf.yaml b/tests/components/audio_http/test-custom-ca.esp32-idf.yaml new file mode 100644 index 0000000000..86eececf66 --- /dev/null +++ b/tests/components/audio_http/test-custom-ca.esp32-idf.yaml @@ -0,0 +1,6 @@ +media_source: + - platform: audio_http + id: audio_http_ca_source + ca_certificate_path: $component_dir/test_ca.pem + +<<: !include common.yaml diff --git a/tests/components/audio_http/test-inherit-ca.esp32-idf.yaml b/tests/components/audio_http/test-inherit-ca.esp32-idf.yaml new file mode 100644 index 0000000000..0bf22bc46d --- /dev/null +++ b/tests/components/audio_http/test-inherit-ca.esp32-idf.yaml @@ -0,0 +1,13 @@ +# http_request requires a network component; audio_http's common.yaml declares none. +wifi: + ssid: MySSID + password: password1 + +http_request: + ca_certificate_path: $component_dir/test_ca.pem + +media_source: + - platform: audio_http + id: audio_http_inherit_ca_source + +<<: !include common.yaml diff --git a/tests/components/audio_http/test_ca.pem b/tests/components/audio_http/test_ca.pem new file mode 100644 index 0000000000..30cbc1a3c4 --- /dev/null +++ b/tests/components/audio_http/test_ca.pem @@ -0,0 +1,10 @@ +-----BEGIN CERTIFICATE----- +MIIBkTCB+wIJAKHBfpegPjMCMA0GCSqGSIb3DQEBCwUAMBExDzANBgNVBAMMBnVu +dXNlZDAeFw0yNDAxMDEwMDAwMDBaFw0yNTAxMDEwMDAwMDBaMBExDzANBgNVBAMM +BnVudXNlZDBcMA0GCSqGSIb3DQEBAQUAA0sAMEgCQQC5mMUB1hOgLmlnXtsvcGMP +XkhAqZaR0dDPW5OS8VEopWLJCX9Y0cvNCqiDI8cnP8pP8XJGU1hGLvA5PJzWnWZz +AgMBAAGjUzBRMB0GA1UdDgQWBBR5oQ9KqFeZOdBuAJrXxEP0dqzPtTAfBgNVHSME +GDAWgBR5oQ9KqFeZOdBuAJrXxEP0dqzPtTAPBgNVHRMBAf8EBTADAQH/MA0GCSqG +SIb3DQEBCwUAA0EAKqZFf6+f8FPDbKyPCpssquojgn7fEXqr/I/yz0R5CowGdMms +H3WH3aKP4lLSHdPTBtfIoJi3gEIZjFxp3S1TWw== +-----END CERTIFICATE----- diff --git a/tests/unit_tests/components/test_audio_http.py b/tests/unit_tests/components/test_audio_http.py new file mode 100644 index 0000000000..94355c09c9 --- /dev/null +++ b/tests/unit_tests/components/test_audio_http.py @@ -0,0 +1,71 @@ +"""Tests for the audio_http media source's ca_certificate_path inheritance.""" + +from pathlib import Path + +from esphome.components.audio_http.media_source import _inherit_ca_certificate_path +from esphome.core import ID +import esphome.final_validate as fv +from esphome.types import ConfigType + + +def _source_config() -> ConfigType: + return {"id": ID("audio_http_source")} + + +def _run(full_config: dict, config: ConfigType) -> ConfigType: + token = fv.full_config.set(full_config) + try: + return _inherit_ca_certificate_path(config) + finally: + fv.full_config.reset(token) + + +def test_explicit_option_wins() -> None: + """A source with its own ca_certificate_path is left untouched.""" + config = {"id": ID("s"), "ca_certificate_path": Path("own.pem")} + result = _run( + {"http_request": {"verify_ssl": True, "ca_certificate_path": Path("hr.pem")}}, + config, + ) + assert result["ca_certificate_path"] == Path("own.pem") + + +def test_inherited_from_http_request() -> None: + """Without its own option, the source picks up http_request's CA path.""" + result = _run( + { + "http_request": { + "verify_ssl": True, + "ca_certificate_path": Path("hr.pem"), + } + }, + _source_config(), + ) + assert result["ca_certificate_path"] == Path("hr.pem") + + +def test_no_http_request_component() -> None: + """No http_request block means no inheritance.""" + result = _run({}, _source_config()) + assert "ca_certificate_path" not in result + + +def test_http_request_without_ca() -> None: + """An http_request block without ca_certificate_path means no inheritance.""" + result = _run({"http_request": {"verify_ssl": True}}, _source_config()) + assert "ca_certificate_path" not in result + + +def test_not_inherited_when_verify_ssl_disabled() -> None: + """verify_ssl: false disables the CA for http_request itself, so the + inheritance must skip it too instead of silently pinning playback.""" + result = _run( + { + "http_request": { + "verify_ssl": False, + "ca_certificate_path": Path("hr.pem"), + } + }, + _source_config(), + ) + assert "ca_certificate_path" not in result