如何使用相同的 TLS 会话通过数据连接连接到 FTPS 服务器?

IT小君   2021-09-21T00:00:23

环境:我在 64 位 Windows 7 上使用 Sun Java JDK 1.8.0_60,使用 Spring Integration 4.1.6(内部似乎使用 Apache Commons Net 3.3 进行 FTPS 访问)。

我正在尝试与我们的应用程序集成从我们客户端的 FTPS 服务器自动下载。我已经使用 Spring Integration 在 SFTP 服务器上成功地做到了这一点,其他客户端没有任何问题而没有任何问题,但这是客户端第一次要求我们使用 FTPS,让它连接起来非常令人费解。在我的实际应用程序中,我正在使用 XML bean 配置 Spring Integration,为了尝试了解什么不起作用,我正在使用以下测试代码(尽管我在这里对实际主机/用户名/密码进行了匿名处理):

final DefaultFtpsSessionFactory sessionFactory = new DefaultFtpsSessionFactory();
sessionFactory.setHost("XXXXXXXXX");
sessionFactory.setPort(990);
sessionFactory.setUsername("XXXXXXX");
sessionFactory.setPassword("XXXXXXX");
sessionFactory.setClientMode(2);
sessionFactory.setFileType(2);
sessionFactory.setUseClientMode(true);
sessionFactory.setImplicit(true);
sessionFactory.setTrustManager(TrustManagerUtils.getAcceptAllTrustManager());
sessionFactory.setProt("P");
sessionFactory.setProtocol("TLSv1.2");
sessionFactory.setProtocols(new String[]{"TLSv1.2"});
sessionFactory.setSessionCreation(true);
sessionFactory.setCipherSuites(new String[]{"TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256"});

final FtpSession session = sessionFactory.getSession();
//try {
    final FTPFile[] ftpFiles = session.list("/");
    logger.debug("FtpFiles: {}", (Object[]) ftpFiles);
//} catch (Exception ignored ) {}
session.close();

我正在运行此代码以-Djavax.net.debug=all打印所有 TLS 调试信息。

到 FTPS 服务器的主要“控制”连接工作正常,但是当它尝试打开列表的数据连接(或我尝试过的任何其他数据连接)时,我得到一个javax.net.ssl.SSLHandshakeException: Remote host closed connection during handshake,由java.io.EOFException: SSL peer shut down incorrectly. 如果我取消注释session.list命令周围的swallowing-exceptions catch块,那么我可以看到(通过javax.net.debug输出)服务器在拒绝数据连接SSL握手后发送了以下消息:

main, READ: TLSv1.2 Application Data, length = 129
Padded plaintext after DECRYPTION:  len = 105
0000: 34 35 30 20 54 4C 53 20   73 65 73 73 69 6F 6E 20  450 TLS session 
0010: 6F 66 20 64 61 74 61 20   63 6F 6E 6E 65 63 74 69  of data connecti
0020: 6F 6E 20 68 61 73 20 6E   6F 74 20 72 65 73 75 6D  on has not resum
0030: 65 64 20 6F 72 20 74 68   65 20 73 65 73 73 69 6F  ed or the sessio
0040: 6E 20 64 6F 65 73 20 6E   6F 74 20 6D 61 74 63 68  n does not match
0050: 20 74 68 65 20 63 6F 6E   74 72 6F 6C 20 63 6F 6E   the control con
0060: 6E 65 63 74 69 6F 6E 0D   0A                       nection..

似乎正在发生的事情(这是我第一次处理 FTPS,虽然我之前处理过普通 FTP)是服务器确保对控制和数据连接进行身份验证和加密的方式是在“正常”之后TLS 连接建立控制连接和身份验证发生在那里,每个数据连接都要求客户端使用相同的 TLS 会话进行连接。这对我来说是有意义的,因为它应该如何工作,但 Apache Commons Net FTPS 实现似乎并没有这样做。它似乎正在尝试建立一个新的 TLS 会话,因此服务器拒绝了该尝试。

基于这个关于在 JSSE 中恢复 SSL 会话的问题,Java 似乎假设或需要每个主机/帖子组合的不同会话。我的假设是,由于 FTPS 数据连接位于与控制连接不同的端口上,因此它没有找到现有会话并试图建立一个新会话,因此连接失败。

我看到了三种主要的可能性:

  1. 服务器不遵循 FTPS 标准,要求数据端口上的 TLS 会话与控制端口上的相同。我可以使用 FileZilla 3.13.1 连接到服务器(使用与我在代码中尝试使用的相同的主机/用户/密码)。服务器在登录时将自己标识为“FileZilla Server 0.9.53 beta”,所以也许这是某种专有的 FileZilla 做事方式,我需要做一些奇怪的事情来说服 Java 使用相同的 TLS 会话。
  2. Apache Commons Net 客户端实际上并不遵循 FTPS 标准,并且只允许某些不允许保护数据连接的子集。这看起来很奇怪,因为它似乎是从 Java 内部连接到 FTPS 的标准方式。
  3. 我完全错过了一些东西并误诊了这一点。

我很感激你能提供的关于如何连接到这种 FTPS 服务器的任何方向。谢谢你。

评论(4)
IT小君

实际上,某些 FTP(S) 服务器确实要求将 TLS/SSL 会话重用于数据连接。这是一种安全措施,服务器可以通过它验证数据连接是否与控制连接由同一客户端使用。

常见FTP服务器的一些参考:


可以帮助您实现的是 Cyber​​duck FTP(S) 客户端确实支持 TLS/SSL 会话重用,并且它使用 Apache Commons Net 库:

  • https://trac.cyberduck.io/ticket/5087 - 在数据连接上重用会话密钥

  • 查看它的FTPClient.java代码(扩展 Commons Net FTPSClient),特别是它对method 的覆盖_prepareDataSocket_

      @Override
      protected void _prepareDataSocket_(final Socket socket) throws IOException {
          if(preferences.getBoolean("ftp.tls.session.requirereuse")) {
              if(socket instanceof SSLSocket) {
                  // Control socket is SSL
                  final SSLSession session = ((SSLSocket) _socket_).getSession();
                  if(session.isValid()) {
                      final SSLSessionContext context = session.getSessionContext();
                      context.setSessionCacheSize(preferences.getInteger("ftp.ssl.session.cache.size"));
                      try {
                          final Field sessionHostPortCache = context.getClass().getDeclaredField("sessionHostPortCache");
                          sessionHostPortCache.setAccessible(true);
                          final Object cache = sessionHostPortCache.get(context);
                          final Method method = cache.getClass().getDeclaredMethod("put", Object.class, Object.class);
                          method.setAccessible(true);
                          method.invoke(cache, String.format("%s:%s", socket.getInetAddress().getHostName(),
                                  String.valueOf(socket.getPort())).toLowerCase(Locale.ROOT), session);
                          method.invoke(cache, String.format("%s:%s", socket.getInetAddress().getHostAddress(),
                                  String.valueOf(socket.getPort())).toLowerCase(Locale.ROOT), session);
                      }
                      catch(NoSuchFieldException e) {
                          // Not running in expected JRE
                          log.warn("No field sessionHostPortCache in SSLSessionContext", e);
                      }
                      catch(Exception e) {
                          // Not running in expected JRE
                          log.warn(e.getMessage());
                      }
                  }
                  else {
                      log.warn(String.format("SSL session %s for socket %s is not rejoinable", session, socket));
                  }
              }
          }
      }
    
  • 似乎该_prepareDataSocket_方法是FTPSClient专门添加到 Commons Net 中以允许 TLS/SSL 会话重用实现的:https :
    //issues.apache.org/jira/browse/NET-426

    对重用的原生支持仍然悬而未决:https :
    //issues.apache.org/jira/browse/NET-408

  • 您显然需要覆盖 Spring IntegrationDefaultFtpsSessionFactory.createClientInstance()以返回FTPSClient具有会话重用支持的自定义实现。


自 JDK 8u161 起,上述解决方案不再单独工作。

根据JDK 8u161 更新发行说明(以及@Laurent回答):

添加了 TLS 会话哈希和扩展的主密钥扩展支持
......
在兼容性问题的情况下,应用程序可以通过在 JDK 中设置系统属性jdk.tls.useExtendedMasterSecret禁用此扩展的协商false

即,您可以调用它来解决问题:

System.setProperty("jdk.tls.useExtendedMasterSecret", "false");

尽管这应该仅被视为一种解决方法。我不知道一个合适的解决方案。


另一个实现在这里:https :
//issues.apache.org/jira/browse/NET-408


关于 1.8.0_161 中的问题有一个单独的问题:
SSL 会话重用在 JDK 8u161 中的 Apache FTPS 客户端


实际上我过去也遇到过同样的问题(只是在 C++/OpenSSL 中,我不使用 Java),所以我知道该用 google 搜索什么。

2021-09-21T00:00:25   回复
IT小君

您可以使用此 SSLSessionReuseFTPSClient 类:

import java.io.IOException;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.net.Socket;
import java.util.Locale;

import javax.net.ssl.SSLSession;
import javax.net.ssl.SSLSessionContext;
import javax.net.ssl.SSLSocket;

import org.apache.commons.net.ftp.FTPSClient;

public class SSLSessionReuseFTPSClient extends FTPSClient {

    // adapted from:
    // https://trac.cyberduck.io/browser/trunk/ftp/src/main/java/ch/cyberduck/core/ftp/FTPClient.java
    @Override
    protected void _prepareDataSocket_(final Socket socket) throws IOException {
        if (socket instanceof SSLSocket) {
            // Control socket is SSL
            final SSLSession session = ((SSLSocket) _socket_).getSession();
            if (session.isValid()) {
                final SSLSessionContext context = session.getSessionContext();
                try {
                    final Field sessionHostPortCache = context.getClass().getDeclaredField("sessionHostPortCache");
                    sessionHostPortCache.setAccessible(true);
                    final Object cache = sessionHostPortCache.get(context);
                    final Method method = cache.getClass().getDeclaredMethod("put", Object.class, Object.class);
                    method.setAccessible(true);
                    method.invoke(cache, String
                            .format("%s:%s", socket.getInetAddress().getHostName(), String.valueOf(socket.getPort()))
                            .toLowerCase(Locale.ROOT), session);
                    method.invoke(cache, String
                            .format("%s:%s", socket.getInetAddress().getHostAddress(), String.valueOf(socket.getPort()))
                            .toLowerCase(Locale.ROOT), session);
                } catch (NoSuchFieldException e) {
                    throw new IOException(e);
                } catch (Exception e) {
                    throw new IOException(e);
                }
            } else {
                throw new IOException("Invalid SSL Session");
            }
        }
    }
}

并使用 openJDK 1.8.0_161 :

我们必须设置:

System.setProperty("jdk.tls.useExtendedMasterSecret", "false");

根据 http://www.oracle.com/technetwork/java/javase/8u161-relnotes-4021379.html

添加了 TLS 会话哈希和扩展的主密钥扩展支持

在兼容性问题的情况下,应用程序可以通过在 JDK 中将系统属性 jdk.tls.useExtendedMasterSecret 设置为 false 来禁用此扩展的协商

2021-09-21T00:00:25   回复
IT小君

为了让 Martin Prikryl 的建议对我有用,我不仅必须将密钥存储socket.getInetAddress().getHostName()socket.getInetAddress().getHostAddress(). (从这里窃取的解决方案。)

2021-09-21T00:00:26   回复
IT小君

@Martin Prikryl 的回答帮助了我。

根据我的实践,值得一提的是,如果你使用

System.setProperty("jdk.tls.useExtendedMasterSecret", "false");

它不起作用,您可以尝试相同功能的JVM参数:

-Djdk.tls.useExtendedMasterSecret=false.

希望能帮到你。

2021-09-21T00:00:27   回复