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 8d1ee1e1..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,49 +17,85 @@ // 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 #include +#include +#include + 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- + // 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; - 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; 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(); - private: - int connectWithMethod(const char* host, uint16_t port, void* method, const char* label); +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); + // 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; + const char *_rootCA = nullptr; bool _insecure = false; - void* _ssl = nullptr; // WOLFSSL* (opaque to keep wolfSSL headers out of here) - void* _ctx = nullptr; // WOLFSSL_CTX* + 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* bool _connected = false; }; -} // namespace freeink +} // namespace freeink diff --git a/libs/network/SecureNet/src/SecureClient.cpp b/libs/network/SecureNet/src/SecureClient.cpp index 417b9d48..8f0b325c 100644 --- a/libs/network/SecureNet/src/SecureClient.cpp +++ b/libs/network/SecureNet/src/SecureClient.cpp @@ -1,10 +1,25 @@ #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. #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 { @@ -50,9 +65,31 @@ 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) { +// 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. @@ -77,12 +114,28 @@ 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) { - wolfSSL_CTX_load_verify_buffer(ctx, reinterpret_cast(_rootCA), - strlen(_rootCA), WOLFSSL_FILETYPE_PEM); } +#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). + 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); + } +#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); @@ -96,20 +149,43 @@ 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 // 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; 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()); @@ -119,6 +195,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), @@ -127,18 +204,63 @@ int SecureClient::connectWithMethod(const char* host, uint16_t port, void* metho return 1; } -int SecureClient::connect(const char* host, uint16_t port) { +// 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 + // 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. 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; + _handshakeMinFree = SIZE_MAX; + _handshakeMinLargest = SIZE_MAX; + + // 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) {