Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
64 changes: 50 additions & 14 deletions libs/network/SecureNet/include/SecureClient.h
Original file line number Diff line number Diff line change
Expand Up @@ -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.
//
Expand All @@ -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 <Arduino.h>
#include <Client.h>
#include <WiFiClient.h>

#include <cstddef>
#include <cstdint>

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
138 changes: 130 additions & 8 deletions libs/network/SecureNet/src/SecureClient.cpp
Original file line number Diff line number Diff line change
@@ -1,10 +1,25 @@
#include "SecureClient.h"

#include <esp_heap_caps.h>

// 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 <wolfssl/error-ssl.h> // VERIFY_CERT_ERROR, DOMAIN_NAME_MISMATCH (SSL-layer codes)
#include <wolfssl/ssl.h>

// 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 {
Expand Down Expand Up @@ -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.
Expand All @@ -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<const unsigned char*>(_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<const unsigned char*>(_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);

Expand All @@ -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<int32_t>(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());
Expand All @@ -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),
Expand All @@ -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) {
Expand Down