BAD CLUSTER
← Back to blog

The TLS Session You Can Only Use Once

FTPS - FTP wrapped in TLS - opens two separate connections: one for commands, one for the actual file data. RFC 4217 lets a server insist that the data connection reuse the exact TLS session the control connection already authenticated, rather than negotiating a fresh one. The reasoning is straightforward: if the data connection has to prove it's a resumption of the same session, a third party can't open their own connection to the server and splice it in as your data channel. Without that check, hijacking a data transfer is easier than it should be.

proftpd's mod_tls enforces this by default, and it's a reasonable thing to enforce. It's also where ZephyrFTP - our dual-pane FTP/SFTP/FTPS client - kept failing every FTPS data transfer against a real proftpd server, even though the control connection's handshake was clean and the same session object was being handed straight to the data connection's handshake, seemingly correctly.

Ruling out the obvious suspects first

The first assumption was a server-side caching problem - proftpd's TLSSessionCache and TLSSessionTickets directives both looked like plausible culprits, so both got tried in a few configurations against a disposable test container. Zero difference, in either direction. That ruled the server out and pointed everything back at the client.

What was actually happening

The client was capturing the control connection's SSL_SESSION* after a successful handshake, then handing that same object to SSL_set_session() for the data connection - the normal, textbook way to do TLS session resumption in OpenSSL. It worked. Once.

The first data connection of a session to attempt resumption would succeed cleanly. Every subsequent one - even against the same control connection, even up-ref'd rather than reused raw, even re-fetched fresh via SSL_get1_session() right after a successful resumed handshake - would silently fall back to a full, non-resumed handshake instead (SSL_session_reused() reporting 0). A strict server correctly rejects that as not the session it agreed to accept, and the transfer dies.

This isn't documented anywhere obvious. The closest read is that a client-side SSL_SESSION object picks up some internal "already used in a handshake" state the first time it's handed to SSL_connect(), and every reuse of that same object after that point is quietly degraded rather than rejected outright - which is exactly what makes it hard to spot. No error. No log line. Just a session that resumes once and then silently stops resuming.

The fix

Don't reuse the object. Duplicate it, every time, immediately before the handshake that needs it - a full DER round trip (i2d_SSL_SESSION() to serialize, d2i_SSL_SESSION() to parse it back into a genuinely independent object) rather than an up-ref of the existing one:

unsigned char *der = nullptr;
const int derLen = i2d_SSL_SESSION(sessionToReuse, &der);
if (derLen > 0) {
    const unsigned char *derPtr = der;
    SSL_SESSION *duplicate = d2i_SSL_SESSION(nullptr, &derPtr, derLen);
    if (duplicate) {
        SSL_set_session(m_ssl, duplicate);   // up-refs internally
        SSL_SESSION_free(duplicate);          // drop our own temporary ref
    }
}
OPENSSL_free(der);

Same session content, parsed fresh into its own object, resumes successfully every single time - first data connection, tenth, doesn't matter. Confirmed against a real proftpd container before it went anywhere near production code, and it's the reason ZephyrFTP runs its own small OpenSSL layer for FTPS specifically rather than leaning on Qt's socket class for it, which doesn't give this level of control over session handling.

If you've hit a "resumption only works the first time" symptom with OpenSSL in some other client-side context - this isn't FTP-specific at all, it's just how SSL_SESSION objects behave - this is worth remembering. It cost real investigation time to track down here.

ZephyrFTP is a dual-pane FTP/SFTP/FTPS client - neither pane is hard-wired to "local," so you can connect both sides to different servers and move files directly between them. It's on our Forgejo if you want the details, or just want the app.