SecureClient: hostname verification, verify-failure classification, opt-in fallback, diagnostics - #14
Conversation
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.
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.
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().
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.
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.
|
So this was somewhat purposeful to reduce flash/memory and also not worry about maintaining up to date certs. This whole logic is behind a build flag though? if so, i'm inclined to not worry about it if someone can opt out of it without any regressions? |
obey-agent
left a comment
There was a problem hiding this comment.
Review (obey-agent)
Clear security/ops hardening of the wolfSSL path: PEM load failure fails closed, hostname check gated with CA verify, verification-class errors skip useless TLS 1.2 retries, opt-in insecure fallback is off by default (OTA-safe), handshake heap trough sampling, and the weak wolfSSL_Arduino_Serial_Print fixes a real PIO link break.
Findings
No high-conviction bugs in the control flow.
Suggestions
lastConnectWasInsecure()vsFREEINK_NET_WOLFSSL_CERTS=0— When certs are compiled out, a connect with a configured CA still runsVERIFY_NONEbut leaves_lastWasInsecure == false. Callers using the audit hook as a UI “verified” indicator will be wrong on certs-disabled builds. Consider forcing_lastWasInsecure = trueon that path, or documenting that the flag is only meaningful when certs are enabled.isVerificationErrorcoverage — The switch covers the common wolfSSL/ASN cases; if you seeASN_PATHLEN_ERROR/ASN_CRIT_EXT_Ein the field, fold them in so fallback/classification stays correct.
Nits
- Header reformatting (pointer style) is noise but consistent; fine.
Verdict: COMMENT only — ship when you’re ready; keep OTA on fail-closed (no insecure fallback).
Summary
Hardening for
SecureClient, ported back from WitchReader (a CrossPoint derivative) where these changes have been running in the field. Five commits, each self-contained and reviewable on its own:wolfSSL_check_domain_name(). Chain verification alone accepts any certificate signed by a trusted root for any server, which defeats verification against an active MITM holding a valid cert for a different domain. This commit also checks the previously ignoredwolfSSL_CTX_load_verify_buffer()result, so a CA PEM that fails to parse fails the connect with a clear log instead of silently leaving an empty trust store.wolfSSL_get_error()code is recorded and classified; only transport/protocol failures take the version-intolerance retry. The code is reset per connect so a stale value can't misclassify a later TCP/DNS failure.setAllowInsecureFallback(true)retries a verification-class failure once without verification, logging a warning and recording the downgrade inlastConnectWasInsecure(). Rationale: readers syncing against self-hosted servers routinely meet self-signed/lapsed certificates, and without a scoped fallback users end up on permanentsetInsecure(). Off by default — security-critical callers (OTA) simply never enable it and keep failing closed. Only verification-class errors (from commit 2) ever trigger it.wolfSSL_Arduino_Serial_Printdefinition — Arduino-wolfSSL'slogging.creferences this symbol, but its definition lives in sketch glue that a PlatformIOlib_depsbuild never compiles, so the documented SecureNet setup fails at link time. A weak default (routing toSerial) makes consumers link out of the box; an application definition overrides it.handshakeMinFree()/handshakeMinLargest(). On PSRAM-less boards the handshake is typically the heap high-water mark of the whole networking path, andESP.getMinFreeHeap()can't isolate it. This is how we sized a single pinned root vs. a CA bundle on an ESP32-C3.Testing
freeinknamespace, fallback default off) was verified by compilation.-DFREEINK_NET_WOLFSSL=1) and flag-off builds both compile clean with-Wall -Wextra.