Skip to content

Commit 17afbc5

Browse files
committed
Merge master (handshake deadline, #25) into the extra CA branch
# Conflicts: # .github/workflows/ci.yml # CHANGELOG.md
2 parents bd76c9e + 9d4315e commit 17afbc5

7 files changed

Lines changed: 420 additions & 24 deletions

File tree

‎.github/workflows/ci.yml‎

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -288,10 +288,26 @@ jobs:
288288
# "Platforms"), so the program is given a bundle: Git for Windows's, copied
289289
# beside it, because openkal resolves a relative name against the working
290290
# directory and names no absolute path.
291+
#
292+
# Git is asked where its bundle is, and the install searched after that,
293+
# rather than the directory named: the runner image that brought Git for
294+
# Windows 2.56 has no `mingw64/etc/ssl/certs/ca-bundle.crt`, and the job
295+
# failed there before the program ran.
291296
- name: Run above openkal
292297
run: |
293298
set -o pipefail
294-
cp "/c/Program Files/Git/mingw64/etc/ssl/certs/ca-bundle.crt" ca-bundle.crt
299+
git --version
300+
ca=$(git config --system --get http.sslcainfo || true)
301+
if [ -n "$ca" ]; then ca=$(cygpath -u "$ca"); fi
302+
if [ ! -f "$ca" ]; then
303+
ca=$(find "/c/Program Files/Git" -path '*/ssl/certs/ca-bundle.crt' -print -quit)
304+
fi
305+
if [ ! -f "$ca" ]; then
306+
echo "::error::no CA bundle in the Git for Windows install"
307+
exit 1
308+
fi
309+
echo "CA bundle: $ca"
310+
cp "$ca" ca-bundle.crt
295311
SSL_CERT_FILE=ca-bundle.crt ./smoke.exe 2>&1 | tee run.log
296312
grep -q '^cancellation: ok' run.log
297313
@@ -352,7 +368,7 @@ jobs:
352368
- name: The hermetic tests
353369
run: |
354370
set -o pipefail
355-
for t in test_framing test_tls_verify test_pool test_proxy test_cancel test_extra_ca; do
371+
for t in test_framing test_tls_verify test_pool test_proxy test_cancel test_handshake_timeout test_extra_ca; do
356372
mcpp test "$t" 2>&1 | tee "$t.log"
357373
grep -q "^$t \.\.\. ok" "$t.log"
358374
done

‎CHANGELOG.md‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,29 @@
22

33
## Unreleased
44

5+
### TLS handshake time limit
6+
7+
The TLS handshake is bounded by `connectTimeoutMs`. It had no limit: after the
8+
TCP connection was up, a server (or an https:// proxy) that accepted it and never
9+
sent its ServerHello held `send`, `send_stream` and `download_to_file` for good
10+
unless the caller passed a stop token.
11+
12+
* The handshake gives up once `connectTimeoutMs` has passed since it began, and
13+
`statusText` is `TLS handshake timed out`. This covers the handshake with the
14+
target, with an https:// proxy, and with the target inside an https:// proxy's
15+
tunnel. The limit is on the handshake as a whole, so a peer that sends a byte
16+
at a time does not hold it open.
17+
* Behaviour change: a handshake slower than `connectTimeoutMs` (10 s by default)
18+
now fails where it used to complete. Each step of setting up a connection (the
19+
TCP connect, a proxy's reply, a handshake) has `connectTimeoutMs` to itself, so
20+
the whole can take several times that.
21+
* `TlsSocket::connect` bounds the handshake with the `timeoutMs` it already
22+
took for the TCP connect, so a caller of it directly sees the change too.
23+
* `TlsSocket::connect_over` takes a trailing `handshakeTimeoutMs`, `-1` (the
24+
default) for no limit; a call by name compiles unchanged, a pointer to it needs
25+
the new parameter in its type. `Socket` gains `set_deadline`, `deadline_hit`
26+
and `wait_before_recv`.
27+
528
### Extra CA certificates
629

730
`HttpClientConfig::extraCaFile` is a path to a PEM file whose certificates are

‎README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ includes in that file, or use libc++ 23 or libstdc++.
7979

8080
| field | default | what it decides |
8181
| --- | --- | --- |
82-
| `connectTimeoutMs` | 10000 | TCP connect |
82+
| `connectTimeoutMs` | 10000 | setting up the connection: the TCP connect, each wait for a proxy's reply, and the TLS handshake, with the proxy and with the target (the handshake as a whole, not each read in it) |
8383
| `readTimeoutMs` | 60000 | any single read, and the total wait on a blocked write |
8484
| `verifySsl` | true | verify the server certificate against the CA bundle (`SSL_CERT_FILE`, else the Windows `ROOT` certificate store in Windows Sockets builds, else a system location); the connection fails if the certificate is not trusted, has expired or is for another host, or if no bundle is found |
8585
| `keepAlive` | true | reuse connections between requests |

‎src/http.cppm‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1061,10 +1061,10 @@ private:
10611061
}
10621062
if (tunnel.proxyTls) {
10631063
return sock.connect_over(std::move(tunnel.proxyTls), parsed.host.c_str(),
1064-
config_.verifySsl);
1064+
config_.verifySsl, config_.connectTimeoutMs);
10651065
}
10661066
return sock.connect_over(std::move(tunnel.socket), parsed.host.c_str(),
1067-
config_.verifySsl);
1067+
config_.verifySsl, config_.connectTimeoutMs);
10681068
}
10691069
return sock.connect(parsed.host.c_str(), parsed.port,
10701070
config_.connectTimeoutMs, config_.verifySsl);

‎src/socket.cppm‎

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,8 @@ public:
5252

5353
// Move constructor
5454
Socket(Socket&& other) noexcept
55-
: fd_(other.fd_), stop_(std::move(other.stop_)) {
55+
: fd_(other.fd_), stop_(std::move(other.stop_)), deadline_(other.deadline_)
56+
, deadline_hit_(other.deadline_hit_) {
5657
other.fd_ = INVALID_SOCKET_FD;
5758
}
5859

@@ -62,6 +63,8 @@ public:
6263
close();
6364
fd_ = other.fd_;
6465
stop_ = std::move(other.stop_);
66+
deadline_ = other.deadline_;
67+
deadline_hit_ = other.deadline_hit_;
6568
other.fd_ = INVALID_SOCKET_FD;
6669
}
6770
return *this;
@@ -77,6 +80,42 @@ public:
7780

7881
[[nodiscard]] bool stop_possible() const { return stop_.stop_possible(); }
7982

83+
// Bounds the TLS handshake: until it is cleared, `wait_before_recv` gives up
84+
// once this time has passed. Cleared once the handshake is over.
85+
void set_deadline(std::optional<std::chrono::steady_clock::time_point> deadline) {
86+
deadline_ = deadline;
87+
deadline_hit_ = false;
88+
}
89+
90+
// True once `wait_before_recv` gave up because of the deadline.
91+
[[nodiscard]] bool deadline_hit() const { return deadline_hit_; }
92+
93+
// What `bio_recv` calls ahead of a `recv` that would otherwise block. True
94+
// when a `recv` now will not block. With neither a token nor a deadline it
95+
// answers true without waiting, so the plain `recv` is as it was.
96+
//
97+
// A wait that ends before the deadline is waited again for what is left: a
98+
// signal ends `poll` with EINTR even under SA_RESTART, which the blocking
99+
// `recv` this wait stands in front of would have restarted. The deadline is
100+
// hit only when the clock says so.
101+
bool wait_before_recv() {
102+
if (!stop_.stop_possible() && !deadline_) return true;
103+
for (;;) {
104+
int wait = -1;
105+
if (deadline_) {
106+
const auto left = std::chrono::ceil<std::chrono::milliseconds>(
107+
*deadline_ - std::chrono::steady_clock::now()).count();
108+
wait = left > 0 ? static_cast<int>(left) : 0;
109+
}
110+
if (wait_readable(wait)) return true;
111+
if (stop_.stop_requested() || !deadline_) return false;
112+
if (std::chrono::steady_clock::now() >= *deadline_) {
113+
deadline_hit_ = true;
114+
return false;
115+
}
116+
}
117+
}
118+
80119
bool connect(const char* host, int port, int timeoutMs) {
81120
// Close existing connection if any
82121
if (is_valid()) {
@@ -342,6 +381,8 @@ public:
342381
private:
343382
SocketHandle fd_ = INVALID_SOCKET_FD;
344383
std::stop_token stop_;
384+
std::optional<std::chrono::steady_clock::time_point> deadline_;
385+
bool deadline_hit_ = false;
345386

346387
// poll_fd in slices while a token is attached. A negative timeout waits
347388
// without limit. Not std::min: <winsock2.h> defines a `min` macro.

‎src/tls.cppm‎

Lines changed: 45 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -94,8 +94,9 @@ static int bio_send(void* ctx, const unsigned char* buf, size_t len) {
9494
// complete and correct.
9595
static int bio_recv(void* ctx, unsigned char* buf, size_t len) {
9696
auto* sock = static_cast<Socket*>(ctx);
97-
// With a stop token, wait here so the recv below cannot block past a stop.
98-
if (sock->stop_possible() && !sock->wait_readable(-1)) {
97+
// With a stop token or a handshake deadline, wait here so the recv below
98+
// cannot block past a stop or the deadline.
99+
if (!sock->wait_before_recv()) {
99100
return MBEDTLS_ERR_NET_RECV_FAILED;
100101
}
101102
int ret = sock->read(reinterpret_cast<char*>(buf), static_cast<int>(len));
@@ -214,20 +215,24 @@ public:
214215

215216
// Connect over an already-established Socket (e.g. a proxy tunnel).
216217
// Takes ownership of the socket and performs TLS handshake on top of it.
217-
bool connect_over(Socket&& socket, const char* host, bool verifySsl) {
218+
// The handshake gives up after `handshakeTimeoutMs`; a negative value waits
219+
// without limit.
220+
bool connect_over(Socket&& socket, const char* host, bool verifySsl,
221+
int handshakeTimeoutMs = -1) {
218222
error_.clear();
219223
socket_ = std::move(socket);
220-
return setup_tls(host, verifySsl);
224+
return setup_tls(host, verifySsl, handshakeTimeoutMs);
221225
}
222226

223227
// Run the handshake inside another TLS session, which is how a client
224228
// reaches a target through an https:// proxy: TLS to the proxy, CONNECT
225229
// inside it, then this session to the target inside the tunnel. Takes
226230
// ownership of `lower`, which must already be past its CONNECT.
227-
bool connect_over(std::unique_ptr<TlsSocket> lower, const char* host, bool verifySsl) {
231+
bool connect_over(std::unique_ptr<TlsSocket> lower, const char* host, bool verifySsl,
232+
int handshakeTimeoutMs = -1) {
228233
error_.clear();
229234
lower_ = std::move(lower);
230-
return setup_tls(host, verifySsl);
235+
return setup_tls(host, verifySsl, handshakeTimeoutMs);
231236
}
232237

233238
bool connect(const char* host, int port, int timeoutMs, bool verifySsl) {
@@ -237,7 +242,7 @@ public:
237242
return false;
238243
}
239244

240-
return setup_tls(host, verifySsl);
245+
return setup_tls(host, verifySsl, timeoutMs);
241246
}
242247

243248
// The read that says which of the four things happened. Prefer it over
@@ -378,7 +383,16 @@ private:
378383
socket_.close();
379384
}
380385

381-
bool setup_tls(const char* host, bool verifySsl) {
386+
// As `set_stop`, the deadline belongs to the socket at the bottom, which
387+
// inside a tunnel is the one beneath `lower_`.
388+
void set_deadline(std::optional<std::chrono::steady_clock::time_point> deadline) {
389+
if (lower_) lower_->set_deadline(deadline);
390+
else socket_.set_deadline(deadline);
391+
}
392+
393+
bool deadline_hit() const { return lower_ ? lower_->deadline_hit() : socket_.deadline_hit(); }
394+
395+
bool setup_tls(const char* host, bool verifySsl, int handshakeTimeoutMs) {
382396
state_ = std::make_unique<TlsState>();
383397

384398
int ret = mbedtls_ctr_drbg_seed(
@@ -454,17 +468,30 @@ private:
454468
// Set BIO callbacks using our Socket, or the session beneath this one
455469
bind_bio();
456470

457-
// Perform TLS handshake
458-
while ((ret = mbedtls_ssl_handshake(&state_->ssl)) != 0) {
459-
if (ret != MBEDTLS_ERR_SSL_WANT_READ && ret != MBEDTLS_ERR_SSL_WANT_WRITE) {
460-
if (ret == MBEDTLS_ERR_X509_CERT_VERIFY_FAILED) {
461-
char info[512] = {};
462-
mbedtls_x509_crt_verify_info(info, sizeof info, "",
463-
mbedtls_ssl_get_verify_result(&state_->ssl));
464-
return fail("certificate verification failed: " + one_line(info));
465-
}
466-
return fail("TLS handshake failed: " + mbedtls_message(ret));
471+
// Perform TLS handshake. The socket is blocking, so `bio_recv` waits
472+
// against the deadline before it reads, and the deadline is taken off
473+
// again before the connection is used.
474+
std::optional<std::chrono::steady_clock::time_point> deadline;
475+
if (handshakeTimeoutMs >= 0) {
476+
deadline = std::chrono::steady_clock::now()
477+
+ std::chrono::milliseconds(handshakeTimeoutMs);
478+
}
479+
set_deadline(deadline);
480+
while ((ret = mbedtls_ssl_handshake(&state_->ssl)) == MBEDTLS_ERR_SSL_WANT_READ
481+
|| ret == MBEDTLS_ERR_SSL_WANT_WRITE) {}
482+
const bool timedOut = deadline_hit();
483+
set_deadline({});
484+
if (ret != 0) {
485+
if (ret == MBEDTLS_ERR_X509_CERT_VERIFY_FAILED) {
486+
char info[512] = {};
487+
mbedtls_x509_crt_verify_info(info, sizeof info, "",
488+
mbedtls_ssl_get_verify_result(&state_->ssl));
489+
return fail("certificate verification failed: " + one_line(info));
490+
}
491+
if (ret == MBEDTLS_ERR_NET_RECV_FAILED && timedOut) {
492+
return fail("TLS handshake timed out");
467493
}
494+
return fail("TLS handshake failed: " + mbedtls_message(ret));
468495
}
469496

470497
return true;

0 commit comments

Comments
 (0)