Cómo resolver errores en el apretón de manos de SSL en Java

¿Cómo resolver errores de falla en el apretón de manos de SSL en Java?



1. Resumen

Un Secured Socket Layer (SSL) es un protocolo criptográfico que proporciona seguridad en la comunicación a través de la red. En este tutorial, discutiremos varios escenarios que pueden resultar en una falla en el apretón de manos de SSL y cómo solucionarlos. Tenga en cuenta que nuestra Introducción a SSL usando JSSE cubre los conceptos básicos de SSL con más detalle.



2. Terminología

Es importante notar que, debido a vulnerabilidades de seguridad, Transport Layer Security (TLS) ha superado a SSL como estándar. La mayoría de los lenguajes de programación, incluido Java, cuentan con bibliotecas que admiten tanto SSL como TLS. Desde la aparición de SSL, muchos productos y lenguajes como OpenSSL y Java han mantenido referencias a SSL, incluso después de que TLS tomó el relevo. Por esta razón, en el resto de este tutorial, utilizaremos el término SSL para referirnos, en general, a los protocolos criptográficos.



3. Configuración

Para demostrar el funcionamiento, crearemos aplicaciones de servidor-cliente utilizando la API de Sockets de Java y simularemos una conexión de red.



3.1. Creando un Cliente y un Servidor

En Java, podemos utilizar sockets para establecer un canal de comunicación entre un servidor y un cliente a través de la red. Los sockets son parte de la Extensión de Socket Seguro de Java (JSSE). Comencemos definiendo un servidor simple:


int port = 8443;
ServerSocketFactory factory = SSLServerSocketFactory.getDefault();
try (ServerSocket listener = factory.createServerSocket(port)) {
    SSLServerSocket sslListener = (SSLServerSocket) listener;
    sslListener.setNeedClientAuth(true);
    sslListener.setEnabledCipherSuites(
      new String[] { "TLS_DHE_DSS_WITH_AES_256_CBC_SHA256" });
    sslListener.setEnabledProtocols(
      new String[] { "TLSv1.2" });
    while (true) {
        try (Socket socket = sslListener.accept()) {
            PrintWriter out = new PrintWriter(socket.getOutputStream(), true);
            out.println("Hello World!");
        }
    }
}

El servidor definido arriba devuelve el mensaje “¡Hola Mundo!” al cliente conectado.

A continuación, definamos un cliente que se conectará a nuestro SimpleServer y leerá los mensajes del servidor:


String host = "localhost";
int port = 8443;
SocketFactory factory = SSLSocketFactory.getDefault();
try (Socket connection = factory.createSocket(host, port)) {
    ((SSLSocket) connection).setEnabledCipherSuites(
      new String[] { "TLS_DHE_DSS_WITH_AES_256_CBC_SHA256" });
    ((SSLSocket) connection).setEnabledProtocols(
      new String[] { "TLSv1.2" });

    SSLParameters sslParams = new SSLParameters();
    sslParams.setEndpointIdentificationAlgorithm("HTTPS");
    ((SSLSocket) connection).setSSLParameters(sslParams);

    BufferedReader input = new BufferedReader(
      new InputStreamReader(connection.getInputStream()));
    System.out.println(input.readLine());
}

Nuestro cliente imprime el mensaje devuelto por el servidor.



3.2. Creando Certificados en Java

SSL proporciona secreto, integridad y autenticidad en las comunicaciones de red. Los certificados juegan un papel esencial en el establecimiento de la autenticidad. Generalmente, compramos un certificado a una Autoridad Certificadora. Pero utilizaremos un certificado autofirmado para nuestros ejemplos.

Para lograr esto, podemos usar keytool, que se incluye con el JDK:


$ keytool -genkey -keypass password \
                  -storepass password \
                  -keystore serverkeystore.jks

El comando anterior inicia un shell interactivo para recopilación de información para el certificado, como el Nombre Común (CN) y el Nombre Distinguido (DN). Cuando proporcionamos todos los detalles relevantes, se genera el archivo serverkeystore.jks, que contiene la clave privada del servidor y su certificado público.

Nota que serverkeystore.jks utiliza el formato de Almacén de Claves de Java (JKS), que es propietario de Java. En estos días, keytool nos recordará que deberíamos considerar usar PKCS#12, que también admite.

Además, podemos usar keytool para extraer el certificado público del archivo del almacén de claves generado:


$ keytool -export -storepass password \
                  -file server.cer \
                  -keystore serverkeystore.jks

En resumen, el comando anterior exporta el certificado público del almacén de claves como un archivo server.cer. Ahora, agreguemos el certificado al almacén de confianza del cliente:


$ keytool -import -v -trustcacerts \
                     -file server.cer \
                     -keypass password \
                     -storepass password \
                     -keystore clienttruststore.jks

Ahora hemos generado un almacén de claves para el servidor y un almacén de confianza correspondiente para el cliente. Discutiremos el uso de estos archivos generados cuando analicemos las posibles fallas en el apretón de manos.



4. Apretón de manos SSL

Los apretones de manos SSL son un mecanismo mediante el cual un cliente y un servidor establecen la confianza y la logística requeridas para asegurar su conexión a través de la red. Entender los detalles de este procedimiento orquestado es crucial para depurar fallos.

Los pasos típicos en un apretón de manos SSL son:

  • El cliente proporciona una lista de posibles versiones de SSL y suites de cifrado a utilizar.
  • El servidor acepta una versión y suite de cifrado particular, respondiendo con su certificado.
  • El cliente extrae la clave pública del certificado y responde con una “clave pre-maestra” cifrada.
  • El servidor descifra la “clave pre-maestra” utilizando su clave privada.
  • Cliente y servidor calculan un “secreto compartido” utilizando la “clave pre-maestra” intercambiada.
  • Cliente y servidor intercambian mensajes confirmando la exitosa encriptación y desencriptación utilizando el “secreto compartido”.

Si bien la mayoría de los pasos son los mismos para cualquier apretón de manos SSL, hay una diferencia sutil entre los apretones de manos unidireccional y bidireccional. Rápidamente revisemos estas diferencias.



4.1. El Apretón de Manos en SSL Unidireccional

Si nos referimos a los pasos mencionados anteriormente, el paso dos menciona el intercambio de certificados. SSL unidireccional requiere que un cliente pueda confiar en el servidor a través de su certificado público. Esto deja al servidor confiando en todos los clientes que solicitan una conexión. No hay forma de que un servidor solicite y valide el certificado público de los clientes, lo que puede suponer un riesgo de seguridad.



4.2. El Apretón de Manos en SSL Bidireccional

Con SSL unidireccional, el servidor debe confiar en todos los clientes. Sin embargo, SSL bidireccional añade la capacidad para que el servidor pueda establecer clientes de confianza también. Para lograr una conexión exitosa utilizando un apretón de manos bidireccional, tanto el cliente como el servidor deben presentar y aceptar los certificados públicos del otro.



5. Escenarios de Falla en el Apretón de Manos

Teniendo en cuenta esa rápida revisión, ahora podemos ver los escenarios de falla con mayor claridad.

Un apretón de manos SSL, en comunicación unidireccional o bidireccional, puede fallar por múltiples razones. Revisaremos cada una de estas razones, simularemos la falla y entenderemos cómo podemos evitar tales escenarios. En cada uno de estos escenarios, utilizaremos el SimpleClient y el SimpleServer de nuestro ejemplo anterior.

La mayoría de las veces, la excepción lanzada en caso de falla será genérica. Por lo tanto, para depurar el apretón de manos SSL, debemos establecer la propiedad javax.net.debug en ssl:handshake para mostrarnos detalles más granulares sobre el apretón de manos:


System.setProperty("javax.net.debug", "ssl:handshake");


5.1. Certificado del Servidor Faltante

Intentemos ejecutar el SimpleServer y conectarlo a través del SimpleClient. De inmediato, el SimpleClient lanza una excepción en lugar del mensaje “¡Hola Mundo!”:


Exception in thread "main" javax.net.ssl.SSLHandshakeException: 
  Received fatal alert: handshake_failure

Esto indica que algo salió mal. La SSLHandshakeException anterior, en un sentido abstracto, indica que el cliente, al conectarse al servidor, no recibió ningún certificado.

Para abordar este problema, utilizaremos el almacén de claves que generamos anteriormente pasándolo como propiedades del sistema al servidor:


-Djavax.net.ssl.keyStore=serverkeystore.jks -Djavax.net.ssl.keyStorePassword=password

Es importante señalar que la propiedad del sistema para la ruta de archivo del almacén de claves debe ser una ruta absoluta o que el archivo del almacén de claves debe colocarse en el mismo directorio desde donde se invoca el comando Java para iniciar el servidor. La propiedad del sistema de Java para el almacén de claves no admite rutas relativas.

¿Esto nos ayudará a obtener la salida que estamos esperando? Averigüémoslo en la siguiente subsección.



5.2. Certificado del Servidor No Confiable

Al reiniciar el SimpleServer y el SimpleClient con los cambios de la subsección anterior, ¿qué obtendremos como salida?


Exception in thread "main" javax.net.ssl.SSLHandshakeException: 
  sun.security.validator.ValidatorException: 
  PKIX path building failed: sun.security.provider.certpath.SunCertPathBuilderException: 
  unable to find valid certification path to requested target

Bien, no funcionó exactamente como esperábamos, pero parece que falló por un motivo diferente.

Esta falla particular se debe al hecho de que nuestro servidor está utilizando un certificado autofirmado, que no está firmado por una Autoridad Certificadora (CA).

Realmente, cada vez que el certificado está firmado por algo diferente de lo que está en el almacén de confianza predeterminado, veremos este error. El almacén de confianza predeterminado en JDK típicamente viene con información sobre las CA comunes en uso.

Para solucionar este problema, tendremos que forzar al SimpleClient a confiar en el certificado presentado por el SimpleServer. Utilizaremos el almacén de confianza que generamos anteriormente pasando las siguientes propiedades del sistema al cliente:


-Djavax.net.ssl.trustStore=clienttruststore.jks -Djavax.net.ssl.trustStorePassword=password

Tenga en cuenta que esta no es una solución ideal. En un escenario ideal, no deberíamos usar un certificado autofirmado, sino un certificado que haya sido certificado por una Autoridad Certificadora (CA), en la que los clientes puedan confiar por defecto.



5.3. Certificado del Cliente Faltante

Intentemos una vez más ejecutar el SimpleServer y el SimpleClient, aplicando los cambios de las subsecciones anteriores:


Exception in thread "main" java.net.SocketException: 
  Software caused connection abort: recv failed

De nuevo, no es algo que esperábamos. La SocketException aquí nos dice que el servidor no pudo confiar en el cliente. Esto se debe a que hemos configurado un SSL bidireccional. En nuestro SimpleServer, tenemos:


((SSLServerSocket) listener).setNeedClientAuth(true);

El código anterior indica que se requiere un SSLServerSocket para la autenticación del cliente a través de sus certificados públicos.

Podemos crear un almacén de claves para el cliente y un almacén de confianza correspondiente para el servidor de una manera similar a la utilizada al crear el almacén de claves y el almacén de confianza anteriores.

Reiniciaremos el servidor y le pasaremos las siguientes propiedades del sistema:


-Djavax.net.ssl.keyStore=serverkeystore.jks \
    -Djavax.net.ssl.keyStorePassword=password \
    -Djavax.net.ssl.trustStore=clienttruststore.jks \
    -Djavax.net.ssl.trustStorePassword=password

Luego, reiniciaremos el cliente pasándole estas propiedades del sistema:


-Djavax.net.ssl.keyStore=serverkeystore.jks \
    -Djavax.net.ssl.keyStorePassword=password \
    -Djavax.net.ssl.trustStore=clienttruststore.jks \
    -Djavax.net.ssl.trustStorePassword=password

Finalmente, obtenemos la salida deseada:


Hello World!


5.4. Certificados Incorrectos

Además de los errores anteriores, un apretón de manos puede fallar debido a una variedad de razones relacionadas con cómo hemos creado los certificados. Un error recurrente se relaciona con un CN incorrecto. Exploremos los detalles del almacén de claves del servidor creado anteriormente:


keytool -v -list -keystore serverkeystore.jks

Cuando ejecutamos el comando anterior, podemos ver los detalles del almacén de claves, específicamente el propietario:


...
Owner: CN=localhost, OU=technology, O=baeldung, L=city, ST=state, C=xx
...

El CN del propietario de este certificado está establecido en localhost. El CN del propietario debe coincidir exactamente con el host del servidor. Si hay alguna discrepancia, se resultará en una SSLHandshakeException.

Intentemos regenerar el certificado del servidor con CN diferente a localhost. Al utilizar el certificado regenerado para ejecutar el SimpleServer y SimpleClient, este falla:


Exception in thread "main" javax.net.ssl.SSLHandshakeException: 
    java.security.cert.CertificateException: 
    No name matching localhost found

La traza de excepción anterior indica claramente que el cliente estaba esperando un certificado con el nombre localhost, que no encontró.

Tenga en cuenta que JSSE no exige la verificación del nombre de host de manera predeterminada. Hemos habilitado la verificación del nombre de host en el SimpleClient mediante el uso explícito de HTTPS:


SSLParameters sslParams = new SSLParameters();
sslParams.setEndpointIdentificationAlgorithm("HTTPS");
((SSLSocket) connection).setSSLParameters(sslParams);

La verificación del nombre de host es una causa común de fallo y, en general, debe hacerse de manera consistente para mejorar la seguridad. Para obtener más detalles sobre la verificación del nombre de host y su importancia en la seguridad con TLS, consulte este artículo.



5.5. Versión SSL Incompatible

Actualmente, hay varios protocolos criptográficos, incluidas diferentes versiones de SSL y TLS, en operación.

Como se mencionó anteriormente, SSL, en general, ha sido superado por TLS debido a su fuerza criptográfica. El protocolo y la versión criptográfica son un elemento adicional que un cliente y un servidor deben acordar durante un apretón de manos.

Por ejemplo, si el servidor utiliza un protocolo criptográfico de SSL3 y el cliente utiliza TLS1.3, no pueden acordar un protocolo criptográfico y se generará una SSLHandshakeException.

En nuestro SimpleClient, cambiemos el protocolo a algo que no sea compatible con el protocolo establecido para el servidor:


((SSLSocket) connection).setEnabledProtocols(new String[] { "TLSv1.1" });

Cuando ejecutamos nuestro cliente, obtendremos una SSLHandshakeException:


Exception in thread "main" javax.net.ssl.SSLHandshakeException: 
  No appropriate protocol (protocol is disabled or cipher suites are inappropriate)

La traza de excepción en tales casos es abstracta y no nos dice el problema exacto. Para resolver estos tipos de problemas, es necesario verificar que tanto el cliente como el servidor están usando el mismo protocolo criptográfico o protocolos compatibles.



5.6. Suite de Cifrado Incompatible

El cliente y el servidor también deben acordar la suite de cifrado que utilizarán para cifrar mensajes.

Durante un apretón de manos, el cliente presentará una lista de posibles cifrados a usar, y el servidor responderá con un cifrado seleccionado de la lista. El servidor generará una SSLHandshakeException si no puede seleccionar un cifrado adecuado.

En nuestro SimpleClient, cambiemos la suite de cifrado a algo que no sea compatible con la suite de cifrado utilizada por nuestro servidor:


((SSLSocket) connection).setEnabledCipherSuites(
  new String[] { "TLS_RSA_WITH_AES_128_GCM_SHA256" });

Cuando reiniciamos nuestro cliente, obtendremos una SSLHandshakeException:


Exception in thread "main" javax.net.ssl.SSLHandshakeException: 
  Received fatal alert: handshake_failure

De nuevo, la traza de excepción es muy abstracta y no nos dice el problema exacto. La resolución de este error es verificar las suites de cifrado habilitadas utilizadas por el cliente y el servidor y asegurarse de que haya al menos una suite común disponible.

Normalmente, los clientes y servidores están configurados para usar una amplia variedad de suites de cifrado, por lo que este error es menos probable que ocurra. Si encontramos este error, generalmente es porque el servidor ha sido configurado para usar un cifrado muy selectivo. Un servidor puede elegir hacer cumplir un conjunto selectivo de cifrados por razones de seguridad.



6. Conclusión

En este tutorial, aprendimos sobre la configuración de SSL utilizando sockets de Java. Luego, discutimos los apretones de manos de SSL con SSL unidireccional y bidireccional. Finalmente, revisamos una lista de posibles razones por las que los apretó de manos SSL pueden fallar y discutimos las soluciones.

La clave para manejar errores de apretón de manos SSL es realizar una depuración cuidadosa y considerar todas las configuraciones de claves, certificados y parámetros de seguridad en su implementación. Esto no solo mejora la seguridad de sus aplicaciones, sino que también garantiza que las comunicaciones entre el cliente y el servidor sean seguras y confiables.