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.