Tema 6: Protocolo SSL (Secure Sockets Layer)

6.1. SSL Básico: Funcionamiento y HTTPS

SSL es el protocolo habitualmente usado para encriptar la comunicación cliente-servidor. Casi todo el tráfico en la red puede encriptarse con SSL: POP, IMAP, telnet, FTP, etc, pero es especialmente interesante para dotar de seguridad al protocolo HTTP, es decir como base del HTTPS.

La implementación de SSL es una extensión de los sockets que permite establecer un canal (stream)de comunicación. Dicha comunicación se inicia con un handshake durante el cual, el cliente y el servidor construyen una session-key (clave simétrica encriptada con un esquema asimétrico - Tema 3) compartida para verificar su identidad mútua:

1.Cliente envía un mensaje client_hello que contiene: versiones SSL soportadas por el cliente, 32 bytes aleatorios que crea el cliente, ID de sesión, lista de cifradores soporados, lista de métodos de compresión sopordados.

2. Servidor responde con server_hello: selección de las versiones SSL de la lista del cliente, 32 bytes aleatorios creados por el servidor, ID de sesión, cifradores escogidos de la lista del cliente (p.e. RSA y RC4) y método de compresión usado (usualmente ninguno).

A continuación, el servidor envía su certificado X.509 firmado por una CA y que contiene la clave pública del servidor (así el cliente puede enviarle mensajes cifrados con ella, de manera que solo quien posea la clave privada puede descrifrarlos). Seguidamente el servidor puede pedir un certificado al cliente pero usualmente al cliente se le considera anónimo. Después el servidor envía un server_hello_done y queda a la espera.

3. Cliente verifica el mensaje del servidor y si se le requiere un certificado se lo envía. Seguidamente envía un client_key_exchange con 48 bytes (sacados de los 32 del cliente y de los 32 del servidor) que se usan para construir la session-key que a su vez es encriptada con la clave pública del certificado del servidor.

A continuación, si al cliente se le pidió un certificado, éste envía un certificate_verify. En cualquier caso, después el cliente construye una clave simétrica con RC4 y una clave para el MAC que se usará para comprobar la integridad de los datos. Después envía un change_cipher_spec para indicar los parámetros de cifrado y seguidamente un finished.

4. Servidor responde con su propio change_cipher_spec y su propio finished.

A partir de este punto, la comunicación cliente-servidor será encriptada por clave simétrica, lo cual es transparente para las librerías Java SSL como JSSE (Java Secure Sockets Extension).

6.2.Instalando y Configurando JSSE

JSSE es un API para usar SSL en Java. Se incluye en J2sdk1.4 pero no en las versiones anteriores. Concretamente, para instalarlo en J2sdk1.3 hay que descargarlo de java.sun.com/products/jsse, colocar los ficheros jcert.jar, jnet.jar y jsse.jar en el directorio $JAVA_HOME/jre/lib/ext y modificar el fichero $JAVA_HOME/jre/lib/security/java.security para que contenga la línea:

security.provider.x=com.sun.net.ssl.internal.ssl.Provider

donde x indica el número de proveedor que sigue al último de los instalados.

6.3. Cliente y servidor HTTPS

Para ejecutar un cliente HTTPS que acceda a una determinada URL que se le pasa desde la línea de comandos (ver ClienteHTTPS.java) hay que indicarle al manejador de URLs dónde están las clases HTTPS:

java -Djava.protocol.handler.pkgs=com.sun.net.ssl.internal.www.protocol ClienteHTTPS https://www.ua.es

Altnerativamente el manejador puede configurarse usando el método setProperty():

System.setProperty(
   "java.protocol.handler.pkgs", "com.sun.net.ssl.internal.www.protocol");

En cuanto al servidor (ver ServidorHTTPS.java), lo primero es crear un socket SSL. Para ello utlizamos el método getDefault() de la clase javax.net.ssl.SSLServerSocketFactory para obtener una SSLServerSocketFactory, y luego llamaremos al método createServerSocket de esta clase para crear un ServerSocket:

SSLServerSocketFactory ssf =
      (SSLServerSocketFactory)SSLServerSocketFactory.getDefault();
    ServerSocket ss = ssf.createServerSocket(8080);

y este socket puede usarse como un socket estándard. Así, a partir de aquí no hay ninguna referencia a SSL y el servidor trabaja de forma transparente. Con un accept() espera llamadas desde un cliente, define un InputStream para leer la entrada del cliente y un OutputStream para escribirla en la pantalla. Después construye una página HTML, y finalmente cierra los streams y el socket.

Sin embargo, si intentamos ejecutar este ejemplo con java ServidorHTTPS, se lanza una excepción al detectarse que no se ha creado un certificado y una clave privada para el servidor SSL. Para ello, llamaremos a keytool desde el directorio desde donde ejecutamos el servidor:

keytool -genkey -v -keyalg RSA -keystore .keystore

y ponemos como nombre el nombre de la máquina, p.e. localhost. Así, para lanzar el servidor HTTPS indicaremos a la máquina virtual el almancén que contiene el certificado y el password para acceder a él. Desde línea de comandos esto queda como:

java -Djavax.net.keyStore=.keystore -Djavax.net.ssl.keyStorePassword=scosco ServidorHTTPS

Tras esperar unos segundos para dejar que el servidor se inicialice, pediremos desde el navegador la URL https://localhost:8080 atacando al puerto donde está escuchando el servidor. Sin embargo, dado que el certificado que hemos utilizado es auto-firmado, el navegador no lo reconocerá aunque nos pedirá si queremos aceptarlo. Si decimos que sí, se muestra una página web con el mensaje Hola!.

Para salvar las advertencias del navegador hemos de conseguir un certificado firmado por una CA reconocida, como Verisign.

Cuando un cliente ataca un servidor SSL, el servidor envía un certificado al cliente para verificar que el cliente conoce la identidad del servidor. El cliente entonces valida el certificado y confía en la CA que lo firmó. Como el certificado de nuestro servidor es auto-firmado hemos de decirle al cliente que nos acepte como CA. Desde línea de comando haremos lo siguiente:

java -Djavax.net.ssl.trustStore=.keystore ClienteHTTPS https://localhost:8080/

y el resultado será el código HTML de la página web que sirve el servidor HTTPS.

Hay que tener en cuenta que el trustStore por defecto es el fichero $JRE_HOME/lib/security/cacerts que contiene todos los certificados en los que Java confía, aunque podemos importar otros desde keytool.

6.4. Protegiendo Keystore y Password: com.sun.net.ssl

Pasar el almacén y el password por teclado no es muy conveniente ya que alguien podría estar haciendo un ps y podría descubrirlos. Una forma más segura consiste en definir el password y cargar el keystore desde código. Utilizaremos para ello las clases TrustManagerFactory (en el cliente),

KeyManagerFactory

(en el servidor) y SSLContext (en ambos) de com.sun.net.ssl.

En el cliente utlizamos el password conocido para cargar el keystore que nos interesa:

char[] password = "scosco".toCharArray();
KeyStore keystore = KeyStore.getInstance("JKS");
keystore.load(new FileInputStream(".keystore"), password);

y seguidamente inicializamos una TrustManagerFactory con el keystore:

TrustManagerFactory tmf = TrustManagerFactory.getInstance("SunX509");
tmf.init(keystore);

Y ya podemos crear un SSLContext:

SSLContext contexto = SSLContext.getInstance("TLS");
TrustManager[] trustManagers = tmf.getTrustManagers();
contexto.init(null, trustManagers, null);

Este contexto permite definir una SSLSocketFactory para que podamos implementar el cliente directamente con sockets, esto es, sin basarnos el HTTPS. El código completo se encuentra en ClienteSSLSocketsSun.java.

Por otro lado, el servidor, una vez conocido el password y cargado el keystore, inicializará con éste último una KeyManagerFactory y con ella a su vez un SSLContext:

KeyManagerFactorykmf = KeyManagerFactory.getInstance("SunX509");
kmf.init(keystore, password);

// Creamos un contexto SSL y lo inicializamos con los
// KeyManagers de la KeyManagerFactory
SSLContext contexto = SSLContext.getInstance("TLS");
KeyManager[] keyManagers = kmf.getKeyManagers();
contexto.init(keyManagers, null, null);

// Definimos una SocketFactory para crear los sockets.
SSLServerSocketFactory ssf = contexto.getServerSocketFactory();
ServerSocket ss = ssf.createServerSocket(PORT);

El código completo se encuentra en ServidorSSLSocketsSun.java. Si ejecutamos cada uno de ellos en una ventana podemos ver que lo que escribimos en el cliente aparece en la ventana del servidor.

6.5. Autentificación del cliente

Como hemos visto, en HTTPS solamente se autentifica el servidor presentando un certificado digital, mientras que el cliente se considera anómino. Si queremos que, para más seguridad, también el cliente se autentifique ante el servidor, éste deberá presenstar un certificado digital.

Para forzar que el cliente se autentifique modificaremos el código del servidor ServidorHTTPS.java de manera que incluya las líneas

// Utilizar una SocketFactory para crear sockets SSL:
SSLServerSocketFactory ssf =
  (SSLServerSocketFactory)SSLServerSocketFactory.getDefault();
  SSLServerSocket ss = (SSLServerSocket)ssf.createServerSocket(8080);

// Requerir autentificación del cliente
ss.setNeedClientAuth(true);

Es decir, que utilice sockets de la clase SSLServerSocket para llamar al método setNeedClientAuth() de dicha clase. El resultado es el código ServidorHTTPSAutorizando.java. Así, el cliente ClienteHTTPS.java podrá conectarse a este servidor para que le muestre una página web. Pero antes, la autorización mútua fuerza a que el cliente incorpore en su trustStore un certificado del servidor y éste incorpore en el suyo un certificado del cliente. Veámoslo paso a paso:

1. Keystore del cliente keytool -genkey -v -keyalg RSA -keystore ks_cliente con "CN=sco" y clave "scosco"
2. Keystore del servidor keytool -genkey -v -keyalg RSA -keystore ks_servidor con "CN=localhost" y clave "dcciadccia"
3. Certificado del servidor keytool -export -v -file servidor.cer -keystore ks_servidor
4. Truststore del cliente keytool -import -v -alias certservidor -file servidor.cer -keystore ts_cliente con clave "scosco" reconociendo que confiamos en el certificado
5. Certificado del cliente keytool -export -v -file cliente.cer -keystore ks_cliente
6. Truststore del servidor keytool -import -v -alias certcliente -file cliente.cer -keystore ts_servidor con clave "dcciadccia" respondiendo que confiamos en el certificado

Así, lanzaremos el servidor de la siguiente forma

java -S.keyStore=ks_servidor -S.keyStorePassword=dcciadccia -S.trustStore=ts_servidor ServidorHTTPSAutorizando

donde hay que sustituir S por javax.net.ssl al escribir el comando. De la misma manera, en otra ventana lanzaremos el cliente:

java -S.keyStore=ks_cliente -S.keyStorePassword=scosco -S.trustStore=ts_cliente ClienteHTTPS https://localhost:8080

donde de nuevo hay que sustituir S por su valor al escribir el comando. El resultado es que el cliente puede acceder a la pagina web.

Siguiendo con el esquema otra posibilidad es que autentifiquemos al cliente no en base a un certificado completo sino a ciertos datos contenidos en el certificado, como por ejemplo el CN del mismo. Para ello, dentro del servidor hemos de incluir, tras el accept(), el siguiente fragmento de codigo

boolean permitido = false;

// Obtener la sesion
SSLSession sesion = ((SSLSocket) s).getSession();

// Comprobar la autentificación del cliente con el AutentificadorCN
AutentificadorCN autentificador = new AutentificadorCN(sesion);
try {
  autentificador.comprobarPermiso();
  permitido = true;
} catch (ExcepcionAutentificacion ea) {
  permitido = false;
  System.out.println("Acceso denegado." + ea);
}

resultando el código ServidorHTTPSAutorizando2.java. En definitiva, lo que hace el servidor es acceder a la SSLSession para obtener la sesión del socket y usarla para crear un objeto de la clase AutentificadorCN que implementa la inferfaz AutentificadorSSL. El único método de dicha interfaz es comprobarPermiso() (ver AutentificadorCN.java) y básicamente hace lo siguiente:

// 1. Obtener la secuencia de certificados.
X509Certificate[] certChain = null;
try {
   certChain = mSession.getPeerCertificateChain();
} catch (SSLPeerUnverifiedException spue) {
   // No hay!
   throw new ExcepcionAutentificacion("Error cadena");
}
// 2. Obtener el certificado del cliente
X509Certificate clientCert = certChain[0];
// 3. Obtener el principal correspondiente al cliente
java.security.Principal client = clientCert.getSubjectDN();
// 4. Obtener el nombre del cliente
String name = client.getName();
// 5. Comprobar que CN=sco
if (name.indexOf("CN=sco,")!=0) {
  throw new ExcepcionAutentificacion("Cliente no es sco");
}

Es decir, en caso de que el CN no coincida con "sco" se lanza una ExcepcionAutentificacion que causará que se deniege el acceso al cliente. Para probar este esquema podemos utilizar los almancenes creados para el ejemplo anterior ya que en el keystore del cliente los certificados tenian CN=sco.Si volvemos a ejecutar el ejemplo anterior pero con este nuevo servidor el resultado será o bien acceder correctamente a una página web que nos da la bienvenida o a una página web con un mensaje de rechazo, lo que ocurrirá si utilizamos un certificado con otro CN aunque todo lo demás sea correcto.