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

How to connect to FTPS server with data connection using same TLS session?

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

我正在尝试将我们的应用程序与从我们客户的 FTPS 服务器自动下载的功能集成在一起。我已经成功地使用 SFTP 服务器使用 Spring 集成,没有给其他客户带来任何麻烦,但这是第一次客户要求我们使用 FTPS,让它连接起来非常令人费解。在我的真实应用程序中,我正在使用 XML bean 配置 Spring 集成,为了尝试了解什么不起作用,我正在使用以下测试代码(尽管我正在匿名化实际的 host/username/password 这里):

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 服务器的主要 "control" 连接工作正常,但是当它尝试打开列表的数据连接(或我尝试过的任何其他数据连接)时,我得到 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)是服务器确保身份验证和加密的方式控制和数据连接的最大区别在于,在 "normal" TLS 连接建立控制连接并在那里进行身份验证后,每个数据连接都需要客户端连接到相同的 TLS 会话。这对我来说很有意义,因为它应该如何工作,但 Apache Commons Net FTPS 实现似乎并没有这样做。它似乎正在尝试建立新的 TLS 会话,因此服务器拒绝了该尝试。

根据 this question about resuming SSL sessions in JSSE,似乎 Java 假定或需要每个 host/post 组合的不同会话。我的假设是,由于 FTPS 数据连接与控制连接位于不同的端口,它没有找到现有的会话并试图建立一个新会话,因此连接失败。

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

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

如果您能提供有关如何连接到这种 FTPS 服务器的任何指导,我将不胜感激。谢谢。

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

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


Cyber​​duck FTP(S) 客户端确实支持 TLS/SSL 会话重用并且它使用 Apache Commons Net 库:

  • https://github.com/iterate-ch/cyberduck/issues/5087 – 在数据连接上重用会话密钥

  • 见其FTPClient.java code (extends Commons Net FTPSClient), particularly its override of _prepareDataSocket_ method:

     @Override
     protected void _prepareDataSocket_(final Socket socket) {
         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 putMethod = cache.getClass().getDeclaredMethod("put", Object.class, Object.class);
                         putMethod.setAccessible(true);
                         Method getHostMethod;
                         try {
                             getHostMethod = socket.getClass().getMethod("getPeerHost");
                         }
                         catch(NoSuchMethodException e) {
                             // Running in IKVM
                             getHostMethod = socket.getClass().getDeclaredMethod("getHost");
                         }
                         getHostMethod.setAccessible(true);
                         Object peerHost = getHostMethod.invoke(socket);
                         putMethod.invoke(cache, String.format("%s:%s", peerHost, 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_ 方法被添加到 Commons Net FTPSClient 专门用于允许 TLS/SSL 会话重用实现:
    https://issues.apache.org/jira/browse/NET-426

    重用的本机支持仍在等待中:
    https://issues.apache.org/jira/browse/NET-408

  • 您显然需要使用会话重用支持覆盖 Spring 集成 DefaultFtpsSessionFactory.createClientInstance() 到 return 您的自定义 FTPSClient 实现。


自从 JDK 8u161.

以来,上述解决方案不再单独工作

根据JDK 8u161 Update Release Notes (and the ):

Added TLS session hash and extended master secret extension support
...
In case of compatibility issues, an application may disable negotiation of this extension by setting the System Property jdk.tls.useExtendedMasterSecret to false in the JDK

也就是说,你可以调用它来解决问题(你仍然需要覆盖 _prepareDataSocket_):

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

虽然这应该只是一种解决方法。我不知道合适的解决方案。


这里有一个替代实现:
https://issues.apache.org/jira/browse/NET-408


关于 1.8 中的问题有一个单独的问题。0_161:


我过去确实有the same problem(只是在C++/OpenSSL中,我没有Java),所以我知道google是为了什么.

为了让 Martin Prikryl 的建议对我有用,我不仅要将密钥存储在 socket.getInetAddress().getHostName() 下,还要存储在 socket.getInetAddress().getHostAddress() 下。 (从 here 窃取的解决方案。)

您可以使用这个 SSLSessionReuseFTPSClient class :

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");
            }
        }
    }
}

并打开JDK 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 来禁用此扩展的协商

@Martin Prikryl 的回答对我有帮助。

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

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

还是不行,你可以试试同样功能的JVM参数:

-Djdk.tls.useExtendedMasterSecret=false.

希望能帮到你。