Title: funcionamiento de la generación de claves TOTP

Date: 2023-12-26
Content:

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:

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:

# 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]
i_key_pad = bytes(x ^ 0x36 for x in key_pad)
o_key_pad = bytes(x ^ 0x5C for x in key_pad)
i_key_pad_msg = i_key_pad + msg
i_key_pad_msg_hashed = hashlib.sha1(i_key_pad_msg).digest()
o_key_pad_msg = o_key_pad + i_key_pad_msg_hashed
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:

hmac_result = ...
start = hmac_result[19] & 0xF
raw_bytes = hmac_result[start:start + 4]
raw_token = int.from_bytes(raw_bytes, byteorder="big")
raw_token = raw_token & 0x7F_FF_FF_FF
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.

Fuentes y enlaces de interés

  1. Especificación de HMAC RFC-2104
  2. Especificación de TOTP RFC-6238
  3. https://es.wikipedia.org/wiki/HMAC
  4. https://github.com/jedisct1/rust-hmac-sha1/blob/master/src/lib.rs
  5. https://github.com/pyauth/pyotp