tls: make the handshake bound a TOTAL, not a per-read timeout
The first version of this bound did not work, and its own tests could not see that. It set mbedtls_ssl_conf_read_timeout to the remaining budget on each iteration of the handshake loop and called that a whole-handshake deadline. It is not one: mbedtls_ssl_handshake does not return between handshake steps, and mbedtls_ssl_fetch_input passes the full configured read_timeout to EVERY partial read. So the loop-level re-budget ran once per handshake instead of once per read.
The consequence is that a peer which DRIPS defeats it entirely, where a peer that goes silent does not. Measured: one byte every 600ms made a 1000ms bound take 13.06s to fire, and against a drip that never stopped it had not fired after 120s. That is the same whole-process freeze the bound exists to prevent, reachable by trickling instead of stalling.
The deadline now lives in the BIO callbacks, which are the only place that sees every individual read, and it clamps each wait to the time remaining. mbedtls_ssl_set_timer_cb would also have worked, but mbedTLS cancels that timer at several points during a handshake; a clamp on the read cannot be cancelled. The per-read read_timeout is kept underneath as a backstop, since the two bound different things.
The handshake WRITE goes through the same clamp. A peer that accepts and never reads fills the window and the client flight blocks in send(). Rare with a flight of a few hundred bytes, but "rare" is not a bound.
Both the old rig and the old tests were the problem: every stall case used a peer that sends NOTHING, and no peer of that shape can distinguish a per-read bound from a total one. The drip peer that can is in sigil-http's test/integration/, where a live TLS peer is available.
Also removes a test that claimed to guard the "connected" status and in fact repeated the failing call above it verbatim, so *status_out = "connected" could have been deleted with all tests still green. "connected" is now asserted where a live peer exists, in sigil-http's integration controls.
Reported by an adversarial review of my own diff, verified by measurement.
RELEASE_NOTES_v0.16.5.md | 14 ++++++++++++--
native/tls.c | 157 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------------------
src/sigil/tls.sgl | 10 +++++++++-
test/test-tls.sgl | 17 +++++++++++++----
4 files changed, 168 insertions(+), 30 deletions(-)RELEASE_NOTES_v0.16.5.mdmodified
## Changes- `tls-connect hostname port [connect-timeout-ms [handshake-timeout-ms]]` bounds the handshake. The deadline covers the whole handshake rather than each individual read, so a peer sending one byte at a time cannot extend it. bounds the handshake. The deadline is a TOTAL over the handshake, enforced on each individual read as a clamp on how long that read may wait. That distinction carries the whole value of this release, so it is worth stating why. A bound applied only per read is defeated by a peer that DRIPS rather than one that goes silent: mbedTLS does not return between handshake steps and restarts its read timeout on every partial read, so each dribbled byte resets the clock. Measured against an implementation that got this wrong, a peer sending one byte every 600 ms made a 1000 ms bound take 13 seconds to fire, and it never fired at all while the drip continued. A per-read timeout is still kept underneath as a backstop; the two bound different things and neither replaces the other.- `tls-upgrade socket hostname [handshake-timeout-ms]` gains the same bound. An upgraded socket is already connected, so all of its handshake sits past the connect phase and its unbounded window was strictly wider thannative/tls.cmodified
* mode it must hand control back instead of spinning on a socket that * will not accept bytes. */ int nonblocking; /* Absolute monotonic deadline (ms) for a handshake in progress, or 0 for * none. Read by the deadline-aware BIO callbacks, which are the only * place that sees every individual handshake read. Always back to 0 * before an established connection is handed to a caller. */ long handshake_deadline_ms;} TlsConnectionData;/* Symbol used as type tag for TLS connection foreign objects */#define SIGIL_TLS_HS_TIMEOUT 1#define SIGIL_TLS_HS_FAILED 2#ifndef _WIN32/* * Deadline-aware BIO callbacks, used ONLY while a bounded handshake is in * progress. p_bio is the TlsConnectionData, so the deadline is reachable * from inside the callback. * * WHY THE DEADLINE HAS TO LIVE HERE rather than in the caller's loop, which * is where I first put it and where it did not work: * * mbedtls_ssl_handshake does not return between handshake steps, and * mbedtls_ssl_fetch_input loops `while (in_left < nb_want)` passing the * FULL configured read_timeout to every partial read. A deadline enforced * in the caller's loop is therefore consulted once per handshake, not once * per read, and a peer that DRIPS bytes resets a fresh full-length timeout * on each one. Measured: against a peer sending one byte every 600 ms, a * 1000 ms bound took 13.06 s to fire, and against a peer that never * stopped dripping it had not fired at all after 120 s. * * mbedtls_ssl_check_timer would have caught it, but only if a timer * callback is installed, and mbedTLS cancels that timer at several points * in the handshake. Clamping the timeout on the read itself needs no timer * and cannot be cancelled: this is the one place that sees every read. */static int sigil_tls_bio_remaining(TlsConnectionData *conn, long *out){ if (conn->handshake_deadline_ms <= 0) { *out = 0; /* no deadline in force */ return 0; } *out = conn->handshake_deadline_ms - sigil_tls_now_ms(); return 1;}static int sigil_tls_bio_recv_timeout(void *ctx, unsigned char *buf, size_t len, uint32_t timeout){ TlsConnectionData *conn = (TlsConnectionData *)ctx; long remaining; if (sigil_tls_bio_remaining(conn, &remaining)) { if (remaining <= 0) { return MBEDTLS_ERR_SSL_TIMEOUT; } /* Never wait past the whole-handshake deadline, whatever mbedTLS * asks for. This is the clamp that makes the bound a total. */ if (timeout == 0 || (long)timeout > remaining) { timeout = (uint32_t)remaining; } } return mbedtls_net_recv_timeout(&conn->server_fd, buf, len, timeout);}static int sigil_tls_bio_recv(void *ctx, unsigned char *buf, size_t len){ /* mbedTLS prefers f_recv_timeout when set, so this is a fallback. Route * it through the same clamp rather than letting it block unbounded. */ return sigil_tls_bio_recv_timeout(ctx, buf, len, 0);}static int sigil_tls_bio_send(void *ctx, const unsigned char *buf, size_t len){ TlsConnectionData *conn = (TlsConnectionData *)ctx; long remaining; /* The handshake WRITE can block too: a peer that accepts and never reads * fills the window, and the client flight then blocks in send(). Rare * with a flight of a few hundred bytes, but "rare" is not a bound. */ if (sigil_tls_bio_remaining(conn, &remaining)) { fd_set wset; struct timeval tv; int sel; if (remaining <= 0) { return MBEDTLS_ERR_SSL_TIMEOUT; } FD_ZERO(&wset); FD_SET(conn->server_fd.fd, &wset); tv.tv_sec = remaining / 1000L; tv.tv_usec = (remaining % 1000L) * 1000L; sel = select(conn->server_fd.fd + 1, NULL, &wset, NULL, &tv); if (sel == 0) { return MBEDTLS_ERR_SSL_TIMEOUT; } if (sel < 0) { if (errno == EINTR) { return MBEDTLS_ERR_SSL_WANT_WRITE; } return MBEDTLS_ERR_NET_SEND_FAILED; } } return mbedtls_net_send(&conn->server_fd, buf, len);}#endif /* !_WIN32 *//* * Run the TLS handshake on an already-connected conn->server_fd. * * original blocking handshake: blocking mbedtls_net_recv as the BIO and a * loop that only retries on WANT_READ/WANT_WRITE. * * With a positive handshake_timeout_ms, handshake reads are driven through * mbedtls_net_recv_timeout, which selects on the socket and returns * MBEDTLS_ERR_SSL_TIMEOUT when nothing arrives. This is the segment that * connect-timeout-ms does NOT cover: once a peer has ACCEPTED the TCP * connection, the connect phase is over and the handshake read that follows * was previously unbounded. A peer that accepts and then never sends a * ServerHello wedged a production monitoring service for 55 days. * With a positive handshake_timeout_ms, the handshake's reads AND writes go * through deadline-aware BIO callbacks that clamp every wait to the time * remaining. This is the segment that connect-timeout-ms does NOT cover: * once a peer has ACCEPTED the TCP connection the connect phase is over, and * the handshake that follows was previously unbounded. A peer that accepts * and then never sends a ServerHello wedged a production monitoring service * for 55 days. * * The deadline is on the WHOLE handshake, not per read: each iteration * re-budgets the remaining time, so a peer dripping one byte just under the * interval cannot extend the wait indefinitely. * The deadline is a TOTAL over the handshake. It is enforced inside the BIO * rather than in the loop below, for the reason documented on those * callbacks: a loop-level deadline is consulted once per handshake and a * dripping peer walks straight through it. * * The read timeout is unwound before returning, so an established * connection's subsequent tls-read calls behave exactly as before. Only the * Everything is unwound before returning, so an established connection's * subsequent tls-read and tls-write behave exactly as before. Only the * handshake is bounded here. */static int sigil_tls_run_handshake(TlsConnectionData *conn, long handshake_timeout_ms)#ifndef _WIN32 if (handshake_timeout_ms > 0) { int outcome = SIGIL_TLS_HS_OK; long deadline = sigil_tls_now_ms() + handshake_timeout_ms; mbedtls_ssl_set_bio(&conn->ssl, &conn->server_fd, mbedtls_net_send, mbedtls_net_recv, mbedtls_net_recv_timeout); conn->handshake_deadline_ms = sigil_tls_now_ms() + handshake_timeout_ms; mbedtls_ssl_conf_read_timeout(&conn->conf, (uint32_t)handshake_timeout_ms); mbedtls_ssl_set_bio(&conn->ssl, conn, sigil_tls_bio_send, sigil_tls_bio_recv, sigil_tls_bio_recv_timeout); for (;;) { long remaining = deadline - sigil_tls_now_ms(); if (remaining <= 0) { outcome = SIGIL_TLS_HS_TIMEOUT; break; } mbedtls_ssl_conf_read_timeout(&conn->conf, (uint32_t)remaining); ret = mbedtls_ssl_handshake(&conn->ssl); if (ret == 0) { break; outcome = SIGIL_TLS_HS_FAILED; break; } /* WANT_* on a blocking BIO means the deadline is the only thing * that can end this; check it so the retry cannot spin. */ if (conn->handshake_deadline_ms - sigil_tls_now_ms() <= 0) { outcome = SIGIL_TLS_HS_TIMEOUT; break; } } /* Restore unbounded reads + the plain BIO for the session. */ conn->handshake_deadline_ms = 0; mbedtls_ssl_conf_read_timeout(&conn->conf, 0); mbedtls_ssl_set_bio(&conn->ssl, &conn->server_fd, mbedtls_net_send, mbedtls_net_recv, NULL); } if (ret < 0) { /* Report bytes already COMMITTED rather than collapsing to #f: * they are on the wire, and a caller that resent them would * corrupt the stream. With nothing committed, #f as before. */ if (total_written > 0) { break; } return SIGIL_FALSE; /* Error */ } * mbedtls_net_free keeps mbedTLS's own bookkeeping out of a path where * server_fd may never have been handed to it. */ if (conn->server_fd.fd >= 0) {#ifdef _WIN32 closesocket(conn->server_fd.fd);#else close(conn->server_fd.fd);#endif } conn->server_fd.fd = -1; /* Prevent double-close in mbedtls_net_free */ mbedtls_net_free(&conn->server_fd);src/sigil/tls.sglmodified
;;; sends a ServerHello previously blocked the handshake read forever. ;;; Under Sigil's cooperative scheduler that freezes the whole process, ;;; so this is the difference between a failed request and a dead ;;; service. The bound covers the whole handshake, not each read. ;;; service. ;;; ;;; The bound is a TOTAL over the handshake, not a per-read one. That ;;; distinction is the whole value: a per-read bound is defeated by a ;;; peer that DRIPS rather than one that goes silent, because mbedTLS ;;; restarts its read timeout on every partial read. Measured against an ;;; earlier build, one byte every 600 ms made a 1000 ms per-read bound ;;; take 13 s to fire, and never fire at all while the drip continued. ;;; ;;; Omitted or <= 0 keeps the original blocking handshake. ;;; ;;; ```schemetest/test-tls.sglmodified
(assert-equal "tcp-connect-failed" (car r)) (assert-false (cdr r)))) (test "tls-connect/status reports connected only with a live peer" ;; Guards the other direction: "connected" is not produced by a failure. (let ((r (tls-connect/status "127.0.0.1" 1 1000 1000))) (assert-false (equal? "connected" (car r))))) ;; NOT TESTED HERE: the "connected" status, and the bound holding against a ;; peer that DRIPS rather than going silent. Both need a live TLS peer, ;; which this suite cannot stand up in-process, and both are covered by ;; sigil-http's test/integration/run-timeout-tests.sh: ;; "tls-connect/status reports connected for a live peer" and "the ;; handshake bound fires against a DRIPPING peer, not just a silent one". ;; ;; Said out loud because an earlier version of this file had a test here ;; claiming to guard the "connected" direction which in fact repeated the ;; failing call above verbatim. It asserted that a REFUSED connection is ;; not reported as connected, which is not the same claim at all, and ;; `*status_out = "connected"` could have been deleted from the C with all ;; 20 tests still green. (test "handshake timeout bounds an accept-and-stall peer" (let* ((server (tcp-listen 0))