ESP32 https connection to REST API doesn't work

Hello,

I use an ESP32 with Arduino IDE and want to communicate with the REST API of a smart home controller (Bosch smart home). The connection needs to be https. Its necessary to use a self-signed certificate and key to establish the connection. I generated both via openssl. I simply want to get some information from the controller via GET from https://192.168.0.94:8444/smarthome/rooms.

I included the key and certificate in Postman (settings -> certificates) and it works perfectly to receive the data via GET. Unfortunately, it doesn't work with my program below on the ESP32. I use the same certificate and key.

#include <Arduino.h>
#include <WiFi.h>
#include <WiFiClientSecure.h>
#include <HTTPClient.h>

const char* ssid = "myssd";
const char* password = "mypassword";

const char* client_key = "-----BEGIN PRIVATE KEY-----\n" \
"MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDoh0jP2seMtAjy\n" \
"ipqF3xTsZgc0AE0ZzKKiqdrelUaZdC7p5mjpkcr2VC8aq1yVyzzjNlfSmUIuUoEW\n" \
"6COPPMHaNYAZBEDQ+/kbBV7fqlyi4k8db7ymAOHO9NpUkRhP/Wmd6HSwEmJyh3eh\n" \
"urDQ8AGeke8EawcTnNve6tDvlAHUvHnHqqQcBt9IcmErapsPpYPJp0dJykcki7KG\n" \
"gueZyACbV5eiqI5Om2+yA79NC3z/ClKkYjCSiH4+LkemItGBhFwXfu/l0mSH+7TX\n" \
"4F66dXqHITP6tAc9v8R8jAlFGAh1J0Opdw1+vHkRBBzXaGDZXCUaVcYu6qJP2rjj\n" \
"aJ4boO35AgMBAAECggEAKQAXsNzLBE6HaFrTqci+y/smiH8IGUU5pRRtnQM1vl+b\n" \
"2Q7RT2PvZXVNE/cEvX3nV8nwfNgcsQXHsMYQZRSJuuPlmSOkmSTg1OiixK3ppSfh\n" \
"2tclrKCpsEAA0ln+LzaH/bmr/5LSTI8B42DDhifXpUHmowELL6RgKYN+gUv38a5O\n" \
"7Cbs7Pp53yp0WQi4rwHZRp8BkFa7+Ys1OfDVOuo9Tmhu7W+3ElNskBlq49fMr1ro\n" \
"++F2Rc+mXvf9aYOlwlZkHtN9vi9nffciPyuyeIxcrY+XpQMmoOY9HE/RmWHP9NIw\n" \
"ciKYdzYIF9NsF3DiyPyZuvAfILH/3VpX7tlkV/xP3QKBgQD+5lLcHU7A8+6QpXGc\n" \
"m6ajQWiSMQSeG6+XrK3IrrTVpC8vnzNk7KCl5LADPIH2/SGUeykyOUhtY0EH7TU7\n" \
"R8stxWPhZCa+AjtYiKCt+GQa0zJ3G16XNQEkQXb1qHUMsoil2OMmXiRLdWnZrbe0\n" \
"eOH2zg/0qcjq6qEBAWwKxPsyhQKBgQDpiD1M8hafe1N7HGHRwkpyh6El1tehgeFS\n" \
"U1C3fUFXg0jJiTYt3MPu5y+obkwuDJiZxHsc2+buzuaHKWT3QKMwYyOlKEW/AUPr\n" \
"eaVwL7QdILpS2skly/gyb+L+v/qcCw7oEOZBkrqs6nliBSdtdVIYSaDwk+ZTvy+k\n" \
"qQzv6NLZ5QKBgQC3Oc7UBIkor++LsDEzlrs43PqTI4MjN9mHmIuvFs5MR+HXRIPI\n" \
"B0QPG4zeNq9hYGGz/rgFLyH953vGe99oxlmphBsg2EH1EVuhb0y44TTsB2ZeMaFT\n" \
"5bj1W81sFnRU9Ty+omHdoYxLZZH59CJ6Ku6a31Ke1zDilFe5hlEoiKHclQKBgQCA\n" \
"dKlLduqMxRiRYnZIHDg5MXOpzbiEpV2cnCmNFL/pdFSITwpXRdUcx1wGqM0Mk28p\n" \
"PdN+d1NazsItXM6l1o8tIYtM4I8k1nxWvW4L/KF1WDhFNQVp3f1jOPpCqLUgOlDz\n" \
"w7/0F9LJJJyqFy2t3tfOdkmpq6EJNePuDfMI0CeTJQKBgBUrxpYdrcky7iYM9uMW\n" \
"on4p6N0lIMmG3BX69Xp+qJLtchbPK77J9pv41Dy4XlMWbiyULr3/nRlMxayGtChl\n" \
"GCBVsyjjecOS2YoWlJWmr/+IVTJiN3LOLZjjLON1wQhy9nk4SEuL1zzkbJm+avuo\n" \
"nZCdwulmKMmYZyFOg3QfRkC/\n" \
"-----END PRIVATE KEY-----\n";

const char* client_cert = "-----BEGIN CERTIFICATE-----\n" \
"MIIDyTCCArGgAwIBAgIURexqsGAq/U2q/KDrHQYJqYZAUL0wDQYJKoZIhvcNAQEL\n" \
"BQAwczELMAkGA1UEBhMCREUxCzAJBgNVBAgMAkJFMQ8wDQYDVQQHDAZCRXJsaW4x\n" \
"DDAKBgNVBAoMA0VTUDEMMAoGA1UECwwDRVNQMQwwCgYDVQQDDANFU1AxHDAaBgkq\n" \
"hkiG9w0BCQEWDWhvaG9ob0Blc3AuZGUwIBcNMjQxMDAxMjA1MzExWhgPMjA1MjAy\n" \
"MTYyMDUzMTFaMHMxCzAJBgNVBAYTAkRFMQswCQYDVQQIDAJCRTEPMA0GA1UEBwwG\n" \
"QkVybGluMQwwCgYDVQQKDANFU1AxDDAKBgNVBAsMA0VTUDEMMAoGA1UEAwwDRVNQ\n" \
"MRwwGgYJKoZIhvcNAQkBFg1ob2hvaG9AZXNwLmRlMIIBIjANBgkqhkiG9w0BAQEF\n" \
"AAOCAQ8AMIIBCgKCAQEA6IdIz9rHjLQI8oqahd8U7GYHNABNGcyioqna3pVGmXQu\n" \
"6eZo6ZHK9lQvGqtclcs84zZX0plCLlKBFugjjzzB2jWAGQRA0Pv5GwVe36pcouJP\n" \
"HW+8pgDhzvTaVJEYT/1pneh0sBJicod3obqw0PABnpHvBGsHE5zb3urQ75QB1Lx5\n" \
"x6qkHAbfSHJhK2qbD6WDyadHScpHJIuyhoLnmcgAm1eXoqiOTptvsgO/TQt8/wpS\n" \
"pGIwkoh+Pi5HpiLRgYRcF37v5dJkh/u01+BeunV6hyEz+rQHPb/EfIwJRRgIdSdD\n" \
"qXcNfrx5EQQc12hg2VwlGlXGLuqiT9q442ieG6Dt+QIDAQABo1MwUTAdBgNVHQ4E\n" \
"FgQUKwSyP0Cle0HkI8UW8hAwHeoRc54wHwYDVR0jBBgwFoAUKwSyP0Cle0HkI8UW\n" \
"8hAwHeoRc54wDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAgmr4\n" \
"dmcWJoZ03clRrvpZIm88eY+cfkBxHgqYQqiuX3rxLd/ggpyJg4/qFTMd0XgZc2oc\n" \
"m1+wAX4iJ/5ZuIpfdpAmAtv0kBg6Br2W9lREDJx8lOFgO7Ae7GAQ7F5V9rQQNl3R\n" \
"Kfb44OqukWHfcj46XuRx+tsGODzasrl0c9Jl/77eUK5eSDuHada8xmiLEew8pUSB\n" \
"xHMqh2VZJ5BZbTkWzekBUeOVbw7EFaI5L6vZA4quuf9lvvoPxoFVvUip0GzxLaeh\n" \
"9TQqMO5yZwvtHEuX1sa/X/x64uRsmyLUjBlAj/iwyNO3Wt4paXqzKXXjio4ClKCq\n" \
"CCbO1A1+YrGnHMgtqg==\n" \
"-----END CERTIFICATE-----\n";

void setup() {

  Serial.begin(115200);
  Serial.println();

  WiFi.mode(WIFI_STA);
  WiFi.begin(ssid, password);
  Serial.print("Connecting to WiFi..");
  while (WiFi.status() != WL_CONNECTED) {
    Serial.print('.');
    delay(1000);
  }
  Serial.println(" connected!");

  WiFiClientSecure *client = new WiFiClientSecure;
  HTTPClient https;

  client->setPrivateKey(client_key);
  client->setCertificate(client_cert);
  client->setInsecure();
  
  Serial.println("Connecting to server...");

  https.begin(*client, "https://192.168.0.94:8444/smarthome/rooms");
  int httpCode = https.GET();
      
  if (httpCode > 0) {
    Serial.printf("GET... code: %d\n", httpCode);
    String payload = https.getString();
    Serial.println(payload);
  }
  else {
    Serial.printf("Connection failed, error: %s\n", https.errorToString(httpCode).c_str());
  }
    
  https.end();

}

void loop() {
}

I get the following messages on serial monitor:
Connecting to WiFi... connected!
Connecting to server...
Connection failed, error: connection refused

Do you have any idea where the mistake could be?

Regards, tobo

Now that you have posted a private key on a public forum, once/if you get this working, you should discard this key pair and create another one.

Under the Tools menu, in the board-specific options, set Core Debug Level to Verbose or Debug and re-Upload. That should have more details on why the connection failed. You might also try client->connect(IPAddress(192, 168, 0, 94), 8444) directly first, just to see if that works.

Note that Bosch's Best Practices says not to skip host verification as you have done with client->setInsecure(). Once you get the connection working, the repo has the PEM to use with client->setCACert.

Thank for your reply, kenb4.

I activated debug level "debug" and get the following result:

Connecting to server...
[  1152][D][HTTPClient.cpp:303] beginInternal(): protocol: https, host: 192.168.0.94 port: 8444 url: /smarthome/rooms
[  1154][D][HTTPClient.cpp:598] sendRequest(): request type: 'GET' redirCount: 0
[  1178][D][ssl_client.cpp:178] start_ssl_client(): WARNING: Skipping SSL Verification. INSECURE!
[  3545][E][ssl_client.cpp:37] _handle_error(): [start_ssl_client():277]: (-80) UNKNOWN ERROR CODE (0050)
[  3546][E][WiFiClientSecure.cpp:144] connect(): start_ssl_client: -80
[  3551][D][HTTPClient.cpp:1163] connect(): failed connect to 192.168.0.94:8444
[  3557][W][HTTPClient.cpp:1483] returnError(): error(-1): connection refused
Connection failed, error: connection refused
[  3574][D][HTTPClient.cpp:408] disconnect(): tcp is closed

When I use "client->connect(IPAddress(192, 168, 0, 94), 8444)", I also get this error code -80:

Connecting to server...
[  1205][D][ssl_client.cpp:178] start_ssl_client(): WARNING: Skipping SSL Verification. INSECURE!
[  3524][E][ssl_client.cpp:37] _handle_error(): [start_ssl_client():277]: (-80) UNKNOWN ERROR CODE (0050)
[  3524][E][WiFiClientSecure.cpp:144] connect(): start_ssl_client: -80

Any idea what that means? I also tried to use the server authentication with client->setCACert(BSH_root_CA); instead of client->setInsecure();. Bosch uploaded an Issuing CA.pem and an Productive Root CA.pem, I tried them both but got this result:

Connecting to server...
[  1350][E][ssl_client.cpp:37] _handle_error(): [start_ssl_client():277]: (-9984) X509 - Certificate verification failed, e.g. CRL, CA or signature check failed
[  1353][E][WiFiClientSecure.cpp:144] connect(): start_ssl_client: -9984

Is there maybe an error how I code the certificates in my program?

Some line breaks for clarity:

The action is in the file ssl_client.cpp. At line 178, inside the function start_ssl_client, is the normal warning when using setInsecure. It skips a bunch of stuff.

Near the top of the file at line 37 is the _handle_error function. It will print the name of the function and the line number where it was called: still in start_ssl_client, at line 277, almost one hundred lines later.

The bad news is this is an UNKNOWN ERROR CODE, with the absolute four-digit hex value 0050. All the error codes are negative; it is, as in the parentheses at the beginning, -80. The named errors are usually large numbers like -0x2700 or -0x6880

Checking the main source repo, there is no obvious match for the line numbers. Which board and version are you using? Another way to check is to hover over the #include <WiFiClientSecure.h> line, and you should get a popup giving you the full path in a single line. Try triple-click to select and copy if needed. It should break down into several parts (using an example I have handy)

  • likely start with your home directory, and somewhere in there
  • /.arduino15/packages: the OS-specific (Linux here) Arduino15 directory and packages
  • /m5stack/hardware/esp32/2.1.1: vendor, hardware, board type, version
  • /libraries/WiFiClientSecure/src/WiFiClientSecure.h: actual path in the code

In that same directory on your computer, you should have ssl_client.cpp. Go to line 278 and post the code around it. In a near match, it looks like

log_v("Performing the SSL/TLS handshake...");
unsigned long handshake_start_time=millis();
while ((ret = mbedtls_ssl_handshake(&ssl_client->ssl_ctx)) != 0) {
    if (ret != MBEDTLS_ERR_SSL_WANT_READ && ret != MBEDTLS_ERR_SSL_WANT_WRITE) {
        return handle_error(ret);
    }
    if((millis()-handshake_start_time)>ssl_client->handshake_timeout)
        return -1;
    vTaskDelay(2);//2 ticks
}

It's calling mbedtls_ssl_handshake, and when that returns -80, it calls handle_error, which is a macro that calls the actual _handle_error with the function and line number.

You might also change the Core Debug Level to Verbose, since there are useful messages at that level. The idea here is to confirm which function is returning that -80, to focus on that.

Hello kenb4,

thanks for you effort and in-depth description, which helps me a lot!

I use an ESP32 devkit v1 (Wroom32). Here is the code around line 278 in ssl_client.cpp:

 while ((ret = mbedtls_ssl_handshake(&ssl_client->ssl_ctx)) != 0) {
        if (ret != MBEDTLS_ERR_SSL_WANT_READ && ret != MBEDTLS_ERR_SSL_WANT_WRITE) {
            return handle_error(ret);								
        }
        if((millis()-handshake_start_time)>ssl_client->handshake_timeout)
            return -1;
        vTaskDelay(2);//2 ticks
    }

So the error comes from the mbedtls_ssl_handshake() function. I found this page with a list of all error codes. There is a "low level error code" 0x0050 NET - Connection was reset by peer. Is this my code -80?.

There is one more thing I need to explain: Before I can access port 8444, I must first access port 8443 and send a JSON body with my client_cert. This seems to work, I get a confirmation from the Bosch controller.

I assume that the controller compares the certificate sent with the one currently in use when I try to access port 8444. I can access port 8444 with Postman using my client_cert after I have registered this certificate with my ESP32. Could error code -80 mean that this comparison fails?

So I think there is something wrong with the way I am embedding the certificates into my code. This would also explain why the Bosch root certificate via client->setCACert(BSH_root_CA) is not accepted. Do you see an error in the coding of my certificates?

The PEMs look OK at a glance. You can reduce the visual noise and copy&paste errors by using a raw string

const char GOOGLE_ROOT_CERT_R1[] = R"(
-----BEGIN CERTIFICATE-----
MIIFYjCCBEqgAwIBAgIQd70NbNs2+RrqIQ/E8FjTDTANBgkqhkiG9w0BAQsFADBX
MQswCQYDVQQGEwJCRTEZMBcGA1UEChMQR2xvYmFsU2lnbiBudi1zYTEQMA4GA1UE
CxMHUm9vdCBDQTEbMBkGA1UEAxMSR2xvYmFsU2lnbiBSb290IENBMB4XDTIwMDYx
OTAwMDA0MloXDTI4MDEyODAwMDA0MlowRzELMAkGA1UEBhMCVVMxIjAgBgNVBAoT
GUdvb2dsZSBUcnVzdCBTZXJ2aWNlcyBMTEMxFDASBgNVBAMTC0dUUyBSb290IFIx
MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAthECix7joXebO9y/lD63
ladAPKH9gvl9MgaCcfb2jH/76Nu8ai6Xl6OMS/kr9rH5zoQdsfnFl97vufKj6bwS
iV6nqlKr+CMny6SxnGPb15l+8Ape62im9MZaRw1NEDPjTrETo8gYbEvs/AmQ351k
KSUjB6G00j0uYODP0gmHu81I8E3CwnqIiru6z1kZ1q+PsAewnjHxgsHA3y6mbWwZ
DrXYfiYaRQM9sHmklCitD38m5agI/pboPGiUU+6DOogrFZYJsuB6jC511pzrp1Zk
j5ZPaK49l8KEj8C8QMALXL32h7M1bKwYUH+E4EzNktMg6TO8UpmvMrUpsyUqtEj5
cuHKZPfmghCN6J3Cioj6OGaK/GP5Afl4/Xtcd/p2h/rs37EOeZVXtL0m79YB0esW
CruOC7XFxYpVq9Os6pFLKcwZpDIlTirxZUTQAs6qzkm06p98g7BAe+dDq6dso499
iYH6TKX/1Y7DzkvgtdizjkXPdsDtQCv9Uw+wp9U7DbGKogPeMa3Md+pvez7W35Ei
Eua++tgy/BBjFFFy3l3WFpO9KWgz7zpm7AeKJt8T11dleCfeXkkUAKIAf5qoIbap
sZWwpbkNFhHax2xIPEDgfg1azVY80ZcFuctL7TlLnMQ/0lUTbiSw1nH69MG6zO0b
9f6BQdgAmD06yK56mDcYBZUCAwEAAaOCATgwggE0MA4GA1UdDwEB/wQEAwIBhjAP
BgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBTkrysmcRorSCeFL1JmLO/wiRNxPjAf
BgNVHSMEGDAWgBRge2YaRQ2XyolQL30EzTSo//z9SzBgBggrBgEFBQcBAQRUMFIw
JQYIKwYBBQUHMAGGGWh0dHA6Ly9vY3NwLnBraS5nb29nL2dzcjEwKQYIKwYBBQUH
MAKGHWh0dHA6Ly9wa2kuZ29vZy9nc3IxL2dzcjEuY3J0MDIGA1UdHwQrMCkwJ6Al
oCOGIWh0dHA6Ly9jcmwucGtpLmdvb2cvZ3NyMS9nc3IxLmNybDA7BgNVHSAENDAy
MAgGBmeBDAECATAIBgZngQwBAgIwDQYLKwYBBAHWeQIFAwIwDQYLKwYBBAHWeQIF
AwMwDQYJKoZIhvcNAQELBQADggEBADSkHrEoo9C0dhemMXoh6dFSPsjbdBZBiLg9
NR3t5P+T4Vxfq7vqfM/b5A3Ri1fyJm9bvhdGaJQ3b2t6yMAYN/olUazsaL+yyEn9
WprKASOshIArAoyZl+tJaox118fessmXn1hIVw41oeQa1v1vg4Fv74zPl6/AhSrw
9U5pCZEt4Wi4wStz6dTZ/CLANx8LZh1J7QJVj2fhMtfTJr9w4z30Z209fOU0iOMy
+qduBmpvvYuR7hZL6Dupszfnw0Skfths18dG9ZKb59UhvmaSGZRVbNQpsg3BZlvi
d0lIKO2d1xozclOzgjXPYovJJIultzkMu34qQb9Sz/yilrbCgj8=
-----END CERTIFICATE-----
)";

That's the "GTS Root R1" (Google Trust Services) certificate, which I happen to have handy. Try connecting to google.com:443 with nothing, not even setInsecure. You should get an error. Then try that with setCACert. Use Verbose to see more of the steps. You can then use the Bosch root cert with the home controller. You should not need the intermediate.

To track down the -80, you need the version of Mbed TLS in use. Seems like the easiest way to do that is to add

Serial.println(MBEDTLS_VERSION_STRING);

The code is included as a static library, so you have the headers, but not the implementation. If you're wondering, those headers should be under the tools -- sibling to the libraries

hardware/esp32/2.1.1$ rg MBEDTLS_VERSION_STRING tools
tools/sdk/esp32s3/include/mbedtls/mbedtls/include/mbedtls/version.h
49:#define MBEDTLS_VERSION_STRING         "2.28.4"
50:#define MBEDTLS_VERSION_STRING_FULL    "mbed TLS 2.28.4"

I happen to have 2.28.4, so mbedtls_ssl_handshake is here. Also of interest is mbedtls_strerror, which is how the error codes are converted to descriptions, with both the "high level" and "low level" parts.

Thx, I guess it's a good idea to solve the problem with setCACert before I try to connect with my self-signed client certificates and key.

I used your Google certificate and it works! I can connect to google.com:443. However, it does not work with the Bosch certificate to connect to my controller. My MBEDTLS_VERSION is 2.28.3

I was a bit frustrated and tried to use an ESP8266. And surprisingly it works with that. I needed to receive the time to make it work. That's the code:

#include <Arduino.h>
#include <ESP8266WiFi.h> 
#include <WiFiClientSecure.h>

const char* ssid = "#######";
const char* password = "########";

const char* BSH_root_CA = R"string_literal(
-----BEGIN CERTIFICATE-----
MIIFETCCAvmgAwIBAgIUR8y7kFBqVMZCYZdSQWVuVJgSAqYwDQYJKoZIhvcNAQEL
I cut some lines here
1Eg5GjNiavG7ninMOWSJLfW4xPD6S3zxDAYjsPDJbMFqEFIF2ZvyYC1mVeflB/WM
xnZ+67w=
-----END CERTIFICATE-----
)string_literal";

WiFiClientSecure *client = new WiFiClientSecure;

void setup() {

  Serial.begin(115200);
  Serial.println();

  WiFi.mode(WIFI_STA);
  WiFi.begin(ssid, password);
  Serial.print("Connecting to WiFi..");
  while (WiFi.status() != WL_CONNECTED) {
    Serial.print('.');
    delay(1000);
  }
  Serial.println(" connected!");


  configTime(3 * 3600, 0, "pool.ntp.org", "time.nist.gov");

  Serial.print("Waiting for NTP time sync: ");
  time_t now = time(nullptr);
  while (now < 8 * 3600 * 2) {
    delay(500);
    Serial.print(".");
    now = time(nullptr);
  }
  Serial.println(" time received!");
  struct tm timeinfo;
  gmtime_r(&now, &timeinfo);
  Serial.println("Current time: ");
  Serial.print(asctime(&timeinfo));
  
  X509List cert(BSH_root_CA);

  client->setTrustAnchors(&cert);

  Serial.println("Connecting to server...");

  if (!client->connect(IPAddress(192, 168, 0, 94), 8446)) {
    Serial.println("Connection failed!");
  }
  else {
    Serial.println("Connected to server!");
  }
}

void loop() {
}

And here the code for the ESP32. It's almost the same:

#include <Arduino.h>
#include <WiFi.h>
#include <WiFiClientSecure.h>

const char* ssid = "#####";
const char* password = "#####";

const char* BSH_root_CA = R"string_literal(
-----BEGIN CERTIFICATE-----
MIIFETCCAvmgAwIBAgIUR8y7kFBqVMZCYZdSQWVuVJgSAqYwDQYJKoZIhvcNAQEL
I cut some lines here
1Eg5GjNiavG7ninMOWSJLfW4xPD6S3zxDAYjsPDJbMFqEFIF2ZvyYC1mVeflB/WM
xnZ+67w=
-----END CERTIFICATE-----
)string_literal";

WiFiClientSecure *client = new WiFiClientSecure;

void setup() {

  Serial.begin(115200);
  Serial.println();

  WiFi.mode(WIFI_STA);
  WiFi.begin(ssid, password);
  Serial.print("Connecting to WiFi..");
  while (WiFi.status() != WL_CONNECTED) {
    Serial.print('.');
    delay(1000);
  }
  Serial.println(" connected!");

  configTime(3 * 3600, 0, "pool.ntp.org", "time.nist.gov");

  Serial.print("Waiting for NTP time sync: ");
  time_t now = time(nullptr);
  while (now < 8 * 3600 * 2) {
    delay(500);
    Serial.print(".");
    now = time(nullptr);
  }
  Serial.println(" time received!");
  struct tm timeinfo;
  gmtime_r(&now, &timeinfo);
  Serial.println("Current time: ");
  Serial.print(asctime(&timeinfo));

  client->setCACert(BSH_root_CA);
  
  Serial.println("Connecting to server...");
  
  if (!client->connect(IPAddress(192, 168, 0, 94), 8446)) {
    Serial.println("Connection failed!");
  }
  else {
    Serial.println("Connected to server!");
  }
}

void loop() {
}

I receive this on verbose debug level with the ESP32:

Connecting to server...
[  5168][V][ssl_client.cpp:62] start_ssl_client(): Free internal heap before TLS 220204
[  5171][V][ssl_client.cpp:68] start_ssl_client(): Starting socket
[  5201][V][ssl_client.cpp:147] start_ssl_client(): Seeding the random number generator
[  5202][V][ssl_client.cpp:156] start_ssl_client(): Setting up the SSL/TLS structure...
[  5206][V][ssl_client.cpp:180] start_ssl_client(): Loading CA cert
[  5216][V][ssl_client.cpp:256] start_ssl_client(): Setting hostname for TLS session...
[  5219][V][ssl_client.cpp:272] start_ssl_client(): Performing the SSL/TLS handshake...
[  5322][E][ssl_client.cpp:37] _handle_error(): [start_ssl_client():277]: (-9984) X509 - Certificate verification failed, e.g. CRL, CA or signature check failed
[  5325][E][WiFiClientSecure.cpp:144] connect(): start_ssl_client: -9984
[  5332][V][ssl_client.cpp:326] stop_ssl_socket(): Cleaning SSL connection.
Connection failed!

So, it's not a problem with the certificate. The ESP8266 wificlientsecure seems to use BearSSL. So the problem is perhaps MBEDTLS used on the ESP32. Might it help to upgrade to a newer version? I'm not sure how to do this because It's not listed in my library manager.

Edit: I just updated my ESP32 package to the newest version. MbedTLS version is now 3.6.0. I still get the same error.

Edit2: Connection with client_cert and client_key are accepted on ESP8266. I can access port 8444 without an error. I'm really curious why it works with ESP8266 but not with ESP32.

The PEM ending with "xnZ+67w=" is the "Issuing CA", not the "Productive Root CA", but I doubt that will make a difference.

The PEMs in your code are fine; the Google one works, and the Bosch one works with BearSSL. It might be quicker to go with BearSSL. There is an Arduino port as a library, ArduinoBearSSL, with examples. Maybe it works with ESP32?

With Mbed TLS, the error with setCACert: -9984 is

#define MBEDTLS_ERR_X509_CERT_VERIFY_FAILED               -0x2700

which is a legitimate "expected" error. Not so sure about -80, which is

#define MBEDTLS_ERR_NET_CONN_RESET                        -0x0050

But it's defined in library/net_sockets, and seems to be used more in the programs that come with Mbed TLS, and not in library/ssl_tls. Could be wrong though. There are lots of MBEDTLS_SSL_DEBUG_MSG in the latter, so if you want to pursue that: first, get it to build; then the best bet might be to switch to an appropriate version tag, make sure the debug messages are enabled with MBEDTLS_DEBUG_C, and then modify or add a test for your use cases.

FYI, this is what the messages for a successful mTLS connection look like

[  4629][V][ssl_client.cpp:62] start_ssl_client(): Free internal heap before TLS 262556
[  4630][V][ssl_client.cpp:68] start_ssl_client(): Starting socket
[  4859][V][ssl_client.cpp:146] start_ssl_client(): Seeding the random number generator
[  4862][V][ssl_client.cpp:155] start_ssl_client(): Setting up the SSL/TLS structure...
[  4865][V][ssl_client.cpp:178] start_ssl_client(): Loading CA cert
[  4873][V][ssl_client.cpp:234] start_ssl_client(): Loading CRT cert
[  4878][V][ssl_client.cpp:243] start_ssl_client(): Loading private key
[  4886][V][ssl_client.cpp:254] start_ssl_client(): Setting hostname for TLS session...
[  4890][V][ssl_client.cpp:269] start_ssl_client(): Performing the SSL/TLS handshake...
[  6094][D][ssl_client.cpp:282] start_ssl_client(): Protocol is TLSv1.2 Ciphersuite is TLS-ECDHE-RSA-WITH-AES-128-GCM-SHA256
[  6094][D][ssl_client.cpp:284] start_ssl_client(): Record expansion is 29
[  6100][V][ssl_client.cpp:290] start_ssl_client(): Verifying peer X.509 certificate...
[  6108][V][ssl_client.cpp:298] start_ssl_client(): Certificate verified.
[  6114][V][ssl_client.cpp:313] start_ssl_client(): Free internal heap after TLS 216892
[  6122][V][ssl_client.cpp:369] send_ssl_data(): Writing HTTP request with 102 bytes...