Как подключиться к серверу FTPS с передачей данных, используя тот же сеанс TLS?

Среда: я использую Sun Java JDK 1.8.0_60 в 64-битной Windows 7, используя Spring Integration 4.1.6 (которая, по-видимому, использует Apache Commons Net 3.3 для доступа по FTPS).

Я пытаюсь интегрировать с нашим приложением автоматическую загрузку с сервера FTPS нашего клиента. Я успешно справился с SFTP-серверами, используя Spring Integration без каких-либо проблем для других клиентов без проблем, но это первый раз, когда клиент требует, чтобы мы использовали FTPS, и подключение к нему было очень загадочным. В то время как в моем реальном приложении я настраиваю Spring Integration с использованием bean-компонентов XML, чтобы попытаться понять, что не работает, я использую следующий тестовый код (хотя здесь я анонимизирую фактический хост / имя пользователя / пароль):

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 команда, затем я вижу (хотя вывод 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, и поэтому сервер отклоняет попытку.

Исходя из этого вопроса о возобновлении сеансов SSL в JSSE, выясняется, что Java предполагает или требует разные сеансы для каждой комбинации хост / пост. Моя гипотеза состоит в том, что, поскольку соединение для передачи данных FTPS находится на другом порту, чем управляющее соединение, он не находит существующий сеанс и пытается установить новый, поэтому соединение не устанавливается.

Я вижу три основные возможности:

  1. Сервер не следует стандарту FTPS, требуя того же сеанса TLS на порте данных, что и на порте управления. Я могу нормально подключиться к серверу (используя тот же хост / пользователя / пароль, который я пытаюсь использовать в своем коде), используя FileZilla 3.13.1. Сервер идентифицирует себя как "FileZilla Server 0.9.53 beta" при входе в систему, так что, возможно, это какой-то проприетарный метод FileZilla, и есть кое-что странное, что мне нужно сделать, чтобы убедить Java использовать тот же сеанс TLS.
  2. Клиент Apache Commons Net на самом деле не следует стандарту FTPS, а допускает только некоторые подмножества, которые не позволяют защищать соединения для передачи данных. Это может показаться странным, так как кажется, что это стандартный способ подключения к FTPS из Java.
  3. Я совершенно что-то упускаю и неправильно диагностирую это.

Я был бы признателен за любые указания относительно того, как подключиться к серверу FTPS такого типа. Спасибо.

5 ответов

Решение

Действительно, некоторые серверы FTP(S) требуют, чтобы сеанс TLS/SSL повторно использовался для подключения к данным. Это мера безопасности, с помощью которой сервер может проверить, используется ли соединение для передачи данных тем же клиентом, что и управляющее соединение.

Некоторые ссылки для распространенных FTP-серверов:


Что может помочь в реализации, так это то, что клиент Cyberduck FTP(S) поддерживает повторное использование сеансов TLS/SSL и использует библиотеку Apache Commons Net:

  • https://trac.cyberduck.io/ticket/5087 - повторно использовать ключ сеанса при подключении к данным

  • Увидеть его FTPClient.java код (расширяет сеть Commons FTPSClient), особенно его переопределение _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_ метод был добавлен в Commons Net FTPSClient специально, чтобы позволить реализацию повторного использования сеанса TLS/SSL:
    https://issues.apache.org/jira/browse/NET-426

    Нативная поддержка для повторного использования все еще ожидается:
    https://issues.apache.org/jira/browse/NET-408

  • Вам, очевидно, нужно будет переопределить интеграцию Spring DefaultFtpsSessionFactory.createClientInstance() вернуть свой обычай FTPSClient реализация с поддержкой повторного использования сессии.


Вышеупомянутое решение больше не работает само по себе, так как JDK 8u161.

Согласно примечаниям к выпуску обновления JDK 8u161ответу @Laurent):

Добавлен хэш сеанса TLS и расширенная поддержка расширений главного секрета.

...

В случае проблем с совместимостью приложение может отключить согласование этого расширения, установив свойство системы jdk.tls.useExtendedMasterSecret в false в JDK

То есть, вы можете вызвать это, чтобы решить проблему:

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

Хотя это должно рассматриваться только как обходной путь. Я не знаю правильного решения.

Вы можете использовать этот класс 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 и расширенная поддержка расширений главного секрета.

В случае проблем с совместимостью приложение может отключить согласование этого расширения, установив для свойства System jdk.tls.useExtendedMasterSecret значение false в JDK.

Чтобы предложение Мартина Прикрыла сработало для меня, мне пришлось хранить ключ не только под socket.getInetAddress().getHostName() но и под socket.getInetAddress().getHostAddress(), (Решение украдено отсюда.)

Если вы можете позволить себе добавить Bouncy Castle TLSorg.bouncycastle:bctls-jdk18onв качестве зависимости вы можете попробовать следующее решение, которое позволяет избежать отражения и установки глобальных свойств:

      class FTPSClientWithResumeBC extends FTPSClient {
    static {
        Security.addProvider(new BouncyCastleJsseProvider());
    }

    public FTPSClientWithResumeBC() {
        super(createSslContext());
    }

    private static SSLContext createSslContext() {
        try {
            // doesn't work with TLSv1.3
            SSLContext sslContext = SSLContext.getInstance("TLSv1.2", "BCJSSE");
            sslContext.init(null, new TrustManager[]{TrustManagerUtils.getValidateServerCertificateTrustManager()}, new SecureRandom()); // 1
            return sslContext;
        } catch (NoSuchAlgorithmException | NoSuchProviderException | KeyManagementException e) {
            throw new RuntimeException("Cannot create SSL context.", e);
        }
    }

    @Override
    protected void _connectAction_() throws IOException {
        super._connectAction_();
        execPBSZ(0);
        execPROT("P");
    }

    @Override
    protected void _prepareDataSocket_(Socket dataSocket) {
        if (_socket_ instanceof BCSSLSocket sslSocket) {
            BCExtendedSSLSession bcSession = sslSocket.getBCSession();
            if (bcSession != null && bcSession.isValid() && dataSocket instanceof BCSSLSocket dataSslSocket) {
                dataSslSocket.setBCSessionToResume(bcSession); // 2
                dataSslSocket.setHost(bcSession.getPeerHost()); // 3
            }
        }
    }
}

Примечания:

  • работал у меня™ сorg.bouncycastle:bctls-jdk18on:1.77,commons-net:commons-net:3.10.0и сервер FileZilla 1.7.3
  • Реализация BouncyCastle TLS поддерживает возобновление сеанса — используется в строке, отмеченной цифрой 2.
  • строка, отмеченная цифрой 3, была необходима для одного из моих тестовых серверов, она требовала присутствия SNI в ClientHello подключения к данным (и не делала этого сама по себе)
  • инициализация в строке с пометкой 1 аналогична тому, чтоFTPSClientделает по умолчанию, вам может потребоваться настроить его в соответствии с вашими потребностями
  • Я не эксперт ни по SSL, ни по FTPS, поэтому отнеситесь к ответу с некоторой осторожностью.

Ответ @Martin Prikryl мне помог.

Согласно моей практике, стоит упомянуть, что если вы использовали

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

и это не работает, вы можете попробовать параметры JVM той же функции:

-Djdk.tls.useExtendedMasterSecret=false.

Надеюсь помочь вам.

Другие вопросы по тегам