HTTPS trust: use the public CA bundle + one missing root, not a pinned cert
Build and push server image / build-and-push (push) Successful in 33s

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.
This commit is contained in:
2026-07-19 17:38:39 -04:00
parent 4ffd9e852c
commit 5588ce3e1b
6 changed files with 69 additions and 58 deletions
+27 -18
View File
@@ -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 <host>:443 -showcerts </dev/null
```
Pick whichever certificate in the printed chain you want as the trust
anchor (typically the root) and rebuild. A private/self-signed cert
works exactly the same way -- there's no requirement that it chain to
a public CA at all, since the device trusts this one file directly.
and add whichever certificate in the chain is missing to
`additional_root_ca.pem` (or a file of your own, if you'd rather not
touch the committed one) alongside the existing entry, then rebuild.
The device does perform normal hostname verification (it's not
skipped), so the Tools Server field's hostname has to match what the
+1 -2
View File
@@ -1,4 +1,3 @@
idf_component_register(SRCS main.c wifi_provisioning.c frame_client.c qr_onboarding.c status_screen.c epd_draw.c next_button.c back_button.c combo_button.c manage_qr_overlay.c
PRIV_REQUIRES esp_event nvs_flash esp_wifi esp_netif esp_http_server esp_http_client mbedtls dns_server epd7in3e qrcode epaper_fonts esp_driver_gpio
EMBED_FILES root.html
EMBED_TXTFILES certs/tools_server_ca.pem)
EMBED_FILES root.html)
@@ -0,0 +1,21 @@
-----BEGIN CERTIFICATE-----
MIIDdTCCAl2gAwIBAgILBAAAAAABFUtaw5QwDQYJKoZIhvcNAQEFBQAwVzELMAkG
A1UEBhMCQkUxGTAXBgNVBAoTEEdsb2JhbFNpZ24gbnYtc2ExEDAOBgNVBAsTB1Jv
b3QgQ0ExGzAZBgNVBAMTEkdsb2JhbFNpZ24gUm9vdCBDQTAeFw05ODA5MDExMjAw
MDBaFw0yODAxMjgxMjAwMDBaMFcxCzAJBgNVBAYTAkJFMRkwFwYDVQQKExBHbG9i
YWxTaWduIG52LXNhMRAwDgYDVQQLEwdSb290IENBMRswGQYDVQQDExJHbG9iYWxT
aWduIFJvb3QgQ0EwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDaDuaZ
jc6j40+Kfvvxi4Mla+pIH/EqsLmVEQS98GPR4mdmzxzdzxtIK+6NiY6arymAZavp
xy0Sy6scTHAHoT0KMM0VjU/43dSMUBUc71DuxC73/OlS8pF94G3VNTCOXkNz8kHp
1Wrjsok6Vjk4bwY8iGlbKk3Fp1S4bInMm/k8yuX9ifUSPJJ4ltbcdG6TRGHRjcdG
snUOhugZitVtbNV4FpWi6cgKOOvyJBNPc1STE4U6G7weNLWLBYy5d4ux2x8gkasJ
U26Qzns3dLlwR5EiUWMWea6xrkEmCMgZK9FGqkjWZCrXgzT/LCrBbBlDSgeF59N8
9iFo7+ryUp9/k5DPAgMBAAGjQjBAMA4GA1UdDwEB/wQEAwIBBjAPBgNVHRMBAf8E
BTADAQH/MB0GA1UdDgQWBBRge2YaRQ2XyolQL30EzTSo//z9SzANBgkqhkiG9w0B
AQUFAAOCAQEA1nPnfE920I2/7LqivjTFKDK1fPxsnCwrvQmeU79rXqoRSLblCKOz
yj1hTdNGCbM+w6DjY1Ub8rrvrTnhQ7k4o+YviiY776BQVvnGCv04zcQLcFGUl5gE
38NflNUVyRRBnMRddWQVDf9VMOyGj/8N7yy5Y0b2qvzfvGn9LhJIZJrglfCm7ymP
AbEVtQwdpf5pLGkkeB6zpxxxYu7KyJesF12KwvhHhm4qxFYxldBniYUr+WymXUad
DKqC5JlR3XC321Y9YeRq4VzW9v493kHMB65jUr9TU/Qr6cf9tveCX4XSQRjbgbME
HMUfpIBvFSDJ3gyICh3WZlXi/EjJKSZp4A==
-----END CERTIFICATE-----
-21
View File
@@ -1,21 +0,0 @@
-----BEGIN CERTIFICATE-----
MIIDejCCAmKgAwIBAgIQf+UwvzMTQ77dghYQST2KGzANBgkqhkiG9w0BAQsFADBX
MQswCQYDVQQGEwJCRTEZMBcGA1UEChMQR2xvYmFsU2lnbiBudi1zYTEQMA4GA1UE
CxMHUm9vdCBDQTEbMBkGA1UEAxMSR2xvYmFsU2lnbiBSb290IENBMB4XDTIzMTEx
NTAzNDMyMVoXDTI4MDEyODAwMDA0MlowRzELMAkGA1UEBhMCVVMxIjAgBgNVBAoT
GUdvb2dsZSBUcnVzdCBTZXJ2aWNlcyBMTEMxFDASBgNVBAMTC0dUUyBSb290IFI0
MHYwEAYHKoZIzj0CAQYFK4EEACIDYgAE83Rzp2iLYK5DuDXFgTB7S0md+8Fhzube
Rr1r1WEYNa5A3XP3iZEwWus87oV8okB2O6nGuEfYKueSkWpz6bFyOZ8pn6KY019e
WIZlD6GEZQbR3IvJx3PIjGov5cSr0R2Ko4H/MIH8MA4GA1UdDwEB/wQEAwIBhjAd
BgNVHSUEFjAUBggrBgEFBQcDAQYIKwYBBQUHAwIwDwYDVR0TAQH/BAUwAwEB/zAd
BgNVHQ4EFgQUgEzW63T/STaj1dj8tT7FavCUHYwwHwYDVR0jBBgwFoAUYHtmGkUN
l8qJUC99BM00qP/8/UswNgYIKwYBBQUHAQEEKjAoMCYGCCsGAQUFBzAChhpodHRw
Oi8vaS5wa2kuZ29vZy9nc3IxLmNydDAtBgNVHR8EJjAkMCKgIKAehhxodHRwOi8v
Yy5wa2kuZ29vZy9yL2dzcjEuY3JsMBMGA1UdIAQMMAowCAYGZ4EMAQIBMA0GCSqG
SIb3DQEBCwUAA4IBAQAYQrsPBtYDh5bjP2OBDwmkoWhIDDkic574y04tfzHpn+cJ
odI2D4SseesQ6bDrarZ7C30ddLibZatoKiws3UL9xnELz4ct92vID24FfVbiI1hY
+SW6FoVHkNeWIP0GCbaM4C6uVdF5dTUsMVs/ZbzNnIdCp5Gxmx5ejvEau8otR/Cs
kGN+hr/W5GvT1tMBjgWKZ1i4//emhA1JG1BbPzoLJQvyEotc03lXjTaCzv8mEbep
8RqZ7a2CPsgRbuvTPBwcOMBBmuFeU88+FSBX6+7iP0il8b4Z0QFqIwwMHfs/L6K1
vepuoxtGzi4CZ68zJpiq1UvSqTbFJjtbD4seiMHl
-----END CERTIFICATE-----
+5 -17
View File
@@ -6,6 +6,7 @@
#include "esp_wifi_default.h"
#include "esp_netif.h"
#include "esp_http_client.h"
#include "esp_crt_bundle.h"
#include "esp_sleep.h"
#include "freertos/FreeRTOS.h"
@@ -25,19 +26,6 @@ static const char *TAG = "frame_client";
static EventGroupHandle_t s_sta_event_group;
/* The exact CA certificate the tools server's reverse proxy presents,
* embedded verbatim (not sourced from ESP-IDF's built-in root bundle --
* that does an exact byte-level match against its compiled-in table,
* and a semantically-identical "same name, same public key" root
* re-issued under a different serial/signature, as Google did for GTS
* Root R4, doesn't match it; confirmed on hardware, see
* firmware/main/certs/tools_server_ca.pem for how this was extracted).
* If the proxy's CA ever changes, re-extract with:
* openssl s_client -connect <host>:443 -showcerts </dev/null
* and replace this file with whichever cert in the chain you want as
* the trust anchor (typically the root). */
extern const char tools_server_ca_pem_start[] asm("_binary_tools_server_ca_pem_start");
/* Builds a full URL from cfg->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);
+15
View File
@@ -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.