Historia de un único protocolo de enlace SSL

Hola Habr!

Recientemente, tuve que atornillar SSL con autenticación mutua al cliente web Spring Reactive. Parece un asunto simple, pero resultó en un vagabundeo en las fuentes de JDK con un final inesperado. Se adquirió experiencia para un artículo completo, que puede ser útil para los ingenieros en tareas cotidianas o en preparación para una entrevista.


Declaración del problema.


Hay un servicio REST en el lado del cliente que funciona a través de HTTPS.
Debe acceder desde una aplicación Java cliente.

Lo primero que me dieron en esta búsqueda fueron 2 archivos con la extensión .pem: un certificado de cliente y una clave privada. Verifiqué su rendimiento usando Postman: especifiqué las rutas a ellos en la configuración y, después de extraer una solicitud, me aseguré de que el servidor responde con 200 OK y un cuerpo de respuesta significativo. Por separado, verifiqué que sin un certificado de cliente, el servidor devuelve un estado HTTP de 500 y un mensaje corto en el cuerpo de la respuesta de que se produjo una "excepción de seguridad" con un código específico.

A continuación, debe configurar correctamente la aplicación Java del cliente.

Para las solicitudes REST, utilicé Spring Reactive WebClient con E / S sin bloqueo.
La documentación tiene un ejemplo de cómo se puede personalizar arrojándole un objeto SslContext, que solo almacena certificados y claves privadas.

Mi configuración en una versión simplificada fue casi copiar y pegar de la documentación:

SslContext sslContext = SslContextBuilder .forClient() .keyManager(…) /*  ,   .pem      (, , ). PEM      . /    openssl .     KeyManagerFactory. */ .build(); ClientHttpConnector connector = new ReactorClientHttpConnector( builder -> builder.sslContext(sslContext)); WebClient webClient = WebClient.builder() .clientConnector(connector).build(); 

Siguiendo el principio de TDD, también escribí una prueba que usa WebTestClient en lugar de WebClient, que muestra un montón de información de depuración. La primera afirmación fue así:

 webTestClient .post() .uri([  ]) .body(BodyInserters.fromObject([ ,   ,  ])) .exchange() .expectStatus().isOk() 

Esta simple prueba no pasó inmediatamente: el servidor devolvió 500 con el mismo cuerpo que si Postman no especificara un certificado de cliente.

Por separado, observo que durante la depuración, activé la opción "no verificar el certificado del servidor", es decir, pasé la instancia de InsecureTrustManagerFactory como TrustManager para SslContext. Esta medida era redundante, pero excluía la mitad de las opciones con seguridad.

La información de depuración en la prueba no arrojó luz sobre el problema, pero parecía que algo salió mal en la etapa de protocolo de enlace SSL, por lo que decidí comparar con más detalle cómo se produce la conexión en ambos casos: para Postman y para el cliente Java. Todo esto se puede ver usando Wireshark: este es un analizador de tráfico de red tan popular. Al mismo tiempo, vi cómo ocurre el protocolo de enlace SSL con la autenticación bidireccional, por así decirlo, en vivo (les gusta preguntar esto durante las entrevistas):

  • En primer lugar, el cliente envía un mensaje de saludo de cliente que contiene metainformación como la versión del protocolo y la lista de algoritmos de cifrado que admite
  • En respuesta, el servidor envía inmediatamente un paquete de los siguientes mensajes: Server Hello, Certificate, Server Key Exchange, Request Request, Server Hello Done Done .
    Server Hello indica el algoritmo de cifrado seleccionado por el servidor (conjunto de cifrado). Dentro del Certificado se encuentra el certificado del servidor. Server Key Exchange lleva cierta información necesaria para el cifrado, dependiendo del algoritmo elegido (no estamos interesados ​​en los detalles ahora, por lo que asumimos que esta es solo una clave pública, ¡aunque es incorrecta!). Además, en el caso de la autenticación bidireccional, en el mensaje Solicitud de certificado , el servidor solicita un certificado de cliente y explica qué formatos admite y en qué emisores confía.
  • Una vez recibida esta información, el cliente verifica el certificado del servidor y envía su certificado, su "clave pública" y otra información en los siguientes mensajes: Certificado, Intercambio de claves del cliente, Verificación del certificado . El último es el mensaje ChangeCipherSpec , que indica que todas las comunicaciones posteriores se realizarán en forma cifrada.
  • Finalmente, después de todos estos cambios, el servidor verificará el certificado del cliente y, si todo está bien, dará una respuesta.

Después de quince minutos de permanecer en el tráfico, noté que el cliente Java, en respuesta a la Solicitud de certificado del servidor, por alguna razón no envía su certificado, a diferencia del cliente Postman. Es decir, hay un mensaje de Certificado, pero está vacío.

A continuación, primero tendría que mirar la especificación del protocolo TLS , que dice literalmente lo siguiente:
Si la lista de certificados_autoridades en el mensaje de solicitud de certificado no está vacía, una de las CA enumeradas DEBE emitir uno de los certificados de la cadena de certificados.
Estamos hablando de la lista de certificados_autoridades especificada en el mensaje de Solicitud de Certificado proveniente del servidor. Uno de los emisores enumerados en esta lista debe firmar un certificado de cliente (al menos una de las cadenas). A esto lo llamamos el cheque X.

No conocía esta condición y la encontré cuando llegué a las profundidades del JDK en la depuración (lo tengo JDK9). HttpClient de Netty, que subyace a Spring WebClient, usa SslEngine del JDK de forma predeterminada. Alternativamente, puede cambiarlo al proveedor OpenSSL agregando las dependencias necesarias, pero finalmente no lo necesitaba.
Entonces, establecí puntos de interrupción dentro de la clase sun.security.ssl.ClientHandshaker y en el controlador de mensajes serverHelloDone encontré una verificación X que no pasó: ninguno de los emisores en la cadena de certificados del cliente estaba en la lista de emisores en los que el servidor confía ( del mensaje de solicitud de certificado del servidor).

Me dirigí al cliente para obtener un nuevo certificado, pero el cliente objetó que todo funcionaba bien para él y le entregó a Python un script, con el que generalmente verificaba la funcionalidad de los certificados. El script no hizo más que enviar una solicitud HTTPS utilizando la biblioteca de solicitudes, y devolvió 200 OK. Finalmente me sorprendí cuando el viejo rizo también devolvió 200 OK. Inmediatamente me acordé de la broma: "Toda la compañía se sale del paso, un teniente da un paso en la pierna".

Curl es, por supuesto, una utilidad de buena reputación, pero el estándar TLS tampoco es un pedazo de papel higiénico. Sin saber qué más comprobar, subí sin rumbo deambulando por la documentación del rizo y en Github, donde encontré un error tan famoso.

El reportero describió exactamente la prueba X: en curl con el backend predeterminado (OpenSSL), no se ejecutó, a diferencia de curl con el backend GnuTLS. No era demasiado vago, recogí el rizo de la fuente con la opción --with-gnutls , y envié una solicitud sufrida. Y, finalmente, otro cliente, además del JDK, devolvió el estado HTTP 500 junto con la "excepción de seguridad".

Escribí sobre esto al cliente en respuesta al argumento "Bueno, curl funciona", y recibí un nuevo certificado, regenerado y perfectamente instalado en el servidor. Con él, mi configuración para WebClient funcionó bien. Final feliz

La epopeya SSL tardó más de dos semanas con toda la correspondencia (incluyó el estudio de registros detallados de Java, la elección del código de otro proyecto que funciona para el cliente y solo la atención).

Lo que me desconcertó durante mucho tiempo, además de la diferencia en el comportamiento del cliente, fue que el servidor se configuró de tal manera que el certificado lo solicitó, pero no lo verificó. Sin embargo, hay explicaciones para esto en la especificación:
Además, si algún aspecto de la cadena de certificados era inaceptable (por ejemplo, no estaba firmado por una CA reconocida y confiable), el servidor PUEDE a su discreción continuar con el apretón de manos (considerando que el cliente no está autenticado) o enviar una alerta fatal.

Source: https://habr.com/ru/post/es414909/


All Articles