4

I'm a volunteer for The Tor Project contributing to Arti (a Rust implementation for Tor).

In this project we implement Rust crates native-tls and tokio-native-tls. We encounter a very persistent issue that when making a request using this crate it works on Linux but consistently fails on MacOS with the error Error: connection closed via error..

This is related to us having a custom DataStream (data.rs#L105) which implements internal buffering.

After doing some thorough research I found that native-tls implements OpenSSL on Linux, but uses Secure Transport on MacOS. My initial thought was that the reason it fails when using Secure Transport, is because we buffer the data internally and it actually does not get send, resulting in the request stalling (because the data remains in our buffer because we do not manually flush it). If that's the case, why does it "magically" work when native-tls uses OpenSSL when ran on Linux? In my research I found that OpenSSL implements a lot of advanced mechanisms like e.g the BIO which might cause an automatic flushing of the buffer of our internal DataStream while Secure Transport simply does not - and that this is the reason our DataStream implementing internal buffering in combination with native-tls only works when OpenSSL is used.

Is this correct? I've been stuck on this issue for a long time but lack the in-depth expertise in low-level TLS-implementations to construct a definitive conclusion.

So that's why I'm asking here if it's indeed true OpenSSL provides these kind of mechanisms triggering a type of automatic flushing, resulting in flushing the buffer of our internal DataStream, while Secure Transport simply doesn't.

More context on the issue can be found here and here.

1 Answer 1

1
+150

The whole API provided within Secure Transport is very limited and their workflow is listed here

Now let's look at the core method in c++ example which reproduces this issue by Neil in Arti's issue you provided.

// Secure Transport Write callback.
static OSStatus WriteFunc(SSLConnectionRef connection, const void* data, size_t* dataLength) {
    Conn* c = SSLConn<Conn>(connection);
    const uint8_t* p = static_cast<const uint8_t*>(data);
    size_t want = *dataLength;
    *dataLength = 0;

    if (c->buffered) {
        // Buffer implementation simulating what happens in Rust:
        // we buffer the data and return noErr pretending it was send (What Arti's DataStream does with the internal buffering).
        for (size_t i = 0; i < want; ++i) {
            c->outBuf.push_back(p[i]);
            if (c->outBuf.size() >= c->cap) {
                // Autoflush when capacity reached (equally as done in Rust).
                OSStatus st = c->flush();
                if (st != noErr) return st;
            }
        }
        *dataLength = want;
        
        return noErr; // Tells Secure Transport all bytes are delivered, but they're only delivered to the internal buffering.
    }
     
    // Direct send without buffering (E.g TcpConnect behavior).
    size_t off = 0;
    while (off < want) {
        ssize_t n = ::send(c->fd, p + off, want - off, 0);
        if (n < 0) {
            if (errno == EWOULDBLOCK || errno == EAGAIN) return errSSLWouldBlock;
            return errSSLClosedAbort;
        }
        off += static_cast<size_t>(n);
    }
    *dataLength = want;

    return noErr;
}

This function is registered by SSLSetIOFuncs(_:_:_:), and this callback is invoked during both the handshake phase (for TLS protocol messages) and application data phase (for encrypted payload)

If the c++ version does mimic the traffic flow; Then the implementation is missing protocol-aware flushing. In TLS, certain moments REQUIRE immediate transmission:

  • Complete handshake messages (ClientHello, ServerHello, etc.)
  • Before waiting for peer response
  • Application data that expects a response

Ideally, we would need logic like this for proper TLS buffering with Secure Transport:

// Pseudo-code for proper TLS buffering
if (c->buffered) {
    buffer_data(data, length);
    
    // Smart flush triggers:
    if (is_handshake_message_complete() ||
        is_waiting_for_peer_response() ||
        if_buffer_full()) {
        
        flush_immediately();
    }
    
    return noErr;
}

without these 2 pseudo API (is_handshake_message_complete and is_waiting_for_peer_response) our test code in the c++ example will fail as:

  • ClientHello gets buffered → never sent because buffer isn't full
  • Secure Transport waits for ServerHello → will never come
  • Handshake times out → connection fails

This explains why Arti's HTTPS requests failed after approximately 30 seconds with error -9806 (errSSLClosedAbort - "connection closed via error"), which typically indicates the client failed to interact with the server within a reasonable timeframe.

Lastly, the Secure Transport framework, in apple's doc:

Important This API is considered legacy. Use the Network framework instead.

And there are no such API like is_handshake_message_complete and is_waiting_for_peer_response in Secure Transport, which means Secure Transport's callback model doesn't provide the hooks needed for smart protocol-aware flushing, so the safest approach is to flush after every write on macOS.


Now let's look at the typical workflow with OpenSSL (with handshake/write application data/read application data stages), and compare the implementation differences to Secure Transport:

// after SSL_set_bio(ssl, rbio, wbio) or SSL_set_fd(ssl, fd) and nonblocking fd

// --- Handshake ---
for (;;) {
    int rc = SSL_connect(ssl);
    if (rc == 1) break;                         // handshake done

    int err = SSL_get_error(ssl, rc);
    if (err == SSL_ERROR_WANT_READ) {
        wait_until_readable(fd);                // select/poll/kevent
        continue;
    }
    if (err == SSL_ERROR_WANT_WRITE) {
        wait_until_writable(fd);                // (don't advance any app buffers)
        // Optionally: BIO_flush(wbio);         // if you *really* need to poke the BIO
        continue;
    }
    fail("handshake", err);                     // SSL_ERROR_SYSCALL, SSL_ERROR_SSL, etc.
}

// --- Write application data (handles partials) ---
size_t off = 0;
while (off < len) {
    int n = SSL_write(ssl, app_data + off, (int)(len - off));
    if (n > 0) { off += (size_t)n; continue; }

    int err = SSL_get_error(ssl, n);
    if (err == SSL_ERROR_WANT_WRITE) { wait_until_writable(fd); continue; }
    if (err == SSL_ERROR_WANT_READ)  { wait_until_readable(fd); continue; }
    fail("write", err);
}

// --- Read application data ---
for (;;) {
    int n = SSL_read(ssl, buf, sizeof buf);
    if (n > 0) { consume(buf, n); continue; }

    int err = SSL_get_error(ssl, n);
    if (err == SSL_ERROR_WANT_READ)  { wait_until_readable(fd); continue; }
    if (err == SSL_ERROR_WANT_WRITE) { wait_until_writable(fd); continue; }
    if (err == SSL_ERROR_ZERO_RETURN) break;    // peer performed a clean shutdown
    fail("read", err);
}

So, the key differences:

  1. Callback vs Function based APIs:
  • Secure Transport: The same WriteFunc callback handles both handshake messages and application data, with no way to distinguish between them or their urgency.
  • OpenSSL: Uses separate functions (SSL_connect for handshake, SSL_write for application data), allowing different handling strategies for each phase.
  1. State Communication:
  • OpenSSL: Returns SSL_ERROR_WANT_WRITE to explicitly signal "flush needed" state. Applications can then either wait_until_writable(fd) or call BIO_flush() to handle buffered data.
  • Secure Transport: Provides no equivalent state signaling mechanism. The callback must return immediate success/failure with no way to communicate buffering requirements back to the caller.
Sign up to request clarification or add additional context in comments.

5 Comments

Thanks a lot for your reply, it's very clear. Could you only also explain on why exactly OpenSSL does not require flushing after every write, while Secure Transport does?
Sure, let me write a simple version with openssl.
I'd highly appreciate that. I am not able to find an expert opinion on this niche topic regarding the Secure Transport vs OpenSSL implementation. Would you agree that this behavior is not necessarily a bug in a Rust crate, but rather the result from Secure Transport's limited API/workings?
Yes, O'Niel, of that I'm pretty sure. There is little API or states that the Secure Transport Offers.

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.