Cifrado Asimétrico con OpenSSL
RATAS DE BIBLIOTECA
Cifrado Asimétrico con OpenSSL
2024-07-03
Por
Don Bit0

En este número nos vamos a adentrar en el fascinante mundo del cifrado asimétrico de la mano de OpenSSL. Un viaje alucinante en el que nos sumergiremos en la tecnología utilizada para implementar la mayoría de sistemas de seguridad actuales….. Vam’o allá.

Por si acaso has llegado aquí sin haber leído esto o esto, los sistemas de cifrado asimétrico son aquellos que utilizan un par de claves complementarias para cifrar y descifrar mensajes. Si un mensaje es cifrado con una de las claves del par, se puede descifrar con la otra y viceversa. Esta tecnología es la que permite que accedamos de forma segura a nuestro banco o que cierta información se pueda firmar digitalmente.

RSA

Si bien hay distintos algoritmos de cifrado asimétrico, en este artículo vamos a utilizar RSA ya que es el más popular y uno de los más utilizados. RSA toma su nombre de los apellidos de sus creadores: Rivest, Shamir y Adleman. Matemáticamente se basa en generar números muy grandes… pero eso lo vamos a dejar de lado en este artículo ya que no es algo que no es estrictamente necesario para poder utilizar este tipo de algoritmo.

El sistema criptográfico RSA utiliza dos claves, como ya os avanzamos, que se suelen conocer como clave pública y clave privada, precisamente porque una de ellas se espera que la conozca todo el mundo, mientras que las otra es fundamental que la mantengamos secreta. Usando estas dos claves podemos hacer varias cosas:

  • Cifrando un mensaje con la clave pública de alguien (esa que conoce todo el mundo), así conseguiremos que sólo la persona con la correspondiente clave privada pueda descifrar el mensaje
  • Si ciframos un mensaje con nuestra clave privada… cualquiera puede descifrarlo (usando nuestra clave pública), pero en este caso, los receptores del mensaje sabrán seguro que el mensaje es nuestro, ya que solo nosotros conocemos nuestra clave privada (esto se usa en los sistemas de firma digital).

Así que como podéis ver, disponer de estas claves es el primer paso que necesitamos para poder jugar con este tipo de sistema criptográfico.

Generando claves RSA con openssl

La forma más sencilla de generar nuestro par de claves es utilizando la utilidad de línea de comandos que ofrece OpenSSL y que lleva su mismo nombre. En seguida veremos como hacer lo mismo en nuestros propios programas, pero saber utilizar la utilidad openssl nos va a permitir comprobar que nuestro código está generando la información correctamente.

Para generar un par de claves RSA podemos utilizar el siguiente comando:

$ openssl genrsa -out claves.pem 2048

Esto va a generar nuestro par de claves en el fichero claves.pem. En este caso hemos utilizado una longitud de clave de 2048 que se considera segura. Si sois unos paranoicos podéis aumentar la longitud de clave a 4096.

Este comando va a generar un fichero talque así:

-----BEGIN PRIVATE KEY-----
MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDVB0KQAy/3aj97
(.... mogollón de letras raras )
-----END PRIVATE KEY-----

Este formato se conoce como PEM que es el acrónimo de Privacy_Enhanced Mail, el cual es el estándar más popular para almacenar claves y certificados. Básicamente almacena los datos codificados en Base64 indicando el principio y el fin de los datos con las líneas -----BEGIN y -----END seguidas de una etiqueta como por ejemplo: PRIVATE KEY, CERTIFICATE, X509 CRL

Y sí, como su propio nombre indica el formato fue definido inicialmente para mejorar la privacidad de los correos electrónicos.

Generando Claves desde nuestro Programa

En ocasiones nos va a interesar generar claves en nuestros propios programas. Hay muchos casos en los que esto es útil, pero a modo de ejemplo, esto es algo que hacen los programas ransomware, que generan un par de claves aleatorias para cifrar los datos de su víctima. Realmente no se cifran con esa clave directamente, pero eso lo veremos un poco más adelante. En este artículo os contamos como se usan en sistemas de mensajería por ejemplo.

El programa para generar las claves utiliza la familia de funciones EVP de OpenSSL que ya hemos visto en entregas anteriores y que simplifica nuestra vida mogollón:

#include <openssl/evp.h>
#include <openssl/rsa.h>
#include <openssl/pem.h>

int main () {
  EVP_PKEY_CTX *ctx;
  EVP_PKEY     *pkey;

  ctx = EVP_PKEY_CTX_new_id (EVP_PKEY_RSA, NULL);
  
  EVP_PKEY_keygen_init (ctx);
  EVP_PKEY_CTX_set_rsa_keygen_bits (ctx, 2048);
  EVP_PKEY_keygen (ctx, &pkey);
  
  FILE *f = fopen ("mis_claves.pem", "wb");
  PEM_write_PrivateKey (f, pkey, NULL, NULL, 0, NULL, NULL);
  fclose (f);

OpenSSL mantienen los pares de claves usados por los algoritmos asimétricos en una estructura llamada EVP_PKEY y nos ofrece varias funciones para trabajar con ellas… como veremos a continuación.

Como siempre con OpenSSL, lo primero que tenemos que hacer es crear un contexto para generar nuestra clave. Luego inicializamos el generador de claves, configuramos los valores que queramos (en este caso la longitud de la clave RSA) y generamos la clave. Las primeras 4 líneas de código generan la clave en memoria.

Finalmente, la almacenamos en un fichero en formato PEM, usando la función PEM_write_PrivateKey. Esta función recibe varios parámetros y enseguida os contamos como usarla. Este es su prototipo:

int PEM_write_PrivateKey(FILE *fp, EVP_PKEY *x, const EVP_CIPHER *enc,
                     unsigned char *kstr, int klen,
                     pem_password_cb *cb, void *u);

Los últimos 4 parámetros se utilizan en el caso que queramos proteger la clave con una clave :) y se interpretan de la siguiente forma:

  • Si kstr no es NULL entonces klen caracteres de kstr se utilizarán como clave para cifrar el par de claves que hemos generado usando el cifrado EVP_CIPHER (por ejemplo AES-256).
  • Sino, si cb no es NULL y apunta a una función (un callback), se ejecutará esa función, la cual normalmente, preguntará al usuario por una contraseña. En ese caso, el puntero u se usa para pasar parámetros adicionales a la función.

El callback cb tiene el siguiente prototipo:

int cb(char *buf, int size, int rwflag, void *u);

donde: * buf es el buffer donde se escribirá la contraseña * size es el tamnaño máximo del buffer * rwflag indica si estamos leyendo o escribiendo en el fichero * u es el mismo parámetro que pasamos a PEM_write_PrivateKey

En nuestro ejemplo,para mantener el código sencillo, almacenamos nuestras claves RSA sin cifrar. Si queréis hacer pruebas con eso, podéis generar claves protegidas con contraseña con el siguiente comando:

$ openssl genrsa -aes256 2048
Enter PEM pass phrase:
Verifying - Enter PEM pass phrase:

Esto genera un fichero PEM, pero con las siguientes cabeceras:

-----BEGIN ENCRYPTED PRIVATE KEY-----
MIIFLTBXBgkqhkiG9w0BBQ0wSjApBgkqhkiG9w0BBQwwHAQIZxFPDRoCW6cCAggA
(... letras raras)
-----END ENCRYPTED PRIVATE KEY-----

Podéis obtener la clave en plano usando

$ openssl rsa -in CLAVE_CIFRADA.pem
$ openssl rsa -in CLAVE_CIFRADA.pem -passin pass:TU_CLAVE

Donde está mi clave pública?

Esa es una muy buena pregunta. La verdad es que cuando generamos la clave estamos generando el par de claves que necesitamos y en general, el fichero PEM que contiene la clave privada también contiene la clave publica. Sin embargo, nos interesa almacenar la clave pública de forma separada para así, por ejemplo, poder compartirla.

En la línea de comandos lo podemos hacer muy fácilmente usando el siguiente comando:

$ openssl rsa -in clave.pem -pubout clave_publica.pem

Para hacer que nuestro programa genere también un fichero para nuestra clave pública solo tenemos que añadir la siguientes líneas al final:

  f = fopen ("example_public.pem", "w");
  PEM_write_PUBKEY (f, pkey);
  fclose (f);

Ahora que ya sabemos como generar claves… veamos como leerlas en nuestros programas.

Nuestro programa de ejemplo

De la misma forma que hicimos con el artículo sobre cifrado simétrico, vamos a escribir un sencillo programa de ejemplo que implementa las principales funciones que podríamos necesitar en nuestros programas, exceptuando la generación de claves que ya hemos discutido. A saber:

  • Leer claves de fichero almacenados en el disco
  • Cifrar datos usando la clave pública de un usuario
  • Descifrar un mensaje utilizando nuestra clave privada.

Así que vamos a ello!

Leer claves de disco

Lo primero que debemos hacer es leer las claves que normalmente estarán almacenadas en ficheros en el disco o en memoria. Como para nuestro ejemplo tenemos que leer una clave pública y una privada, vamos a almacenar la privada en un fichero y la pública como una cadena de caracteres en nuestro código para así demostrar como leer datos en ambos casos.

La clave pública la podemos almacenar como una cadena de caracteres. Algo como esto:

const unsigned char *clave_pub_str =
  "-----BEGIN PUBLIC KEY-----\n"
  "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAu49Qdny7xh2PbeV/oTYr\n"
  "Q9MmkDfKvf/Fgq7DFfAnYuDiYljM/FNCjKRRQLn4YSyINWuXpDAO9AG7jBfVvm2K\n"
  "5Pchkj0HA/Gau9eSFUSoR0LWCrAlAZw8ovq8urQHsFKtd5kkjGsEfnp9zHfvV3NB\n"
  "gO5WzJhhDTnCza5B7AhVKXjS0YeGzKonGhTW6OJTEAnE+oyZp7acdOKy9knXs/Uk\n"
  "2PfRI+P4i/XR09LbQFb06y66MN+CLkm3TVU+Olw6wuSUCj/0rM3CrbWtAbDff9lp\n"
  "vlnHI+A3kOHydm71L1bLDs6fY8kASbA+1OITcmCXPXLLe6omSfRPYkstCU/CoPfb\n"
  "7QIDAQAB\n"
  "-----END PUBLIC KEY-----\n";

Suponiendo que la clave privada está en un fichero llamado clave.pem, nuestro código quedaría de la siguiente forma:

  OSSL_LIB_CTX     *libctx = NULL;
  OSSL_DECODER_CTX *dctx = NULL;
  EVP_PKEY_CTX     *pkctx = NULL;
  EVP_PKEY         *pub_key = NULL;
  EVP_PKEY         *priv_key = NULL;
  
  libctx = NULL; /* Usa el contexto de librería por defecto */
  /* Configuramos decodificación clave privada */
  dctx = OSSL_DECODER_CTX_new_for_pkey (&priv_key, "PEM", NULL, "RSA",
                    EVP_PKEY_KEYPAIR,libctx, NULL);

  /* Lee clave publica usuario 2*/
  FILE *f = fopen ("clave.pem", "rb");
  OSSL_DECODER_from_fp (dctx, f);
  fclose (f);
  OSSL_DECODER_CTX_free(dctx);
  
  /* Configuramos decodificación de la clave pública*/
  dctx = OSSL_DECODER_CTX_new_for_pkey (&pub_key, "PEM", NULL, "RSA",
                    EVP_PKEY_PUBLIC_KEY,libctx, NULL);
  const unsigned char *data = clave_pub_str;
  size_t         data_len = strlen (clave_pub_str);;
  OSSL_DECODER_from_data (dctx, &data, &data_len);
  OSSL_DECODER_CTX_free(dctx);

Aunque estamos seguros de que sois capaces de entender todo el código directamente vamos a explicar un par de cosas que puede que no sean tan obvias. La primera es el uso de OSSL_LIB_CTX este es como el contexto de más alto nivel en el que se define como queremos usar OpenSSL. Salvo casos muy especiales será siempre NULL, lo que significa que usaremos el contexto por defecto que nos irá de perlas en la mayoría de los casos.

Como podéis ver la función para crear el contexto del decodificador de claves espera como parámetro ese valor. Podríamos haber pasado NULL directamente, pero de esta forma veis como usar este contexto de librería.

Para leer las claves estamos usando un objeto OpenSSL llamado decodificador (OSSL_DECODER), esto nos permite procesar fácilmente las claves en distintos formatos. Como podéis ver, en ambos casos le estamos diciendo que el buffer que contiene la clave estará codificado usando formato PEM y contiene una clave RSA. Es posible poner todos estos parámetros a NULL y la librería intentará averiguar los valores correctos.

Observad que para la clave privada, le decimos al objeto decodificador que esperamos EVP_PKEY_KEYPAIR mientras que para la clave pública esperamos EVP_PKEY_PUBLIC_KEY. Lo que esto significa, a parte de almacenar en la variable que pasamos como primer parámetro una u otra clave, es que el fichero/buffer que pasamos en el primer caso tiene que contener una clave privada o de lo contrario se producirá un error. En el segundo caso podemos pasar una clave privada o una pública y el OSSL_DECODER obtendrá el valor correcto.

Recordad que la clave privada es realmente el par de claves y siempre podemos deducir la clave publica a partir de la clave privada.

NOTA

Explicado así a lo bestia, el algoritmo genera un número muy grande que se puede factorizar como el producto de dos números primos muy grandes: KP = PUB * PRIV, de forma que si conocemos dos de esos valores siempre podemos calcular el otro fácilmente. Al ser PUB y PRIV primos, esa es la única factorización posible de KP. Esto es una simplificación, pero entender como podemos derivar una de las claves a partir de la otra.

Una vez preparados nuestros contexto solo tenemos que utilizar la función que nos vaya mejor. En el primer caso usamos OSS_DECODER_from_fp que nos permite leer los datos fácilmente desde un fichero, y en el segundo caso usamos OSS_DECODER_from_data que nos permite leer los datos de memoria.

Notas sobre formatos

Hasta ahora hemos estado usando el formato PEM para todos nuestros ejemplo, ya que es el más común, sin embargo, podemos codificar nuestras claves usando diferentes formatos y, como habéis visto, OSSL_DECODER nos va a permitir obtener los valores que necesitamos a partir de ellos. Vamos a perder unos minutos en entender estos formatos.

El formato PEM ya lo describimos anteriormente. No es más que el valor de nuestra clave codificado en base64 con marcas inicial y final.

Existe otro formato bastante popular, utilizado para intercambiar las claves, llamado DER (Distinguised Encoding Rules reglas de cifrado distinguidas/diferenciadas). Este formato es una versión reducida de BER (Basic Encoding rules reglas de cifrado básico) y existe un tercer formato derivado de BER llamado CER (Canonical Encoding Rules Reglas de cifrado canónico).

Todas estas formas de codificación están relacionadas con el nivel de presentación del modelo de referencia OSI… toma yá. El nivel de presentación, o capa 6, es en la que se describe como representar información de forma que pueda ser intercambiada por cualquier tipo de máquina. Una de las cosas que se utiliza para ello es el lenguaje de definición de datos ASN.1 (Abstract Syntax Notation o Notación de Sintaxis Abstracta).

ASN.1 es un lenguaje parecido a Pascal con el que definir estructuras de datos y sus instancias asociadas. Junto al lenguaje es necesario especificar una forma de convertir datos asociados a las estructuras ASN.1, en algo que podamos intercambiar entre máquinas. Por ejemplo, si nuestra estructura de datos define un entero, tenemos que definir cuantos bits va a tener ese entero y en que orden se van a enviar, de forma que máquinas con distinta Endianess u otros tamaños de palabra puedan leer el dato que queremos.

En aplicaciones sencillas, acabarás utilizando JSON o XML para este propósito, sin embargo gran parte de la infraestructura de seguridad de red se definió sobre conceptos OSI y es por ello que la manera multiplataforma de intercambiar información es utilizando ASN.1. Hay que decir, que la representación binaria que consigues con ASN.1 es mucho más eficiente que JSON o XML además de otra ventajas.

JSON o XML son otras formas de solucionar el problema de serialización o marshalling. El problema consiste en tomar una representación en memoria de datos, normalmente en formato binario dependiente de la máquina que los almacena, y convertirlos en otra representación que otras máquinas diferentes puedan interpretar para generar los mismos datos en memoria pero usando su representación interna. JSON o XML consiguen esto convirtiendo los datos en cadenas de texto. Es una de las formas más portables de hacerlo, pero también la más ineficiente. Un valor entero de 32bits requiere 4 bytes para poder almacenarlo en forma binaria. Ese valor Como texto hexadecimal (0xffffffff) requeriría 8 bytes (2 bytes por dígito hexadecimal), y como una representación decimal podría requerir hasta 10 caracteres (máximo valor con signo es 2147483647).

Volviendo a nuestra cuestión de las claves. DER es una representación binaria de una estructura ASN.1. Esto lo puedes ver con el siguiente comando:

$ openssl asn1parse -in clave.pem
    0:d=0  hl=4 l=1215 cons: SEQUENCE
    4:d=1  hl=2 l=   1 prim: INTEGER           :00
    7:d=1  hl=2 l=  13 cons: SEQUENCE
    9:d=2  hl=2 l=   9 prim: OBJECT            :rsaEncryption
   20:d=2  hl=2 l=   0 prim: NULL
   22:d=1  hl=4 l=1193 prim: OCTET STRING      [HEX DUMP]:308204A...

Estos son los campos que contiene nuestra clave. Así que resumiendo:

  • Un par de claves pública/privada es un conjunto de datos organizados en una estructura ASN.1
  • Esos datos se pueden codificar en distintas representaciones binarias ASN.1 como DER, BER o CER
  • Si representamos la clave con DER, la codificamos en base64 y le añadimos una cabecera y un pie … tenemos una clave PEM

Y no vamos a hablar de certificados por el momento … Ya la hemos liado bastante. Aunque esperamos que esté un poco más claro ahora de donde vienen todos esos valores.

Podéis probar a decodificar los datos de una clave PEM con el programa base64 y comparar el resultado con lo que obtenéis al convertir la clave usando

openssl rsa -in clave.pem -outform DER -out clave.der

Cifrando datos

Como veremos en un segundo, lo más complicado de los sistema asimétricos es el manejo de las claves. Lo que sigue os resultará muy familiar y sencillo, si bien nos toparemos de frente con algunas de las limitaciones de estos sistemas.

Veamos el código para cifrar un mensaje usando RSA. Lo primero que haremos será declarar e inicializar nuestro buffer de entrada y de salida.

  /* Cifrar mensaje para usuario dos. Usamos clave publica usuario 2*/
  char    in[1024];
  char    out[1024];
  size_t  in_len, out_len, len;
  
  memset (in,0,1024);
  strcpy (in, "Hola Occam's Razor. Estoy cifrando asimétricamente!\n");
  in_len = strlen (in);
  
  memset (out, 0, 1024);
  out_len= in_len;

Ahora crearemos un contexto PKEY (Pair KEY) que nos permitirá cifrar nuestro buffer de entrada. Al crearlo le pasamos la clave que queremos utilizar para el cifrado, en este caso la clave pública del usuario para el que estamos cifrando el mensaje. Una vez que el contexto está definido solo tenemos que llamar a EVP_PKEY_encrypt_init y EVP_PKEY_encrypt para cifrar nuestro mensaje. Algo como esto:

  pkctx = EVP_PKEY_CTX_new (pub_key, NULL);
  if ((EVP_PKEY_encrypt_init (pkctx)) <=0) 
       ERR_print_errors_fp (stderr);
  // Calcula tamaño necesario para el buffer de salida
  if (EVP_PKEY_encrypt (pkctx, NULL, &out_len, in, in_len) <=0) 
      ERR_print_errors_fp (stderr);
  
  printf ("+ Mensaje original: %d bytes. Mensaje salida: %d bytes\n",
      in_len, out_len);
      
  // Ahora ciframos de verdad
  if (EVP_PKEY_encrypt (pkctx, out, &out_len, in, in_len)<=0) 
      ERR_print_errors_fp (stderr);
  BIO_dump_indent_fp (stdout, out, out_len, 2);
  printf ("\n");

  EVP_PKEY_CTX_free (pkctx);

Como podéis ver hacemos dos llamadas a EVP_PKEY_encrypt. En la primera el buffer de salida es NULL. En este caso, la función almacenará en out_len el tamaño de buffer que necesitaríamos para almacenar el mensaje cifrado de forma que lo podremos reservar dinámicamente si fuera necesario.

Para el caso de RSA con una clave de 2048 bits, el tamaño del mensaje cifrado es siempre de 256 bytes (2048 bits / 8 = 256 bytes). A parte de eso, y que no es necesario llamar a una función LOQUESEA_final, el proceso es similar al que usamos para cifrar mensajes utilizando un sistema criptográfico simétrico.

Descifrado del mensaje

El descifrado se realiza de la misma forma, pero sustituyendo las funciones EVP_PKEY_encrypt* por EVP_PKEY_decrypt*. AL igual que en el cifrado, podemos llamar a EVP_PKEY_decrypt con un buffer de salida NULL para dejar que la función calcule el tamaño del buffer que necesitamos.

Con todo esto, el código para descifrar un mensaje cifrado con RSA sería algo como esto:

  // Borra buffer in  
  memset (in, 0, 1024);
  in_len = out_len;

  // Ahora usamos la clave privada
  if (pkctx = EVP_PKEY_CTX_new_from_pkey (libctx, priv_key, NULL)) 
              ERR_print_errors_fp (stderr);
  if (EVP_PKEY_decrypt_init (pkctx) <= 0) 
              ERR_print_errors_fp (stderr);
  
  // Calcula tamaño necesario para el buffer de salida
  if ((EVP_PKEY_decrypt (pkctx, NULL, &in_len, out, out_len) <= 0)) 
      ERR_print_errors_fp (stderr);
  printf ("+ Mensaje original: %d bytes. Mensaje salida: %d bytes\n",
      out_len, in_len);

  // Ahora encripta de verdad
  if ((EVP_PKEY_decrypt (pkctx, in, &in_len, out, out_len)) <= 0) {
    ERR_print_errors_fp (stderr);
  }
  printf ("%d bytes out (%d)\n", in_len, out_len);
  BIO_dump_indent_fp (stdout, in, in_len, 2);
  printf ("\n");

  EVP_PKEY_CTX_free (pkctx);

Sin grandes novedades, el único comentario que podemos hacer es el uso de EVP_PKEY_CTX_new_from_pkey, la cual nos permite pasar un parámetro adicionales con propiedades de consulta. Este parámetro nos permite definir con más detalle como queremos que funcione, pero por el momento usar esas opciones simplemente va a añadir complejidad a la explicación y confundiros aún más de lo que probablemente ya estéis. ;)

CONCLUSIONES

En este artículo hemos visto como utilizar OpenSSL para trabajar con sistemas de cifrado asimétrico y, en el camino, hemos explorado el mundo de la generación y los formatos de claves. OpenSSL ofrece interfaces más específicos y multitud de opciones para controlar su funcionamiento. Ahora que conocemos lo básico estamos listos para que empiece lo bueno!.


SOBRE Don Bit0
No os podemos contar mucho sobre Don Bit0. Es un tipo misterioso que de vez en cuando colabora con nosotros y luego, simplemente se desvanece. Como os podéis imaginar por su nick, Don Bit0, está cómodo en el bajo nivel, entre bits, y cerquita del HW que lo mantiene calentito.

 
Tu publicidad aquí :)