From 5588ce3e1b59df05c4bf185a461a00af8c46b92c Mon Sep 17 00:00:00 2001 From: Thomas Faour Date: Sun, 19 Jul 2026 17:38:39 -0400 Subject: [PATCH] HTTPS trust: use the public CA bundle + one missing root, not a pinned cert Root-caused the earlier "No matching trusted root certificate found" failure properly this time by reading ESP-IDF's actual bundle-matching code (esp_crt_bundle.c): it looks up a trusted root by the ISSUER name of whatever certificate it can't otherwise validate, not by matching the presented certificate itself. The live server's chain ends in a GTS Root R4 certificate cross-signed by the old GlobalSign Root CA R1 (common Cloudflare/Google Trust Services practice, for compatibility with older/embedded clients) -- and ESP-IDF's current bundle snapshot has dropped that old GlobalSign root entirely, so the lookup came up empty. This was a general gap, not something specific to this one deployment's cert. Fix: keep the standard public CA bundle (esp_crt_bundle_attach) as the trust mechanism -- so any normal reverse-proxy cert (Let's Encrypt, etc.) works out of the box -- and add the one missing root on top via ESP-IDF's CONFIG_MBEDTLS_CUSTOM_CERTIFICATE_BUNDLE mechanism (sdkconfig.defaults), which appends a project-supplied cert file to the bundle at build time. Fetched GlobalSign's official Root CA R1 cert and cryptographically verified (openssl verify) it actually validates the live server's certificate before embedding it -- see firmware/main/certs/additional_root_ca.pem (replaces the old tools_server_ca.pem, which pinned one exact certificate directly and would've broken for anyone else's reverse proxy). Confirmed working against the real deployment on hardware. --- firmware/README.md | 45 +++++++++++++--------- firmware/main/CMakeLists.txt | 3 +- firmware/main/certs/additional_root_ca.pem | 21 ++++++++++ firmware/main/certs/tools_server_ca.pem | 21 ---------- firmware/main/frame_client.c | 22 +++-------- firmware/sdkconfig.defaults | 15 ++++++++ 6 files changed, 69 insertions(+), 58 deletions(-) create mode 100644 firmware/main/certs/additional_root_ca.pem delete mode 100644 firmware/main/certs/tools_server_ca.pem diff --git a/firmware/README.md b/firmware/README.md index 40771af..b6a3a35 100644 --- a/firmware/README.md +++ b/firmware/README.md @@ -96,30 +96,39 @@ The Tools Server field accepts either: manage-menu overlay data, the QR codes' own links) uses whichever scheme you enter. -The firmware does **not** use ESP-IDF's general public CA bundle -- -it pins one specific certificate, embedded at build time from -[`main/certs/tools_server_ca.pem`](main/certs/tools_server_ca.pem) and -trusted directly via `cert_pem` on every request. (The public bundle -was tried first and rejected: it does an exact byte-level match against -its compiled-in table, and a real-world root that's been re-issued -under a new serial/signature but the same name and key -- as Google did -for GTS Root R4 -- doesn't match it, confirmed on hardware.) +The firmware trusts ESP-IDF's standard public CA bundle +(`esp_crt_bundle_attach`) -- so any reverse proxy with a normal +publicly-trusted certificate just works out of the box: Let's Encrypt, +a Cloudflare-proxied hostname, or any other public CA. -This means a normal publicly-trusted certificate (Let's Encrypt, a -Cloudflare-proxied hostname, etc.) does **not** automatically work -- -only whichever certificate is actually embedded in -`certs/tools_server_ca.pem` is trusted. To point the device at a -different reverse proxy, extract that proxy's actual certificate and -replace the file's contents: +One specific root is added on top of that bundle at build time, from +[`main/certs/additional_root_ca.pem`](main/certs/additional_root_ca.pem) +(wired in via `CONFIG_MBEDTLS_CUSTOM_CERTIFICATE_BUNDLE_PATH` in +[`sdkconfig.defaults`](sdkconfig.defaults)): **GlobalSign Root CA R1**. +This isn't a workaround specific to one deployment -- it's needed +because Cloudflare (via Google Trust Services) commonly cross-signs its +GTS Root R4 chain with this old GlobalSign root for backward +compatibility with older/embedded clients, and ESP-IDF's current bundle +snapshot has removed it (deprecated from modern trust stores). Without +it, ESP-IDF's chain validation -- which looks up a trusted root by the +*issuer name* of whatever certificate it can't otherwise validate, not +by matching the presented certificate itself -- comes up empty and the +handshake fails with "No matching trusted root certificate found", +confirmed on hardware against a real Cloudflare-fronted deployment. +Since this is a common, not deployment-specific, gap, it's likely worth +keeping even if you're not using Cloudflare. + +If your proxy presents a certificate chain the bundle (plus this one +addition) still doesn't validate -- an unusual public CA, or a private/ +self-signed cert with no public CA in the chain at all -- diagnose with: ``` openssl s_client -connect :443 -showcerts :443 -showcerts toolsserver + a path (no leading slash), * appending cfg->access_token as ?token= if one's set. toolsserver is * normally a bare "host:port", defaulting to plain http; it may instead @@ -243,7 +231,7 @@ static frame_server_config_t fetch_frame_config(const frame_config_t *cfg) .url = url, .method = HTTP_METHOD_GET, .timeout_ms = CONFIG_FRAME_SERVER_CHECK_TIMEOUT_MS, - .cert_pem = tools_server_ca_pem_start, + .crt_bundle_attach = esp_crt_bundle_attach, }; esp_http_client_handle_t client = esp_http_client_init(&config); @@ -304,7 +292,7 @@ static void fetch_photo_info(const frame_config_t *cfg, char *location_line1, si .url = url, .method = HTTP_METHOD_GET, .timeout_ms = CONFIG_FRAME_SERVER_CHECK_TIMEOUT_MS, - .cert_pem = tools_server_ca_pem_start, + .crt_bundle_attach = esp_crt_bundle_attach, }; esp_http_client_handle_t client = esp_http_client_init(&config); @@ -364,7 +352,7 @@ static int fetch_face_labels(const frame_config_t *cfg, manage_face_label_t *out .url = url, .method = HTTP_METHOD_GET, .timeout_ms = CONFIG_FRAME_SERVER_CHECK_TIMEOUT_MS, - .cert_pem = tools_server_ca_pem_start, + .crt_bundle_attach = esp_crt_bundle_attach, }; esp_http_client_handle_t client = esp_http_client_init(&config); @@ -513,7 +501,7 @@ static esp_err_t fetch_and_display(const frame_config_t *cfg, fetch_action_t act .url = url, .method = action == FETCH_NORMAL ? HTTP_METHOD_GET : HTTP_METHOD_POST, .timeout_ms = CONFIG_FRAME_FETCH_TIMEOUT_MS, - .cert_pem = tools_server_ca_pem_start, + .crt_bundle_attach = esp_crt_bundle_attach, }; esp_http_client_handle_t client = esp_http_client_init(&config); diff --git a/firmware/sdkconfig.defaults b/firmware/sdkconfig.defaults index c9fee64..c6f1838 100644 --- a/firmware/sdkconfig.defaults +++ b/firmware/sdkconfig.defaults @@ -1,5 +1,20 @@ CONFIG_LWIP_MAX_SOCKETS=16 +# HTTPS trust for the tools server: the standard public CA bundle (works +# for any normal reverse-proxy cert -- Let's Encrypt, a typical +# Cloudflare-issued edge cert, etc.) PLUS one specific root ESP-IDF's +# current bundle snapshot is missing: GlobalSign Root CA R1, which +# Cloudflare (via Google Trust Services) cross-signs its GTS Root R4 +# chain with for backward compatibility with older/embedded clients -- +# extremely common, not unique to this deployment. Without it, ESP-IDF's +# own chain-validation lookup (by issuer name, not a literal cert match) +# comes up empty and the handshake fails with "No matching trusted root +# certificate found", confirmed on hardware. See +# main/certs/additional_root_ca.pem and firmware/README.md's HTTPS +# section for how this was diagnosed/verified. +CONFIG_MBEDTLS_CUSTOM_CERTIFICATE_BUNDLE=y +CONFIG_MBEDTLS_CUSTOM_CERTIFICATE_BUNDLE_PATH="main/certs/additional_root_ca.pem" + # ESP32-C6 dev board has 8MB flash. A 2MB app partition (vs. the ~1MB # "single app" default) gives headroom for the HTTP client, TLS, and image # buffers still to come, without needing to fight for space on every build.