¿Sabías que, a pesar de utilizar HTTPS para comunicar tu app con el exterior, aún existe la posibilidad de que un usuario malintencionado intercepte la comunicación? En este post vamos a ver cómo añadir un extra de seguridad para ponerle las cosas más difíciles a ese usuario.

Breve resumen sobre cómo funciona HTTPS

HTTPS (Hypertext Transfer Protocol Secure) es una versión segura de HTTP, el protocolo que se usa para la transmisión de datos en Internet. Su principal función es proporcionar una capa de seguridad adicional mediante el uso de cifrado.

Cuando la aplicación se conecta a un servidor, se lleva a cabo un conjunto de procedimientos definidos en el protocolo:

  1. Conexión inicial. La aplicación se conecta al servidor y solicita su certificado digital.
  2. Verificación del certificado. La aplicación verifica que el certificado es válido y emitido por una CA (Autoridad Certificadora).
  3. Intercambio de claves. Aplicación y servidor crean una clave de sesión segura utilizando criptografía asimétrica, esto es, basada en clave pública y privada.
  4. Comunicación cifrada. Se usa la clave de sesión para cifrar todos los datos intercambiados, asegurando privacidad e integridad.

Sin embargo, ¿qué ocurre si alguien crea un certificado falso que parece válido y, además, dispone de una CA (Autoridad Certificadora) en la que la víctima confía?

Dado este caso, este usuario sería capaz de realizar el llamado “Man-In-The-Middle”, un ataque en el que el usuario se coloca entre el dispositivo de la víctima y el servidor con el que intenta comunicarse, pudiendo no sólo ver la información transmitida, sino también modificarla.

Esto es especialmente importante en aplicaciones bancarias. ¡Imagina que la cantidad o el número de cuenta es alterado cuando el usuario de la aplicación se dispone a realizar una transferencia!

Atacando el problema: SSL Pinning

Mediante el SSL Pinning, las aplicaciones móviles y otros clientes realizan una verificación explícita de certificados o claves públicas. Su nombre se debe al proceso de "anclar" o "fijar" un certificado digital o una clave pública específica dentro de la aplicación. Esto asegura que la aplicación solo acepte conexiones con servidores que presenten el certificado o clave pública correspondiente. Dependiendo del modo de verificación, podemos encontrar dos tipos de SSL Pinning:

Está claro que esta técnica es una buena solución. Sin embargo, hay un inconveniente y es que, los certificados utilizados en HTTPS tienen un tiempo de vida determinado y es necesaria su renovación antes de que llegue su fecha de caducidad. Por esto, es importante controlar cuándo van a caducar los certificados para actualizar la app cuando estos sean renovados y evitar así dejar a los usuarios incomunicados.

SSL Pinning de certificado con URLSession

Ahora que ya hemos hablado de los conceptos generales, vamos a abordar este problema desde la parte iOS. Existen multitud de librerías para realizar peticiones de red (ej. Alamofire) que ya proporcionan métodos para realizar SSL Pinning. Sin embargo, aquí nos vamos a centrar en cómo podemos implementar esta solución con URLSession, que nos permitirá no depender de ninguna librería externa.

Lo primero que necesitamos son los certificados en los que vamos a confiar. Estos certificados los podemos obtener de muchas formas, como por ejemplo, utilizando el comando openssl:

Terminal
openssl s_client -showcerts -connect yourserver.com:443 < /dev/null | openssl x509 -outform PEM > cert.pem

Esto creará un fichero cert.pem, que es el que necesitamos incluir en la aplicación y que usaremos para compararlo con el que nos proporcionará el servidor en el momento que se produzca la conexión.

A pesar de que existen otras herramientas como el comando curl o herramientas de red especializadas, si tienes cualquier duda, la mejor opción es que alguna persona encargada de los servicios web que va utilizar la aplicación sea quien te proporcione estos certificados.

Por otro lado, necesitaremos especificar un delegado para nuestra URLSession e implementar un método de URLSessionDelegate:

class MyNetworkManager {

    lazy var urlSession: URLSession = {
        return URLSession(configuration: .default, 
                          delegate: self, 
                          delegateQueue: .main)
    }()

}

extension MyNetworkManager: URLSessionDelegate {    

    public func urlSession(_session: URLSession, didReceive challenge: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) {

        // Realizar aqui la validacion
    }
}

Este método se usa cuando se hace una llamada de red y comienza la negociación entre app y servidor. En el parámetro challenge encontraremos información sobre dicha negociación, incluyendo el certificado que está proporcionando el servidor. También se incluye un completionHandler, que es donde URLSession espera nuestra decisión sobre la conexión pudiendo aceptarla, cancelarla o dejar que el sistema decida (opción por defecto).

guard let trust = challenge.protectionSpace.serverTrust else {
    // No existe informacion sobre la confianza del servidor
    completionHandler(.cancelAuthenticationChallenge, nil)
    return
}

guard SecTrustGetCertificateCount(trust) > 0 else {
    // El servidor no ha proporcionado ningun certificado
    completionHandler(.cancelAuthenticationChallenge, nil)
    return
}

guard let serverCertificate = SecTrustGetCertificateAtIndex(trust, 0) else {
    // El certificado no existe
    completionHandler(.cancelAuthenticationChallenge, nil)
    return
}

Con esto ya tenemos el certificado que nos ha proporcionado el servidor. Si no se puede conseguir es porque se ha entrado en uno de los guards y debemos indicarle a URLSession (mediante completionHandler) que cancele la conexión con cancelAuthenticationChallenge. Por supuesto, estos guards pueden combinarse en uno sólo pero se han separado para ver mejor su explicación.

Llega el momento de comparar los certificados:

// Nuestro certificado extraido del servidor:

let pinnedCertificateUrl = Bundle.main.url(forResource: “cert”, withExtension: “pem”)!
let pinnedCertificateData = Data(contentsOf: pinnedCertificateUrl)!

// Certificado recibido en la negociacion:

let data = SecCertificateCopyData(serverCertificate) as Data

// Validacion:

if data == pinnedCertificateData {
    completionHandler(.useCredential, URLCredential(trust: trust))
} else {
    completionHandler(.cancelAuthenticationChallenge, nil)
}

Lo primero que haremos es convertir ambos certificados a Data. Con ello, comprobaremos que son exactamente iguales y, si es así, indicaremos a URLSession que puede confiar en el servidor utilizando la información de confianza proporcionada.

Nota: Cuidado con los forzados, asegúrate de que el fichero cert.pem existe o utiliza guards para evitar posibles crashes.

SSL Pinning de clave pública con URLSession

Otra posibilidad es validar la clave pública incluida en el certificado, en este caso no comparamos el certificado íntegramente sino sólo el hash de la clave pública en base64. La principal ventaja es que no es necesario incluir el certificado en la aplicación, ya que se puede tener el hash directamente en el código. Es más seguro porque quedaría ofuscado con el resto del código y ningún usuario podría extraer el certificado de la app.

Al igual que para obtener el certificado, existen multitud de opciones para obtener la clave pública. Por ejemplo, podemos obtener el hash de la clave pública utilizando la herramienta de openssl como en el caso anterior:

Terminal
openssl s_client -servername yourserver.com -connect yourserver.com:443 | openssl x509 -pubkey -noout | openssl pkey -pubin -outform der | openssl dgst -sha256 -binary | openssl enc -base64

Al ejecutar estos comandos, obtendremos algo como:

hUIG87ch71EZQYhZBEkq2VKBLjhussUw7nR8wyuY7rY=

Ahora que ya tenemos el hash, pasamos a realizar la validación de forma parecida a como hemos hecho con el certificado, pero comparando la clave pública en vez de todo el certificado:

import CryptoKit

. . .

var rsa2048Asn1Header: [UInt8] {
    return  [0x30, 0x82, 0x01, 0x22, 0x30, 0x0d, 0x06, 0x09, 0x2a, 0x86, 0x48, 0x86,
             0xf7, 0x0d, 0x01, 0x01, 0x01, 0x05, 0x00, 0x03, 0x82, 0x01, 0x0f, 0x00]
}

. . .

// Nuestra clave publica

let pinnedPubKey = “hUIG87ch71EZQYhZBEkq2VKBLjhussUw7nR8wyuY7rY=”

// Clave publica recibida en la negociacion:

let serverPubKey = SecCertificateCopyKey(serverCertificate)
var serverPubKeyData = Data(rsa2048Asn1Header)
serverPubKeyData.append(SecKeyCopyExternalRepresentation(serverPubKey!, nil)! as Data)
let serverPubKeyBase64 = Data(SHA256.hash(data: serverPubKeyData)).base64EncodedString()

// Validacion

if serverPubKeyBase64 == pinnedPubKey {
    completionHandler(.useCredential, URLCredential(trust: trust))
} else {
    completionHandler(.cancelAuthenticationChallenge, nil)
}

En este código obtenemos la clave pública y la añadimos tras la cabecera ASN1 necesaria en este tipo de claves. Después, calculamos su hash SHA256 y, finalmente, la codificamos a Base64, que es la cadena de texto que comparamos.

En un escenario ideal trabajaremos con multitud de certificados o claves públicas, por lo que es recomendable crear una clase dedicada exclusivamente a realizar estas validaciones y que disponga de un array de certificados o claves públicas a comprobar. La implementación sería la misma, sólo que en vez de comparar los certificados y claves, habría que buscarlos en dicho array.

Conclusiones

Cada vez son más las empresas que requieren este tipo de medidas de seguridad en sus aplicaciones, por lo que es altamente recomendable implementarla. Requiere algo de esfuerzo extra, sobre todo para obtener un listado válido de certificados y claves públicas, y un cierto control sobre la caducidad de estos. Pero, a la larga, evitará posibles problemas de seguridad.

Cuéntanos qué te parece.

Los comentarios serán moderados. Serán visibles si aportan un argumento constructivo. Si no estás de acuerdo con algún punto, por favor, muestra tus opiniones de manera educada.

Suscríbete