Skip to content

feat(port): HTTPS over the lwIP adapter with mbedTLS 3.6 (#447 Phase 4) - #457

Merged
gg582 merged 1 commit into
devfrom
feat/stm32-phase4-tls
Oct 27, 2026
Merged

gg582 merged 1 commit into
devfrom
feat/stm32-phase4-tls

Conversation

@gg582

@gg582 gg582 commented Oct 27, 2026

Copy link
Copy Markdown
Member

Phase 4 of the bare-metal STM32 epic (#447): HTTPS on the lwIP adapter.

Why not lwIP's altcp_tls

lwIP's altcp_tls_mbedtls includes mbedTLS 2.x-only headers (mbedtls/certs.h, mbedtls/ssl_internal.h). That holds in 2.2.1 and on current master, so it cannot use the vendored mbedTLS 3.6 LTS (lib/mbedtls). The adapter therefore drives mbedTLS 3.6 itself over the pcb, using mbedtls_ssl_set_bio.

API (include/cwist/sys/port/lwip.h)

cwist_lwip_tls *tls = cwist_lwip_tls_create(cert, sizeof cert, key, sizeof key); /* PEM: NUL counted */
cwist_lwip_config cfg;
cwist_lwip_config_init(&cfg);
cfg.port = 443;
cfg.tls = tls;                       /* NULL (default) = plaintext */
cwist_lwip_server *srv = cwist_lwip_listen(app, &cfg);
...
cwist_lwip_close(srv);
cwist_lwip_tls_destroy(tls);
  • Build: TLS code compiles only with -DCWIST_LWIP_TLS and mbedTLS 3.6 linked: a TLS 1.2 server, X.509, PK parse, and CTR_DRBG.
  • Without CWIST_LWIP_TLS: cwist_lwip_tls_create() returns NULL, and cwist_lwip_listen() with cfg.tls set fails instead of serving plaintext.
  • Entropy: the DRBG is seeded only from cwist_port_entropy(). A failing hook makes create fail. A failing reseed later fails the handshake, so nothing runs on a guessable seed.
  • Validation in create: the certificate and key are parsed and checked to be a pair with mbedtls_pk_check_pair.

How it works

  • Receive: ciphertext from tcp_recv goes into a bounded per-connection queue (one full record plus slack). When the queue is full it is refused back to lwIP, so the TCP window throttles the peer. A pump runs the handshake, then decrypts into the same rx buffer and framing path the plaintext server uses. All request limits, 400/413/431/501 handling, keep-alive and pipelining behave the same.
  • Send: responses go through mbedtls_ssl_write. Its records are copied into lwIP (TCP_WRITE_FLAG_COPY), so the serialized response is freed as soon as mbedTLS has taken all of it. WANT_WRITE is retried from tcp_sent and tcp_poll.
  • Pipelining: the next request is picked up from tcp_sent, as on the plaintext path, never recursively. That keeps stack use flat however many requests are queued.
  • 100 Continue: goes through the record layer.
  • Closing: Connection: close sends close_notify before the TCP close. A failed handshake (not TLS, no shared suite, alert) aborts the connection.
  • Unchanged: the plaintext path behaves exactly as before. Every TLS addition sits behind #ifdef CWIST_LWIP_TLS.

Tests

tests/lwip/cwist_test_mbedtls_config.h is a small TLS 1.2 server profile:

  • ECDHE-ECDSA on P-256, AES-128-GCM, SHA-256
  • 16 KB in / 4 KB out record buffers
  • no filesystem, sockets, or platform entropy

tests/lwip/tls_client.{c,h} is an mbedTLS client over a loopback pcb. It verifies the server against the test CA for localhost. tests/lwip/test_tls_cert.h holds test-only self-signed P-256 credentials.

test_lwip_tls (in make test, host):

  • context creation failures: bad PEM, mismatched key, failing entropy hook
  • verified handshake with the expected cipher suite
  • keep-alive, and two pipelined requests in one session
  • Expect: 100-continue with the body split across records
  • a 40 KB response, many 4 KB records through a 2-segment window, byte-exact
  • a 413 over TLS that closes the connection
  • Connection: close → close_notify → FIN
  • a plaintext client on the TLS port: nothing is served in the clear

I confirmed the assertions are live by changing one expectation and watching it fail. The test is also clean under ASan/UBSan/LSan.

Under CWIST_TLS=mbedtls, libcwist already links the vendored mbedTLS, so the test reuses that copy (default config) instead of linking a second one.

hello-tls joins make baremetal-smoke and the size gate: the same stack on Cortex-M7 under qemu-arm. It passes with gcc 13.2 / newlib 4.4 / qemu 8.2 (the CI toolchain) and with gcc 14.2 / qemu 10.

hello-tls (Cortex-M7, -Os) measured budget
flash (CWIST + adapter + lwIP + mbedTLS + test client) 137,052 151,552
static RAM (mostly the test lwipopts.h pools and test buffers) 75,520 83,968
heap high-water (server and client sessions together) 106,640 118,784
stack high-water 8,344 9,216

After the handshake, server and client sessions together hold 49,980 bytes of heap. Both use the same record buffers (16 KB + 4 KB each), which puts a server-side TLS connection at about 25 KB. MBEDTLS_SSL_IN_CONTENT_LEN stays at 16 KB because clients may send full-size records: browsers do not negotiate max_fragment_length.

Local verification

  • Tests: test_lwip_tls, test_lwip_adapter and test_baremetal_port pass.
  • Bare-metal smoke and size gate: make baremetal-smoke and make baremetal-size-gate pass, with both toolchains.
  • Adapter warnings: host and Cortex-M builds, each with and without CWIST_LWIP_TLS, under -Wall -Wextra: no warnings.
  • Repo gates:
    • check_api_baseline: 2 functions and 1 type added
    • check_api_test_coverage, check_test_wiring, check_env_knobs, check_inline_exports, malloc_lint.sh: OK
    • cppcheck 2.13: no findings
  • Full native make -k test: still running locally at the time of opening; result will follow in a comment.

Not in this PR

  • TLS 1.3: mbedTLS 3.6 requires PSA crypto and an external RNG for it. Follow-up.
  • Hardware crypto acceleration (CRYP/HASH/PKA): this is a firmware-side MBEDTLS_*_ALT choice. To be documented in Phase 6.
  • Session resumption and tickets.
  • Real hardware.

Refs #447

lwIP's altcp_tls_mbedtls only supports mbedTLS 2.x (in 2.2.1 and on
master), so the adapter drives mbedTLS 3.6 itself.

- cwist_lwip_tls_create()/destroy() build a server context (certificate
  chain, key, CTR_DRBG, ssl config); cwist_lwip_config.tls turns HTTPS on
  for a listener. TLS code compiles with -DCWIST_LWIP_TLS; without it,
  create returns NULL and a listener asked for TLS fails instead of
  serving plaintext.
- The DRBG is seeded only from cwist_port_entropy(): a failing hook makes
  create fail, and a failing reseed fails the handshake.
- Per connection: an mbedtls_ssl_context with BIOs over the pcb. Received
  ciphertext queues (bounded, refused back to lwIP when full) and is
  decrypted into the same rx/framing path as plaintext; responses go
  through mbedtls_ssl_write and are copied into lwIP, so tx is freed as
  soon as mbedTLS has taken it. The next pipelined request is picked up
  from tcp_sent, as on the plaintext path. 100 Continue goes through the
  record layer. Connection: close sends close_notify. A failed handshake
  aborts the connection.
- tests/lwip/cwist_test_mbedtls_config.h: TLS 1.2 server profile
  (ECDHE-ECDSA P-256, AES-128-GCM, SHA-256; 16 KB in / 4 KB out records).
  tests/lwip/tls_client.{c,h}: mbedTLS client over a loopback pcb.
  tests/lwip/test_tls_cert.h: test-only self-signed P-256 credentials.
- tests/test_lwip_tls.c (make test): verified handshake, keep-alive and
  pipelining in one session, 100-continue with a split body, 40 KB
  response, 413, close_notify, plaintext client on the TLS port, and
  context creation failures. Under CWIST_TLS=mbedtls it links the
  vendored mbedTLS libcwist already uses.
- tests/baremetal/hello_tls.c joins baremetal-smoke and the size gate:
  Cortex-M7 flash 137 KB, heap high-water 106.6 KB with both sessions,
  ~50 KB held by server + client sessions after the handshake.
@gg582

gg582 commented Oct 27, 2026

Copy link
Copy Markdown
Member Author

Full native make -k test on 153159db0 (default BoringSSL build): all targets pass, exit 0, including test_lwip_tls and test_lwip_adapter.

@gg582
gg582 merged commit eb537f9 into dev Oct 27, 2026
40 checks passed
gg582 added a commit that referenced this pull request Oct 27, 2026
…458)

* ci(baremetal): Cortex-M7 smoke and size budget gate (#447 Phase 5) (#455)

- .github/workflows/baremetal.yml (ubuntu-24.04: gcc-arm-none-eabi 13.2,
  newlib 4.4, qemu-user 8.2) runs `make baremetal-size-gate` on every PR
  and push: the three smoke firmware (in-memory dispatch with and without
  an RTC, and the lwIP adapter over loopback) under qemu-arm, then the
  budget check. ELFs, maps and logs are uploaded as an artifact.
- baremetal-smoke keeps each run's output in .baremetal-build/<fw>.log
  and still fails on the first failing firmware.
- scripts/ci/baremetal_size_gate.py measures flash (code, rodata, exidx,
  init arrays, .data load image), static RAM (.data + .bss, not the
  linker heap region) and the heap/stack high-water marks the firmware
  report, against scripts/ci/baremetal-budget.txt. A budgeted value the
  run did not report fails rather than passing.
- Budgets sit about 10% above values measured with both gcc 13.2 (the CI
  toolchain) and 14.2, which agree within 0.6%.

(cherry picked from commit 35ed9a0)

* feat(port): HTTPS over the lwIP adapter with mbedTLS 3.6 (#447 Phase 4) (#457)

lwIP's altcp_tls_mbedtls only supports mbedTLS 2.x (in 2.2.1 and on
master), so the adapter drives mbedTLS 3.6 itself.

- cwist_lwip_tls_create()/destroy() build a server context (certificate
  chain, key, CTR_DRBG, ssl config); cwist_lwip_config.tls turns HTTPS on
  for a listener. TLS code compiles with -DCWIST_LWIP_TLS; without it,
  create returns NULL and a listener asked for TLS fails instead of
  serving plaintext.
- The DRBG is seeded only from cwist_port_entropy(): a failing hook makes
  create fail, and a failing reseed fails the handshake.
- Per connection: an mbedtls_ssl_context with BIOs over the pcb. Received
  ciphertext queues (bounded, refused back to lwIP when full) and is
  decrypted into the same rx/framing path as plaintext; responses go
  through mbedtls_ssl_write and are copied into lwIP, so tx is freed as
  soon as mbedTLS has taken it. The next pipelined request is picked up
  from tcp_sent, as on the plaintext path. 100 Continue goes through the
  record layer. Connection: close sends close_notify. A failed handshake
  aborts the connection.
- tests/lwip/cwist_test_mbedtls_config.h: TLS 1.2 server profile
  (ECDHE-ECDSA P-256, AES-128-GCM, SHA-256; 16 KB in / 4 KB out records).
  tests/lwip/tls_client.{c,h}: mbedTLS client over a loopback pcb.
  tests/lwip/test_tls_cert.h: test-only self-signed P-256 credentials.
- tests/test_lwip_tls.c (make test): verified handshake, keep-alive and
  pipelining in one session, 100-continue with a split body, 40 KB
  response, 413, close_notify, plaintext client on the TLS port, and
  context creation failures. Under CWIST_TLS=mbedtls it links the
  vendored mbedTLS libcwist already uses.
- tests/baremetal/hello_tls.c joins baremetal-smoke and the size gate:
  Cortex-M7 flash 137 KB, heap high-water 106.6 KB with both sessions,
  ~50 KB held by server + client sessions after the handshake.

(cherry picked from commit eb537f9)
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