From 1d1d6ec367d8e64d80647e55a749a2b94ed73f41 Mon Sep 17 00:00:00 2001 From: jpirnay Date: Tue, 14 Jul 2026 16:11:19 +0200 Subject: [PATCH 1/6] Verify the peer hostname against the certificate Two gaps in the verified-connect path: - wolfSSL_CTX_load_verify_buffer's result was ignored, so a CA PEM that fails to parse silently left the context with an empty trust store and every subsequent handshake failed with a misleading no-signer error. Fail the connect immediately with a clear log instead. - The handshake verified the certificate chain but never the hostname: any certificate signed by a trusted root was accepted for any server, which defeats the point of verification against an active MITM holding a valid certificate for a different domain. Register the SNI host with wolfSSL_check_domain_name so wolfSSL matches it against the peer certificate's SAN/CN during the handshake. --- libs/network/SecureNet/src/SecureClient.cpp | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/libs/network/SecureNet/src/SecureClient.cpp b/libs/network/SecureNet/src/SecureClient.cpp index 417b9d48..bb0adfaf 100644 --- a/libs/network/SecureNet/src/SecureClient.cpp +++ b/libs/network/SecureNet/src/SecureClient.cpp @@ -80,8 +80,16 @@ int SecureClient::connectWithMethod(const char* host, uint16_t port, void* metho if (_insecure) { wolfSSL_CTX_set_verify(ctx, WOLFSSL_VERIFY_NONE, nullptr); } else if (_rootCA) { - wolfSSL_CTX_load_verify_buffer(ctx, reinterpret_cast(_rootCA), - strlen(_rootCA), WOLFSSL_FILETYPE_PEM); + // A CA that fails to parse must fail the connect, not silently continue + // against an empty trust store (every verified handshake would then fail + // with a misleading no-signer error). + if (wolfSSL_CTX_load_verify_buffer(ctx, reinterpret_cast(_rootCA), strlen(_rootCA), + WOLFSSL_FILETYPE_PEM) != WOLFSSL_SUCCESS) { + if (Serial) Serial.printf("[SecureClient] setCACert PEM did not parse (%s)\n", label); + stop(); + return 0; + } + wolfSSL_CTX_set_verify(ctx, WOLFSSL_VERIFY_PEER, nullptr); } wolfSSL_SetIORecv(ctx, wcRecv); wolfSSL_SetIOSend(ctx, wcSend); @@ -96,6 +104,12 @@ int SecureClient::connectWithMethod(const char* host, uint16_t port, void* metho wolfSSL_SetIOReadCtx(ssl, &_transport); wolfSSL_SetIOWriteCtx(ssl, &_transport); wolfSSL_UseSNI(ssl, WOLFSSL_SNI_HOST_NAME, host, strlen(host)); + if (!_insecure && _rootCA) { + // Chain verification alone accepts ANY certificate signed by the trusted + // roots, regardless of which server it was issued to. Also match the + // hostname against the certificate's SAN/CN. + wolfSSL_check_domain_name(ssl, host); + } // The recv callback is non-blocking (returns WANT_READ when no bytes are // buffered), so wolfSSL_connect must be retried across handshake round-trips From 57ec924bfc9864794735c5a99d51cdf2b0ed1a73 Mon Sep 17 00:00:00 2001 From: jpirnay Date: Tue, 14 Jul 2026 16:20:20 +0200 Subject: [PATCH 2/6] Skip the TLS 1.2 retry when verification failed A certificate-verification failure is deterministic for a given server: retrying the handshake with an explicit TLS 1.2 ClientHello reaches the identical error, at the cost of a second full handshake (seconds of latency plus the ECC/RSA bignum heap spike). Record wolfSSL_get_error() from the failed handshake, classify the verification-class codes (untrusted/expired/self-signed/mismatched certificates), and only run the version-intolerance retry for transport/protocol failures. The stored code is reset at the start of every connect so a stale verification error from an earlier attempt cannot misclassify a later TCP/DNS failure, and a handshake timeout is deliberately classified as transport. This also gives the upcoming insecure-fallback policy a reliable signal for which failures a fallback could even help with. --- libs/network/SecureNet/include/SecureClient.h | 1 + libs/network/SecureNet/src/SecureClient.cpp | 34 +++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/libs/network/SecureNet/include/SecureClient.h b/libs/network/SecureNet/include/SecureClient.h index 8d1ee1e1..a423a718 100644 --- a/libs/network/SecureNet/include/SecureClient.h +++ b/libs/network/SecureNet/include/SecureClient.h @@ -57,6 +57,7 @@ class SecureClient : public Client { WiFiClient _transport; const char* _rootCA = nullptr; bool _insecure = false; + int _lastConnectErr = 0; // wolfSSL_get_error() from the last failed handshake; 0 = none void* _ssl = nullptr; // WOLFSSL* (opaque to keep wolfSSL headers out of here) void* _ctx = nullptr; // WOLFSSL_CTX* bool _connected = false; diff --git a/libs/network/SecureNet/src/SecureClient.cpp b/libs/network/SecureNet/src/SecureClient.cpp index bb0adfaf..4dd46ef2 100644 --- a/libs/network/SecureNet/src/SecureClient.cpp +++ b/libs/network/SecureNet/src/SecureClient.cpp @@ -4,6 +4,7 @@ // build free of the wolfSSL dependency while leaving a single, well-defined // integration point for the TLS 1.3 transport. #if defined(FREEINK_NET_WOLFSSL) +#include // VERIFY_CERT_ERROR, DOMAIN_NAME_MISMATCH (SSL-layer codes) #include #endif @@ -50,6 +51,26 @@ bool isWantIo(const int err) { return err == WOLFSSL_ERROR_WANT_READ || err == WOLFSSL_ERROR_WANT_WRITE || err == WOLFSSL_CBIO_ERR_WANT_READ || err == WOLFSSL_CBIO_ERR_WANT_WRITE; } + +// True if the wolfSSL error code is a peer-certificate-verification failure +// (as opposed to a transport/protocol failure). A verification failure is +// deterministic for a given server: retrying the handshake with a different +// TLS version (or any number of times) cannot change the outcome. +bool isVerificationError(const int err) { + switch (err) { + case ASN_NO_SIGNER_E: // no trusted root for the chain + case ASN_SIG_CONFIRM_E: // signature check failed + case ASN_BEFORE_DATE_E: // notBefore in the future (device clock) + case ASN_AFTER_DATE_E: // expired + case ASN_SELF_SIGNED_E: + case CRL_CERT_DATE_ERR: + case VERIFY_CERT_ERROR: // generic certificate verification failure + case DOMAIN_NAME_MISMATCH: + return true; + default: + return false; + } +} } // namespace int SecureClient::connectWithMethod(const char* host, uint16_t port, void* method, const char* label) { @@ -119,11 +140,13 @@ int SecureClient::connectWithMethod(const char* host, uint16_t port, void* metho while ((ret = wolfSSL_connect(ssl)) != WOLFSSL_SUCCESS) { const int err = wolfSSL_get_error(ssl, ret); if (!isWantIo(err)) { + _lastConnectErr = err; if (Serial) Serial.printf("[SecureClient] wolfSSL_connect failed (%s): %d\n", label, err); stop(); return 0; } if (static_cast(millis() - deadline) >= 0) { + _lastConnectErr = WOLFSSL_ERROR_WANT_READ; // classify a timeout as transport, not verification if (Serial) { Serial.printf("[SecureClient] handshake timeout (%s): last err %d, transport %s, free heap %u\n", label, err, _transport.connected() ? "up" : "down", (unsigned)ESP.getFreeHeap()); @@ -142,12 +165,23 @@ int SecureClient::connectWithMethod(const char* host, uint16_t port, void* metho } int SecureClient::connect(const char* host, uint16_t port) { + // _lastConnectErr must not leak across connects: a TCP/DNS failure records no + // handshake error, and a stale verification code from an earlier attempt + // would misclassify it. + _lastConnectErr = 0; + // Negotiate the highest mutually supported version rather than pinning TLS 1.3: // self-hosted / Let's Encrypt nginx often tops out at TLS 1.2, and a 1.3-only // client fails those handshakes outright. v23 still selects 1.3 when the peer // offers it (WOLFSSL_TLS13 is enabled) and falls back to 1.2 otherwise. if (connectWithMethod(host, port, wolfSSLv23_client_method(), "auto")) return 1; + // A verification failure is deterministic: the same certificate fails the + // same checks over TLS 1.2, so the retry below would only burn another + // handshake (seconds of latency plus the ECC/RSA heap spike) to reach the + // identical error. + if (isVerificationError(_lastConnectErr)) return 0; + // Some TLS 1.2-only servers are intolerant of a TLS 1.3-capable ClientHello // and abort with a fatal handshake_failure alert. Retry with an explicit // TLS 1.2 ClientHello before giving up. From 698df79fe119d3b94d42d0f32a9388de4b9f48e7 Mon Sep 17 00:00:00 2001 From: jpirnay Date: Tue, 14 Jul 2026 16:30:53 +0200 Subject: [PATCH 3/6] Add an opt-in insecure fallback after verify failure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Field reality for a reader syncing against self-hosted servers: many run self-signed or lapsed certificates, and a client that can only fail closed pushes users to setInsecure() permanently — every connection unverified, including the ones that would have verified fine. connect() is now verified-first: the normal path verifies against the configured CA. When the handshake fails with a verification-class error AND the caller has opted in via setAllowInsecureFallback(true), it retries once without verification and logs a WARNING. The result is recorded in lastConnectWasInsecure() so callers can surface an "unverified" indicator or refuse to send credentials. Deliberate constraints: - Off by default. Security-critical callers (OTA firmware download) simply never enable it and keep failing closed. - Only verification-class failures trigger the fallback (from the classification added in the previous commit); DNS/TCP/protocol errors never do, since retrying those unverified gains nothing and would hide the real problem. - setInsecure() keeps its existing meaning (skip verification outright) and is likewise recorded in lastConnectWasInsecure(). --- libs/network/SecureNet/include/SecureClient.h | 17 +++++- libs/network/SecureNet/src/SecureClient.cpp | 56 +++++++++++++++---- 2 files changed, 61 insertions(+), 12 deletions(-) diff --git a/libs/network/SecureNet/include/SecureClient.h b/libs/network/SecureNet/include/SecureClient.h index a423a718..e4dc2b1b 100644 --- a/libs/network/SecureNet/include/SecureClient.h +++ b/libs/network/SecureNet/include/SecureClient.h @@ -32,6 +32,16 @@ class SecureClient : public Client { // Certificate / verification configuration (applied before connect()). void setCACert(const char* rootCA); void setInsecure(); // skip peer verification (testing only) + // Opt-in: when a CA is set and the handshake fails with a verification-class + // error (untrusted/expired/self-signed/mismatched certificate), retry once + // with verification disabled, logging a warning. Off by default — security- + // critical callers (OTA) must leave this off so downloads fail closed. + // Transport/protocol failures never trigger the fallback. + void setAllowInsecureFallback(bool allow) { _allowInsecureFallback = allow; } + // True if the last successful connect() ended on an unverified handshake + // (via setInsecure() or the fallback above) — an audit hook for callers that + // surface a "connection not verified" indicator. + bool lastConnectWasInsecure() const { return _lastWasInsecure; } // Connect and perform a TLS 1.3 handshake to host:port (uses the SNI host). int connect(IPAddress ip, uint16_t port) override; @@ -52,11 +62,16 @@ class SecureClient : public Client { static bool tls13Available(); private: - int connectWithMethod(const char* host, uint16_t port, void* method, const char* label); + // One handshake attempt at a fixed TLS method and verification level. + int connectWithMethod(const char* host, uint16_t port, void* method, const char* label, bool verifyPeer); + // One connect attempt at a fixed verification level, incl. the TLS 1.2 retry. + int connectAtVerify(const char* host, uint16_t port, bool verifyPeer); WiFiClient _transport; const char* _rootCA = nullptr; bool _insecure = false; + bool _allowInsecureFallback = false; + bool _lastWasInsecure = false; int _lastConnectErr = 0; // wolfSSL_get_error() from the last failed handshake; 0 = none void* _ssl = nullptr; // WOLFSSL* (opaque to keep wolfSSL headers out of here) void* _ctx = nullptr; // WOLFSSL_CTX* diff --git a/libs/network/SecureNet/src/SecureClient.cpp b/libs/network/SecureNet/src/SecureClient.cpp index 4dd46ef2..15be454d 100644 --- a/libs/network/SecureNet/src/SecureClient.cpp +++ b/libs/network/SecureNet/src/SecureClient.cpp @@ -73,7 +73,9 @@ bool isVerificationError(const int err) { } } // namespace -int SecureClient::connectWithMethod(const char* host, uint16_t port, void* method, const char* label) { +// One handshake attempt at a fixed TLS method and verification level. +int SecureClient::connectWithMethod(const char* host, uint16_t port, void* method, const char* label, + bool verifyPeer) { #if defined(FREEINK_WOLFSSL_DEBUG) // Routes wolfSSL's internal trace through wolfSSL_Arduino_Serial_Print (the // application provides that hook). Shows exactly where a handshake stalls. @@ -98,7 +100,7 @@ int SecureClient::connectWithMethod(const char* host, uint16_t port, void* metho } _ctx = ctx; - if (_insecure) { + if (!verifyPeer || _insecure) { wolfSSL_CTX_set_verify(ctx, WOLFSSL_VERIFY_NONE, nullptr); } else if (_rootCA) { // A CA that fails to parse must fail the connect, not silently continue @@ -125,7 +127,7 @@ int SecureClient::connectWithMethod(const char* host, uint16_t port, void* metho wolfSSL_SetIOReadCtx(ssl, &_transport); wolfSSL_SetIOWriteCtx(ssl, &_transport); wolfSSL_UseSNI(ssl, WOLFSSL_SNI_HOST_NAME, host, strlen(host)); - if (!_insecure && _rootCA) { + if (verifyPeer && !_insecure && _rootCA) { // Chain verification alone accepts ANY certificate signed by the trusted // roots, regardless of which server it was issued to. Also match the // hostname against the certificate's SAN/CN. @@ -164,17 +166,14 @@ int SecureClient::connectWithMethod(const char* host, uint16_t port, void* metho return 1; } -int SecureClient::connect(const char* host, uint16_t port) { - // _lastConnectErr must not leak across connects: a TCP/DNS failure records no - // handshake error, and a stale verification code from an earlier attempt - // would misclassify it. - _lastConnectErr = 0; - +// One connect attempt at a fixed verification level, including the TLS 1.2 +// version-intolerance retry. +int SecureClient::connectAtVerify(const char* host, uint16_t port, bool verifyPeer) { // Negotiate the highest mutually supported version rather than pinning TLS 1.3: // self-hosted / Let's Encrypt nginx often tops out at TLS 1.2, and a 1.3-only // client fails those handshakes outright. v23 still selects 1.3 when the peer // offers it (WOLFSSL_TLS13 is enabled) and falls back to 1.2 otherwise. - if (connectWithMethod(host, port, wolfSSLv23_client_method(), "auto")) return 1; + if (connectWithMethod(host, port, wolfSSLv23_client_method(), "auto", verifyPeer)) return 1; // A verification failure is deterministic: the same certificate fails the // same checks over TLS 1.2, so the retry below would only burn another @@ -186,7 +185,42 @@ int SecureClient::connect(const char* host, uint16_t port) { // and abort with a fatal handshake_failure alert. Retry with an explicit // TLS 1.2 ClientHello before giving up. if (Serial) Serial.println("[SecureClient] retrying with TLS 1.2-only handshake"); - return connectWithMethod(host, port, wolfTLSv1_2_client_method(), "tls1.2"); + return connectWithMethod(host, port, wolfTLSv1_2_client_method(), "tls1.2", verifyPeer); +} + +int SecureClient::connect(const char* host, uint16_t port) { + // _lastConnectErr must not leak across connects: a TCP/DNS failure records no + // handshake error, and a stale verification code from an earlier attempt + // would misclassify it. + _lastConnectErr = 0; + _lastWasInsecure = false; + + // Explicitly insecure (setInsecure()): skip verification outright. + if (_insecure) { + const int ok = connectAtVerify(host, port, /*verifyPeer=*/false); + _lastWasInsecure = ok == 1; + return ok; + } + + // Verified-first. + if (connectAtVerify(host, port, /*verifyPeer=*/true)) return 1; + + // Only a verification-class failure can be helped by retrying without + // verification; transport/protocol failures would fail the same way again. + if (_allowInsecureFallback && isVerificationError(_lastConnectErr)) { + if (Serial) { + Serial.printf("[SecureClient] WARNING: certificate verify failed for %s (err %d); retrying WITHOUT verification\n", + host, _lastConnectErr); + } + const int ok = connectAtVerify(host, port, /*verifyPeer=*/false); + _lastWasInsecure = ok == 1; + return ok; + } + + if (isVerificationError(_lastConnectErr) && Serial) { + Serial.printf("[SecureClient] certificate verify failed for %s (err %d); failing closed\n", host, _lastConnectErr); + } + return 0; } int SecureClient::connect(IPAddress ip, uint16_t port) { From cbf4aadcd2d24be611968bb4d4849a28c91c793b Mon Sep 17 00:00:00 2001 From: jpirnay Date: Tue, 14 Jul 2026 16:31:22 +0200 Subject: [PATCH 4/6] Provide a weak wolfSSL_Arduino_Serial_Print definition MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Arduino-wolfSSL's logging.c references wolfSSL_Arduino_Serial_Print unconditionally, but its definition lives in the library's wolfssl.h sketch glue — compiled only into sketch builds. A PlatformIO project that pulls wolfSSL via lib_deps (the documented SecureNet setup) fails at link time with an undefined reference. Define a weak default that routes to Serial. Applications that want the trace in their own logger define the symbol themselves and override this one. --- libs/network/SecureNet/src/SecureClient.cpp | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/libs/network/SecureNet/src/SecureClient.cpp b/libs/network/SecureNet/src/SecureClient.cpp index 15be454d..8e35dd8d 100644 --- a/libs/network/SecureNet/src/SecureClient.cpp +++ b/libs/network/SecureNet/src/SecureClient.cpp @@ -6,6 +6,18 @@ #if defined(FREEINK_NET_WOLFSSL) #include // VERIFY_CERT_ERROR, DOMAIN_NAME_MISMATCH (SSL-layer codes) #include + +// The Arduino-wolfSSL library's logging.c references this hook. It is normally +// defined in the library's wolfssl.h sketch glue, which is only compiled into +// sketch builds — a PlatformIO lib_deps build never compiles it and fails at +// link time with an undefined reference. Provide a weak default (routing to +// Serial) so SDK consumers link out of the box; an application that defines its +// own (e.g. routing into its logger) overrides this one. Signature must match +// wolfcrypt/logging.h exactly (int return). +extern "C" __attribute__((weak)) int wolfSSL_Arduino_Serial_Print(const char* const s) { + if (s && Serial) Serial.printf("[wolfSSL] %s\n", s); + return 0; +} #endif namespace freeink { From a7cf92a4f4c81348c2ae3a45e872b1a79b6f5e87 Mon Sep 17 00:00:00 2001 From: jpirnay Date: Tue, 14 Jul 2026 16:32:28 +0200 Subject: [PATCH 5/6] Record the heap trough across the TLS handshake The handshake is where the ECC/RSA bignum allocations peak, and on PSRAM-less boards (ESP32-C3, ~380 KB total) it is routinely the heap high-water mark of the whole networking path. ESP.getMinFreeHeap() only reports the all-time-since-boot minimum, so it cannot answer "what did THIS handshake cost" once anything else has dipped lower. Sample free-heap and largest-free-block on every handshake retry iteration and expose the minima via handshakeMinFree() / handshakeMinLargest(). Firmware can log them after a connect to size CA sets and buffers against real handshake pressure (this is how the single-pinned-root-vs-bundle decision was measured in a consuming firmware). Two heap walks per 5 ms retry are noise next to the handshake crypto itself. --- libs/network/SecureNet/include/SecureClient.h | 14 +++++++++++++- libs/network/SecureNet/src/SecureClient.cpp | 18 ++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/libs/network/SecureNet/include/SecureClient.h b/libs/network/SecureNet/include/SecureClient.h index e4dc2b1b..97383fa8 100644 --- a/libs/network/SecureNet/include/SecureClient.h +++ b/libs/network/SecureNet/include/SecureClient.h @@ -22,6 +22,9 @@ #include #include +#include +#include + namespace freeink { class SecureClient : public Client { @@ -58,6 +61,13 @@ class SecureClient : public Client { uint8_t connected() override; operator bool() override { return connected(); } + // Heap low-water sampled ACROSS the last handshake (free bytes / largest + // contiguous block). Distinct from ESP.getMinFreeHeap() (all-time since + // boot): this isolates what the TLS handshake itself cost, which is where + // PSRAM-less boards run out first. SIZE_MAX until a connect() has run. + size_t handshakeMinFree() const { return _handshakeMinFree; } + size_t handshakeMinLargest() const { return _handshakeMinLargest; } + // True if the library was built with wolfSSL TLS 1.3 support enabled. static bool tls13Available(); @@ -72,7 +82,9 @@ class SecureClient : public Client { bool _insecure = false; bool _allowInsecureFallback = false; bool _lastWasInsecure = false; - int _lastConnectErr = 0; // wolfSSL_get_error() from the last failed handshake; 0 = none + int _lastConnectErr = 0; // wolfSSL_get_error() from the last failed handshake; 0 = none + size_t _handshakeMinFree = SIZE_MAX; // heap trough during the last handshake + size_t _handshakeMinLargest = SIZE_MAX; // largest-block trough during the last handshake void* _ssl = nullptr; // WOLFSSL* (opaque to keep wolfSSL headers out of here) void* _ctx = nullptr; // WOLFSSL_CTX* bool _connected = false; diff --git a/libs/network/SecureNet/src/SecureClient.cpp b/libs/network/SecureNet/src/SecureClient.cpp index 8e35dd8d..44d86284 100644 --- a/libs/network/SecureNet/src/SecureClient.cpp +++ b/libs/network/SecureNet/src/SecureClient.cpp @@ -1,5 +1,7 @@ #include "SecureClient.h" +#include + // wolfSSL is only pulled in when explicitly enabled. This keeps the default SDK // build free of the wolfSSL dependency while leaving a single, well-defined // integration point for the TLS 1.3 transport. @@ -149,9 +151,22 @@ int SecureClient::connectWithMethod(const char* host, uint16_t port, void* metho // The recv callback is non-blocking (returns WANT_READ when no bytes are // buffered), so wolfSSL_connect must be retried across handshake round-trips // rather than called once. + // + // Sample the heap low-water across the handshake (this is where the ECC/RSA + // bignum allocations peak) so callers can report the handshake's real heap + // trough, distinct from the all-time ESP.getMinFreeHeap() figure. The two + // heap walks per 5 ms retry are noise next to the handshake crypto itself. + auto sampleHeapTrough = [this]() { + const size_t freeNow = esp_get_free_heap_size(); + const size_t largestNow = heap_caps_get_largest_free_block(MALLOC_CAP_DEFAULT); + if (freeNow < _handshakeMinFree) _handshakeMinFree = freeNow; + if (largestNow < _handshakeMinLargest) _handshakeMinLargest = largestNow; + }; + sampleHeapTrough(); const uint32_t deadline = millis() + 15000; int ret; while ((ret = wolfSSL_connect(ssl)) != WOLFSSL_SUCCESS) { + sampleHeapTrough(); const int err = wolfSSL_get_error(ssl, ret); if (!isWantIo(err)) { _lastConnectErr = err; @@ -170,6 +185,7 @@ int SecureClient::connectWithMethod(const char* host, uint16_t port, void* metho } delay(5); } + sampleHeapTrough(); _connected = true; if (Serial) { Serial.printf("[SecureClient] handshake ok (%s): %s / %s in %lu ms\n", label, wolfSSL_get_version(ssl), @@ -206,6 +222,8 @@ int SecureClient::connect(const char* host, uint16_t port) { // would misclassify it. _lastConnectErr = 0; _lastWasInsecure = false; + _handshakeMinFree = SIZE_MAX; + _handshakeMinLargest = SIZE_MAX; // Explicitly insecure (setInsecure()): skip verification outright. if (_insecure) { From 34aabdf2c8045dc90df6ec1b95f974224d8fcf37 Mon Sep 17 00:00:00 2001 From: jpirnay Date: Tue, 14 Jul 2026 18:32:49 +0200 Subject: [PATCH 6/6] Make feature an opt-in instead of default --- README.md | 1 + libs/network/SecureNet/include/SecureClient.h | 44 +++++++++++-------- libs/network/SecureNet/src/SecureClient.cpp | 12 ++++- 3 files changed, 38 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index 507ef725..c95e9d98 100644 --- a/README.md +++ b/README.md @@ -250,6 +250,7 @@ tight. Each defaults on when an included device needs it; force with `=0`/`=1`: | `-DEINK_DISPLAY_SINGLE_BUFFER_MODE=1` | single framebuffer (uses controller RAM as previous frame) | | `-DFREEINK_FB_PSRAM=1` | place the facade framebuffer(s) in PSRAM heap (`MALLOC_CAP_SPIRAM`, allocated in `begin()`) instead of static DRAM `.bss`; auto-on for M5Paper, off everywhere else | | `-DFREEINK_NET_WOLFSSL=1` | enable the wolfSSL TLS 1.3 transport in `SecureNet` | +| `-DFREEINK_NET_WOLFSSL_CERTS=1` | enable wolfSSL certificate verification and hostname checks in `SecureNet` | Panel **orientation/mirroring** is per-board data, not a flag: set `BoardProfile.orientation` (`NO_FLIP`, `MIRROR_X`, `MIRROR_Y`, or `ROTATE_180`). The SSD1677 driver applies it in diff --git a/libs/network/SecureNet/include/SecureClient.h b/libs/network/SecureNet/include/SecureClient.h index 97383fa8..c3d3d780 100644 --- a/libs/network/SecureNet/include/SecureClient.h +++ b/libs/network/SecureNet/include/SecureClient.h @@ -6,8 +6,8 @@ // TLS 1.3 compiled out as empty stubs (PSA crypto prerequisites disabled), so // WiFiClientSecure / esp_http_client cannot reach TLS-1.3-only servers // (e.g. KOSync at kosync.ak-team.com:3042 — handshake fails with -// -0x7780 MBEDTLS_ERR_SSL_FATAL_ALERT_MESSAGE). A -D Kconfig flag can't change a -// precompiled .a, and a custom_sdkconfig rebuild fails on managed-component +// -0x7780 MBEDTLS_ERR_SSL_FATAL_ALERT_MESSAGE). A -D Kconfig flag can't change +// a precompiled .a, and a custom_sdkconfig rebuild fails on managed-component // dependencies. The only fix that doesn't rebuild ESP-IDF is to bring our own // TLS stack compiled from source: wolfSSL, which supports TLS 1.3 + PSA. // @@ -17,6 +17,11 @@ // OPT-IN: enable with -DFREEINK_NET_WOLFSSL=1 and add wolfSSL to lib_deps. With // the flag off, this compiles to an inert no-op (connectSecure() returns false) // so the rest of the SDK builds without the wolfSSL dependency present. +// +// Certificate verification and hostname checks are further opt-in with +// -DFREEINK_NET_WOLFSSL_CERTS=1. Without that flag, setCACert() is accepted +// but certificate parsing/hostname verification are omitted so the TLS +// transport remains effectively insecure (but with a lower memory footprint). #include #include @@ -28,13 +33,13 @@ namespace freeink { class SecureClient : public Client { - public: +public: SecureClient() = default; ~SecureClient() override; // Certificate / verification configuration (applied before connect()). - void setCACert(const char* rootCA); - void setInsecure(); // skip peer verification (testing only) + void setCACert(const char *rootCA); + void setInsecure(); // skip peer verification (testing only) // Opt-in: when a CA is set and the handshake fails with a verification-class // error (untrusted/expired/self-signed/mismatched certificate), retry once // with verification disabled, logging a warning. Off by default — security- @@ -48,13 +53,13 @@ class SecureClient : public Client { // Connect and perform a TLS 1.3 handshake to host:port (uses the SNI host). int connect(IPAddress ip, uint16_t port) override; - int connect(const char* host, uint16_t port) override; + int connect(const char *host, uint16_t port) override; size_t write(uint8_t b) override; - size_t write(const uint8_t* buf, size_t size) override; + size_t write(const uint8_t *buf, size_t size) override; int available() override; int read() override; - int read(uint8_t* buf, size_t size) override; + int read(uint8_t *buf, size_t size) override; int peek() override; void flush() override; void stop() override; @@ -71,23 +76,26 @@ class SecureClient : public Client { // True if the library was built with wolfSSL TLS 1.3 support enabled. static bool tls13Available(); - private: +private: // One handshake attempt at a fixed TLS method and verification level. - int connectWithMethod(const char* host, uint16_t port, void* method, const char* label, bool verifyPeer); + int connectWithMethod(const char *host, uint16_t port, void *method, + const char *label, bool verifyPeer); // One connect attempt at a fixed verification level, incl. the TLS 1.2 retry. - int connectAtVerify(const char* host, uint16_t port, bool verifyPeer); + int connectAtVerify(const char *host, uint16_t port, bool verifyPeer); WiFiClient _transport; - const char* _rootCA = nullptr; + const char *_rootCA = nullptr; bool _insecure = false; bool _allowInsecureFallback = false; bool _lastWasInsecure = false; - int _lastConnectErr = 0; // wolfSSL_get_error() from the last failed handshake; 0 = none - size_t _handshakeMinFree = SIZE_MAX; // heap trough during the last handshake - size_t _handshakeMinLargest = SIZE_MAX; // largest-block trough during the last handshake - void* _ssl = nullptr; // WOLFSSL* (opaque to keep wolfSSL headers out of here) - void* _ctx = nullptr; // WOLFSSL_CTX* + int _lastConnectErr = + 0; // wolfSSL_get_error() from the last failed handshake; 0 = none + size_t _handshakeMinFree = SIZE_MAX; // heap trough during the last handshake + size_t _handshakeMinLargest = + SIZE_MAX; // largest-block trough during the last handshake + void *_ssl = nullptr; // WOLFSSL* (opaque to keep wolfSSL headers out of here) + void *_ctx = nullptr; // WOLFSSL_CTX* bool _connected = false; }; -} // namespace freeink +} // namespace freeink diff --git a/libs/network/SecureNet/src/SecureClient.cpp b/libs/network/SecureNet/src/SecureClient.cpp index 44d86284..8f0b325c 100644 --- a/libs/network/SecureNet/src/SecureClient.cpp +++ b/libs/network/SecureNet/src/SecureClient.cpp @@ -116,7 +116,9 @@ int SecureClient::connectWithMethod(const char* host, uint16_t port, void* metho if (!verifyPeer || _insecure) { wolfSSL_CTX_set_verify(ctx, WOLFSSL_VERIFY_NONE, nullptr); - } else if (_rootCA) { + } +#if defined(FREEINK_NET_WOLFSSL_CERTS) + else if (_rootCA) { // A CA that fails to parse must fail the connect, not silently continue // against an empty trust store (every verified handshake would then fail // with a misleading no-signer error). @@ -128,6 +130,12 @@ int SecureClient::connectWithMethod(const char* host, uint16_t port, void* metho } wolfSSL_CTX_set_verify(ctx, WOLFSSL_VERIFY_PEER, nullptr); } +#else + else if (_rootCA) { + if (Serial) Serial.printf("[SecureClient] certificate verification disabled by build flag; connecting insecurely (%s)\n", label); + wolfSSL_CTX_set_verify(ctx, WOLFSSL_VERIFY_NONE, nullptr); + } +#endif wolfSSL_SetIORecv(ctx, wcRecv); wolfSSL_SetIOSend(ctx, wcSend); @@ -141,12 +149,14 @@ int SecureClient::connectWithMethod(const char* host, uint16_t port, void* metho wolfSSL_SetIOReadCtx(ssl, &_transport); wolfSSL_SetIOWriteCtx(ssl, &_transport); wolfSSL_UseSNI(ssl, WOLFSSL_SNI_HOST_NAME, host, strlen(host)); +#if defined(FREEINK_NET_WOLFSSL_CERTS) if (verifyPeer && !_insecure && _rootCA) { // Chain verification alone accepts ANY certificate signed by the trusted // roots, regardless of which server it was issued to. Also match the // hostname against the certificate's SAN/CN. wolfSSL_check_domain_name(ssl, host); } +#endif // The recv callback is non-blocking (returns WANT_READ when no bytes are // buffered), so wolfSSL_connect must be retried across handshake round-trips