Cada vez es mas común que la mayoría de sitios web te pidan activar la verificación de dos factores mediante el uso de claves OTP (Contraseñas de un solo uso).
Su uso es realmente simple: el servicio en donde quieres activar dicha característica te proporciona una clave alfanumérica, ya sea en texto plano o mediante un código QR, y tu la introduces en un software que te sirva como llavero de ese tipo de claves (Authy, 1Password, Aegis, Vualtwarden, etc...), y listo. Cada que quieres iniciar sesión en la web, a parte del usuario y la contraseña, también deberás introducir el código de 6 dígitos que te genera en tiempo real tu llavero.
El algoritmo detrás de TOTP es bastante simple ya que solo necesita 2 datos para generarte la clave de 6 dígitos y es:
- Una llave alfanumérica
- La fecha y hora actual en UTC
Teniendo esos 2 datos realmente podrías calcular en un papel tu contraseña de un solo uso (aunque para cuando termines, ya habrán pasado 30 segundos y ya no funcionaría jeje).
Voy a dividir el proceso en varios pasos para que se pueda entender mejor.
Preprocesar los datos
Primero que nada la fecha debemos pasarla de un formato como 12-Dic-2023 14:04:03
a su Unix Epoch, que es la cantidad de segundos que han pasado desde el 1/Enero/1970 hasta la fecha seleccionada. Así pues, el Unix Epoch de la fecha anterior luce algo como 1702411443
.
Una vez que ya tienes la fecha en ese formato, ahora debes dividirlo por el intervalo de tiempo que necesites que se refresque la clave. Lo estándar suele ser 30 segundos.
Así que tomando el ejemplo anterior quedaría de la siguiente manera 1702411443 / 30 = 56747048
. Solo nos interesa la parte de los enteros, los decimales los puedes descartar.
Teniendo ya ese número, el último paso es sacar los bytes en formato Big Endian. En Python se puede hacer con el método int.to_bytes
:
msg = 56747048
bytes_msg = msg.to_bytes(8, byteorder="big")
print(list(bytes_msg))
# [3, 97, 228, 40]
Como primer parámetro le pasamos la longitud de bytes del número y como segundo parámetro en que Endianidad los queremos.
La siguiente tarea es decodificar la clave que nos dieron en la web, ya que todas las páginas la dan en formato Base32 para que sea mas sencillo copiar en texto plano o dictarla.
Esto con ayuda de alguna web o biblioteca de Python se puede hacer fácil.
from base64 import b32decode
TOTP_KEY = "BOMY6BV5BWVJEWJ4ZLDYKN3LSIS736B3" # Clave de ejemplo
key_decoded = b32decode(TOTP_KEY, casefold=True)
print(list(key_decoded))
# [11, 153, 143, 6, 189, ..., 248, 59]
A partir de este punto a la fecha ya procesada le voy a llamar "mensaje" y a la clave ya decodificada le voy a llamar "llave", para evitar confusiones futuras.
Teniendo ya nuestros datos procesados, ahora si vamos a la generación de la clave como tal.
HMAC-SHA1
HMAC es un algoritmo que fue pensado con el propósito de tener un método robusto y flexible para validar mensajes en combinación con un llave secreta.
HMAC puede utilizarse con una multitud de algoritmos Hash y TOTP utilizar SHA1 como algoritmo de Hasheo. Esto en principio podría parecer inseguro, ya que SHA1 es un algoritmo que se podría considerar obsoleto para guardar contraseñas, pero TOTP no utiliza a SHA1 para generar un hash para almacenarlo, solo lo usa para generar una clave que a lo mucho servirá por un intervalo corto de tiempo y luego será inútil. Además, SHA1 es bastante mas barato de computar que otros competidores como SHA-3, SHA-256, Tiger, Etc...
La forma de calcularlo es un poco compleja de entender, pero por fortuna no tiene tantos pasos.
Los pasos que vamos a seguir son los siguientes:
- Primero generamos un Array de 64 elementos, primero irán los elementos de nuestra llave y, en caso de que no se llenen todos los campos, rellenamos con ceros a la derecha. Lo llamaremos
key_pad
.
# Indice 0 1 2 3 4 19 20 21 22 62 63
key_pad = [11, 153, 143, 6, 189, ..., 248, 59, 0, 0, ..., 0, 0]
- A partir de ese Array, creamos otro con todos los elementos del anterior pero aplicando un XOR con el número
0x36
. A este nuevo grupo de bytes lo llamamosi_key_pad
.
i_key_pad = bytes(x ^ 0x36 for x in key_pad)
- Repetimos el paso anterior pero aplicando el XOR con el número
0x5C
y a este otro grupo de bytes será elo_key_pad
.
o_key_pad = bytes(x ^ 0x5C for x in key_pad)
- Concatenamos el
i_key_pad
con los valores de nuestro mensaje.
i_key_pad_msg = i_key_pad + msg
- Lo que resulte lo pasamos por nuestra función de Hasheo (SHA1 en nuestro caso).
i_key_pad_msg_hashed = hashlib.sha1(i_key_pad_msg).digest()
- Concatenamos lo que resulte del paso anterior con lo que tenemos en
o_key_pad
.
o_key_pad_msg = o_key_pad + i_key_pad_msg_hashed
- Y como último paso, tambien hasheamos lo que haya resultado del paso anterior.
hmac_result = hashlib.sha1(o_key_pad_msg).digest()
La razón del uso de los números 0x5C
y 0x36
no está del todo clara para mi. Según vi que era por que esos números son especialmente buenos para ofuscar y generan un patrón único que mejora la seguridad... esto no lo puedo asegurar, pero apuesto a que las personas que trabajaron en el diseño del algoritmo saben infinitas veces mas que yo sobre criptografía, así que solo nos queda confiar.
Una vez sacado el resultado del HMAC, ahora si vamos a generar nuestra bonita clave TOTP.
Algoritmo TOTP
Esta última parte también es una locura de cosas que parecen meramente elegidas al azar pero ya son los últimos pasos antes de tener nuestra preciada clave TOTP, así que vamos a continuar.
Los pasos a seguir son:
- Tomar el valor en la posición 20 de los bytes que obtuvimos con HMAC y de ese valor, obtener los primeros 4 bits. Esto último lo podemos hacer utilizando el operador AND con el número
0xF
(una explicación mas detallada acerca se encuentra al pie de página).
hmac_result = ...
start = hmac_result[19] & 0xF
- Ahora utilizamos ese número como índice inicial y tomamos los siguientes 4 valores.
raw_bytes = hmac_result[start:start + 4]
- Ahora generamos un número entero utilizando esos 4 bytes en formato Big Endian.
raw_token = int.from_bytes(raw_bytes, byteorder="big")
- Ahora a ese valor le aplicamos la máscara
0x7FFFFFFF
de vuelta con el operador AND.
raw_token = raw_token & 0x7F_FF_FF_FF
- Y como último paso debemos saber cual es el número de dígitos que debe tener nuestro token TOTP. Lo mas común es que sean 6 pero podría variar dependiendo del servicio web.
Conociendo eso ahora le aplicamos el operador MOD con un número con esa cantidad de 0's en la izquierda y un 1 a la derecha. Por ejemplo con 6 dígitos sería1_000_000
.
token = raw_token % 1_000_000
Y ¡¡listo!!, ese token ya lo podríamos introducir en el campo donde nos pide la clave de doble autenticación.
Parece un mecanismo algo retorcido, pero es de hecho bastante ingenioso ya que la generación de clave se hace utilizando únicamente la fecha y hora actual y la llave que nos da la web en donde queremos autenticarnos y este proceso es completamente offline y totalmente en local.
Una prueba de ello es el proyecto en el que cree un llavero de claves TOTP con un Arduino, una pantalla LCD y un RTC. En mi Mastodon esta el proyecto, por si gustan verlo.
También aquí dejo una implementación, tanto en Python como en Rust de lo presentado en este artículo.
Explicación sobre filtrar bits con el operador AND
El operador AND se emplea para filtrar bits de un número aplicándole una máscara.
Por ejemplo, consideremos el número 219 = 0b11011011
, y si solo nos interesan los primeros 4 bits, aplicamos una máscara con 4 bits activos 0b1111 = 0xF
. Al utilizar el operador AND, obtenemos 0b11011011 & 0xF = 0b1011
.
Para comprender mejor la operación, observemos la tabla de verdad del operador AND:
A | B | & |
---|---|---|
1 | 1 | 1 |
1 | 0 | 0 |
0 | 1 | 0 |
0 | 0 | 0 |
Y viendo la operación en una tabla se puede apreciar mejor como, efectivamente, solo se están conservando los bits del número inicial en donde la máscara tiene 1's.
7 | 6 | 5 | 4 | 3 | 2 | 1 | 0 | Decimal | Hexadecimal | |
---|---|---|---|---|---|---|---|---|---|---|
Valor | 1 | 1 | 0 | 1 | 1 | 0 | 1 | 1 | 219 | 0xDB |
Máscara | 0 | 0 | 0 | 0 | 1 | 1 | 1 | 1 | 15 | 0xF |
Resultado | 0 | 0 | 0 | 0 | 1 | 0 | 1 | 1 | 11 | 0xB |
Notas
En este artículo expliqué una versión simplificada de HMAC-SHA1. La versión completa del algoritmo HMAC toma en cuenta la longitud de la llave y, sí es mayor a 64, le aplica un SHA1 y utiliza ese resultado como nueva llave. Sin embargo, dado que para generar claves TOTP las llaves raramente superan esa longitud, decidí omitir ese detalle para simplificar la explicación.