Skip to content

fix: real peer verification in SecureClient (hostname check + fail-closed CA load) - #65

Open
sfoulad wants to merge 1 commit into
Free-Ink:mainfrom
sfoulad:fix/secureclient-peer-verification
Open

fix: real peer verification in SecureClient (hostname check + fail-closed CA load)#65
sfoulad wants to merge 1 commit into
Free-Ink:mainfrom
sfoulad:fix/secureclient-peer-verification

Conversation

@sfoulad

@sfoulad sfoulad commented Aug 29, 2026

Copy link
Copy Markdown
Contributor

Summary

SecureClient::connect() loads the caller's CA bundle via setCACert() but never calls wolfSSL_check_domain_name(), so hostname verification is silently skipped: any certificate signed by any anchor in the bundle is accepted for any host. A network attacker holding a valid certificate for their own domain (issued by the same public CA) passes verification when impersonating the target. This defeats the point of setCACert for public-internet endpoints.

Two further hardening items in the same small surface:

  1. wolfSSL_CTX_load_verify_buffer() failure was not treated as fatal — a corrupt/truncated bundle degraded to an unverified connection. It now fails the connect (fail closed).
  2. SecureHttpClient treated every response as having a body; per RFC 9110, 1xx/204/304 have none. A kept-alive 304 Not Modified (conditional GET) previously stalled the read loop until timeout.

Changes

  • SecureClient.cpp: arm wolfSSL_check_domain_name(ssl, host) for every non-insecure connection; fail closed on CA-load errors. (+24/−2)
  • SecureHttpClient.h: no-body status handling for 1xx/204/304. (+11)

setInsecure() behaviour is unchanged — callers that explicitly opt out still can; this only makes the verified path actually verify.

Verification

  • Builds clean against a CrossPoint-derived firmware on ESP32-C3 (wolfSSL path, X25519 + SP math config).
  • Host-level chain fixtures: correct chain + correct hostname verifies; wrong CA fails; hostname mismatch fails (including a valid leaf for a different host).
  • The RFC 9110 change is exercised by a conditional-GET 304 over a kept-alive connection.

Happy to split the RFC 9110 hunk into its own PR if you prefer reviewing the security change in isolation.

SecureClient:
- wolfSSL_check_domain_name() is armed for every non-insecure connection.
  SNI names the host but never bound the peer certificate to it, so a
  setCACert() connection still accepted any certificate signed by a
  trusted CA for any hostname.
- wolfSSL_CTX_load_verify_buffer() failure now fails the connect
  outright instead of proceeding to a handshake against an empty trust
  store whose verify error is indistinguishable from a bad peer.

SecureHttpClient:
- 1xx/204/304 responses carry no body (RFC 9110 s6.4.1); read one and a
  kept-alive 304 with no framing headers stalls in readUntilClose()
  until the timeout. Required for conditional GETs (If-None-Match)
  against the GitHub API.
@sfoulad

sfoulad commented Aug 30, 2026

Copy link
Copy Markdown
Contributor Author

Status update: this makes SecureClient actually verify the peer — hostname check plus fail-closed CA loading — instead of accepting any CA-signed certificate for any host; it's the SDK half of crosspoint-reader#3298. This repo runs no CI, so the evidence is downstream: it builds clean into an ESP32-C3 firmware and the chain/hostname negative tests pass at the HTTP layer against captured live GitHub chains. Downstream we're pinned to a fork commit for this one change; a review and merge lets our submodule return to an official upstream ref. Happy to split the hostname check and CA-load changes if that's easier to review.

sfoulad added a commit to sfoulad/midad-by-foulad that referenced this pull request Aug 30, 2026
The RC hardened CrossPoint PR #3298's OTA transport locally instead of
upstream. That hardening now exists upstream on fix/3298-verified-entrypoint
(850e0657, on top of #3298's head 108ea716), so the RC takes the reviewed
form rather than carrying a Midad variation of a generic transport rule.

- src/network/HttpVerifiedFetch.h: added byte-identical to 850e0657.
  urlIsHttps(), verifiedFetchRefusal() and kVerifiedFetchNeedsAnchors now
  have exactly one definition in the tree, upstream's.
- HttpDownloader::fetchUrlVerified(DataCallback) is byte-identical to
  850e0657's: the bespoke verifiedFetchAllowed() helper is gone and the
  precondition runs outside any #if, so the https-only half applies on
  non-wolfSSL builds too.
- The Midad-only ConditionalGet& overload survives and routes through the
  same verifiedFetchRefusal(), not a second copy of the rules.
- lib/UrlOrigin/UrlOrigin.h keeps urlOrigin() (the cross-origin
  Authorization latch) and drops its duplicate urlIsHttps().
- test/http_verified_fetch/ added byte-identical to 850e0657 (11 cases per
  TLS backend) and registered. test/http_url_origin keeps the extra
  malformed-scheme shapes upstream's suite does not enumerate.

Deliberately NOT adopted from 850e0657: its corrected setCACert comment
saying hostname checking is uncovered until Free-Ink/freeink-sdk#65 lands.
That is true of upstream's SDK pin (f831c1e4) but false here -- the RC pins
freeink-sdk 6d33e9fd, whose SecureClient calls wolfSSL_check_domain_name()
on every non-insecure handshake. The gitlink is untouched.

Host tests: 358/358 pass (336 baseline + 22 new).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant