¿Cómo implementas autenticación multifactor basada en TOTP desde cero?
TOTP convierte un secreto compartido y la hora actual en un código rotativo de seis dígitos usando HMAC — entender primero HOTP hace obvio cada detalle de diseño.
APRENDE HACIENDOPon a prueba tu intuición3 pasos
¿Cómo implementas autenticación multifactor basada en TOTP desde cero?
Usa los ejemplos y la explicación de la lección para comprobar tu idea.
Reflexión personal. No se envía a ningún servicio de IA.#Empezá por HOTP — TOTP es solo HOTP con un contador distinto
Antes de que existiera TOTP, el RFC 4226 definió HOTP: una contraseña de un solo uso basada en HMAC, derivada de un secreto compartido y un contador que se incrementa en uno cada vez que se genera un código (pensá en un token de hardware donde apretar el botón genera un código y a la vez avanza el contador). El servidor guarda su propia copia del contador y compara.
La operación central es simple: se calcula el HMAC del contador con el secreto, luego se toman 4 bytes del hash resultante en una posición elegida por el último nibble del propio hash ("truncamiento dinámico"), se enmascara el bit de signo, y se reduce módulo 10^dígitos para obtener un código decimal de longitud fija. El paso de truncamiento dinámico es lo que hace al algoritmo resistente a una clase específica de ataque en la que un atacante que pudiera elegir el offset de truncamiento podría sesgar la distribución de salida.
#De HOTP a TOTP: el contador se vuelve tiempo
TOTP (RFC 6238) hace exactamente un cambio: en lugar de un contador explícito que ambas partes rastrean e incrementan, el contador se deriva del timestamp Unix actual dividido por un paso de tiempo fijo (30 segundos es el valor por defecto casi universal): contador = floor(unix_time / paso). Tanto la app autenticadora como el servidor calculan el mismo contador de forma independiente, siempre que sus relojes coincidan con suficiente precisión — no hace falta sincronizar ningún estado entre peticiones, que es exactamente por qué TOTP funciona sin conexión en una app que nunca habla con tu servidor.
#Una implementación real
import hashlibimport hmacimport structimport timeimport base64def hotp(secret: bytes, counter: int, digits: int = 6) -> str:"""HOTP según RFC 4226: HMAC-SHA1 sobre un contador de 8 bytesbig-endian, truncado a `digits` dígitos decimales."""counter_bytes = struct.pack(">Q", counter) # 8 bytes, big-endianmac = hmac.new(secret, counter_bytes, hashlib.sha1).digest()# Truncamiento dinámico (RFC 4226, sección 5.3)offset = mac[-1] & 0x0Ftruncated = mac[offset:offset + 4]code_int = struct.unpack(">I", truncated)[0] & 0x7FFFFFFFreturn str(code_int % (10 ** digits)).zfill(digits)def totp(secret: bytes, time_step: int = 30, digits: int = 6, at: float = None) -> str:"""TOTP según RFC 6238: HOTP con el contador derivado del reloj de pared."""timestamp = time.time() if at is None else atcounter = int(timestamp // time_step)return hotp(secret, counter, digits)def verify_totp(secret: bytes, submitted_code: str, time_step: int = 30,digits: int = 6, window: int = 1) -> bool:"""Acepta un código del paso de tiempo actual o `window` pasos haciacualquier lado, para tolerar el desfase de reloj y la latencia humanaal escribirlo."""current_counter = int(time.time() // time_step)for offset in range(-window, window + 1):candidate = hotp(secret, current_counter + offset, digits)if hmac.compare_digest(candidate, submitted_code):return Truereturn Falsedef generate_base32_secret(num_bytes: int = 20) -> str:"""Secreto de 160 bits, la longitud recomendada por RFC 4226,codificado para mostrar/aprovisionar vía QR."""import osraw = os.urandom(num_bytes)return base64.b32encode(raw).decode("utf-8")def provisioning_uri(secret_b32: str, account_name: str, issuer: str) -> str:"""URI otpauth:// consumible por Google Authenticator, Authy, etc.Quien la reciba la codifica como QR."""from urllib.parse import quotelabel = quote(f"{issuer}:{account_name}")return (f"otpauth://totp/{label}"f"?secret={secret_b32}&issuer={quote(issuer)}"f"&algorithm=SHA1&digits=6&period=30")
Conectando todo para el enrolamiento y el login:
secret_b32 = generate_base32_secret() # guardalo por usuario, cifrado en repososecret_bytes = base64.b32decode(secret_b32)uri = provisioning_uri(secret_b32, account_name="jane@example.com", issuer="Acme")# renderizá `uri` como un código QR para que el usuario lo escanee en su app autenticadora# ... más tarde, al iniciar sesión:code_from_app = "482913"is_valid = verify_totp(secret_bytes, code_from_app)
Notá que el secreto guardado para las operaciones de HMAC debe ser los bytes crudos, decodificados desde base32 — el texto en base32 es solo una codificación de transporte/visualización para que el secreto sobreviva a ser tipeado a mano o embebido en la URI de un código QR.
#Por qué la verificación revisa una ventana, no un solo código
El verify_totp de arriba revisa el paso de tiempo actual más un paso hacia cada lado (window=1), lo que significa que acepta un código que fue válido hasta 30 segundos atrás o 30 segundos en el futuro. Dos cosas independientes empujan hacia esto: el reloj del teléfono del usuario y el del servidor nunca están perfectamente sincronizados (un desfase de apenas unos segundos es común), y existe una latencia real entre que la app muestra un código y el usuario termina de escribirlo. Una ventana de exactamente cero produce un goteo enloquecedor de quejas de "código inválido" de usuarios que hacen todo bien; una ventana demasiado ancha aumenta de forma significativa la superficie de ataque adivinable, ya que cada paso aceptado es un código de 6 dígitos más (probabilidad de 1 en un millón) con el que un atacante podría tener suerte.
#La comparación en tiempo constante también importa acá
hmac.compare_digest se usa para comparar el código calculado y el enviado en lugar de ==, por la misma razón que importa en la verificación de contraseñas: una comparación de strings ingenua que retorna apenas encuentra un carácter distinto filtra información de tiempo sobre cuántos dígitos iniciales eran correctos, lo cual — a lo largo de suficientes intentos — es un canal lateral real (aunque lento) para adivinar el código verdadero.
#Trampas del enrolamiento que vale la pena conocer
- Nunca regeneres el secreto en silencio. Si el usuario vuelve a escanear un código QR, el código anterior deja de funcionar de inmediato; el enrolamiento debería mostrar el secreto exactamente una vez y exigir que el usuario confirme un código generado antes de activar realmente el MFA, para que un escaneo equivocado no lo deje bloqueado fuera de una cuenta que nunca terminó de proteger.
- Ofrecé códigos de respaldo. Un usuario que pierde su teléfono sin ningún mecanismo alternativo queda bloqueado de forma permanente; los códigos de respaldo de un solo uso, generados en el enrolamiento e invalidados al usarse, son la vía de escape estándar.
- Guardá el secreto cifrado, no simplemente "en una columna junto al resto de los datos del usuario" — es funcionalmente tan sensible como la contraseña misma, ya que cualquiera que lo lea puede generar códigos válidos indefinidamente.