Aller au contenu

Comprendre les mot de passe à usage unique basé sur le temps

cover

Maintenant que tous les services web nous encouragent, de plus en plus, à utiliser une MFA pour sécuriser nos compte, l'un d'entre eux reste le plus utilisé que les autres : Mot de passe à usage unique basé sur le temps ou TOTP génère un code unique de 6 chiffres ou plus à saisir juste après avoir tapé votre mot de passe.

Le serveur ou l'application Web permettant de configurer le TOTP donne un QRCode à scanner (ou une chaîne Base32) à configurer dans une application génératrice de TOTP comme Microsoft Authenticator, Google Authenticator, Bitwarden ou plus.

Nous l'utilisons tous, mais comment ça marche ? Est-ce sécurisé ? Mon compte est-il sécurisé lorsque j'utilise un générateur de TOTP tiers ??

Le protocole

Je vais approfondir dans les détails le protocole sous-jacent vous permettant de générer des TOTP et comment le serveur le vérifie.

Bases

L'idée de base est assez simple. Vous et le serveur avez partagé un secret, une chaîne Base32 de 16 caractères (parfois stockée dans un QRCode). Puis vous synchronisez tous les deux vos horloges.

Enfin, lorsque vous souhaitez vous connecter, vous exécutez une fonction magique sophistiquée et vous obtenez un TOTP. Le serveur calcule le TOTP de son côté et vous demande celui que vous avez généré de votre côté. Si les deux TOTP correspondent, le serveur vous autorise à entrer.

Protocole TOTP

Explorons le protocole TOTP. Selon la RFC 6238 vous avez besoin :

  • Un compteur changeant qui doit être synchronisé entre les deux parties
  • Un secret partagé entre les deux parties

Le compteur est calculé sur la base du temps epoch (Ainsi tout le monde est synchronisé) avec la formule suivante :

C = (T - T0) / X

Avec :

  • C la valeur du compteur
  • T l'heure Unix actuelle en secondes depuis epoch
  • T0 une heure Unix de début arbitraire. 0 par défaut
  • X le pas de temps en secondes ou la fréquence de mise à jour du code. 30 par défaut

Si nous essayons de faire une implémentation Python, cela ressemblera à :

t0 = 0
# https://docs.python.org/3/library/time.html#time.time
current_unix_time = time.time()
time_step = 30

counter = int((current_unix_time - t0) / time_step)
current_unix_time: 1651094239.491242


counter => 55036474

Maintenant la valeur TOTP peut être récupérée avec la formule suivante :

TOTP = HOTP(K, C, D)

Avec:

  • C la valeur du compteur
  • K la clé secrète
  • D le nombre de chiffres dans nos TOTPs
  • HOTP la fonction magique qui calcule l'OTP

En Python, on obtient :

counter = 55036474
key = "ABCDEFGHYJKLMNOP"
digits = 6

totp = hotp(key, counter, digits)
totp => 934929

Protocole HOTP

Ce deuxième protocole est utilisé pour calculer une valeur unique, une valeur compteur et une clé secrete. De la RFC rfc4226, nous apprenons qu'il est basé sur le protocole de hachage HMAC.

Asseyez-vous bien car cela va commencer à être difficile et j'espère que vous êtes à l'aise avec les opérations binaires.

Gérer les entrées

Tout d'abord, nous devons gérer les 2 de nos 3 paramètres d'entrée : counter, key

  • counter doit être converti en une liste d'octets de type unsigned long long et big-endian.

En Python :

import struct
# https://docs.python.org/3/library/struct.html#struct.pack
# https://docs.python.org/3/library/struct.html#byte-order-size-and-alignment
# https://docs.python.org/3/library/struct.html#format-characters
bytes_counter = struct.pack('>Q', counter)
counter: 55036474


bytes_counter(bin) => 00000011 01000111 11001010 00111010
bytes_counter(hex) => 03 47 ca 3a
  • key doit être une chaîne Base32 valide. La RFC 4648 nous indique que la longueur de la chaîne doit être un facteur de 8. Ainsi, du "padding" peut être ajouté avec le caractère = à la fin s'il est trop court. Par exemple : AA => AA======, BBBBBBBBBB => BBBBBBBBBB======, CCCCCCCC => CCCCCCCC
padding = '=' * ((8 - len(key)) % 8)
valid_key = key + padding
key: ABCDEFGHIJKLMNOP


valid_key => ABCDEFGHIJKLMNOP

Ensuite, nous convertissons la clé mise à jour en valeur en liste d'octets :

import base64

bytes_key = base64.b32decode(key)
valid_key: ABCDEFGHIJKLMNOP


bytes_key(bin) => 10001000 01100100 00101001 10001110 10000100 10101001 01101100 0110101 11001111
bytes_key(hex) => 44 32 14 c7 42 54 b6 35 cf

Calculer le HMAC

En utilisant la RFC 2104 nous pouvons en comprendre le fonctionnement de la fonction HMAC. Nous devons choisir une fonction de hachage. HOTP ou RFC 4226 utilise la fonction de hachage cryptographique SHA-1.

Python implémente la bibliothèque HMAC :

import hmac

# Calculate the HMAC-SHA1 value
# Produce a 20 bytes (or 160 bites) long string
mac = hmac.new(bytes_key, bytes_counter, "SHA1").digest()
bytes_counter(hex): 03 47 ca 3a
bytes_key(hex): 44 32 14 c7 42 54 b6 35 cf


mac(bin) => 10110100 11010010 01111010 10110100 10111101 00110101 11111110 00100011 11101101 01011001 01111110 10111100 11110000 01111001 11000001 01001010 00000110 01101100 01010001 00101111
mac(hex) => b4 d2 7a b4 bd 35 fe 23 ed 59 7e bc f0 79 c1 4a 06 6c 51 2f

Calculer le HOTP

Conformément à la section 5.3 de la RFC 4226.

Trouver le décalage

Nous devons d'abord trouver la valeur offset en obtenant les 4 bits les moins significatifs du MAC (Les 4 à droite) en utilisation de opération bit à bit AND

En Python & peut être utilisé pour faire une opération binaire AND entre deux nombres. Avec la valeur 0x0F (en hexadécimal et équivalent à 00001111 en binaire) on peut extraire deux 4 derniers bits. Étant donné que offset est un entier stocké sur 4 bits, la valeur est comprise entre 0 et 15. En Python, nous pouvons utiliser mac[-1] pour obtenir le dernier octet de la liste d'octets converti en entier.

offset = mac[-1] & 0x0F
mac(bin): 10110100 11010010 01111010 10110100 10111101 00110101 11111110 00100011 11101101 01011001 01111110 10111100 11110000 01111001 11000001 01001010 00000110 01101100 01010001 00101111
mac(hex): b4 d2 7a b4 bd 35 fe 23 ed 59 7e bc f0 79 c1 4a 06 6c 51 2f


offset(bin) => 1111
offset(hex) => f
offset(dec) => 15
Recherche de la troncation dynamique

En utilisant le offset trouvé précédemment, nous allons extraire la Troncation dynamique du MAC. Nous devons récupérer les 4 octets à la position offset, offset+1, offset+2 et offset+3 dans la liste d'octets MAC.

Nous avons :

dynamic_truncation = mac[offset:(offset+3)+1]

En Python, la valeur après le : n'est pas incluse. Afin d'extraire la valeur offset + 3, nous devons mettre (offset + 3) + 1

offset(bin): 1111
offset(hex): f
offset(dec): 15

offset(dec) from 15 to 18

mac(bin): 10110100 11010010 01111010 10110100 10111101 00110101 11111110 00100011 11101101 01011001 01111110 10111100 11110000 01111001 11000001 01001010 00000110 01101100 01010001 00101111
mac(hex): b4 d2 7a b4 bd 35 fe 23 ed 59 7e bc f0 79 c1 4a 06 6c 51 2f


dynamic_truncation(bin) => 10010100 00001100 11011000 1010001
dynamic_truncation(hex) => 4a 06 6c 51
Extraire les 31 bits

Enfin, nous allons extraire les 31 bits les moins significatifs de notre Dynamic Truncation La solution serait de convertir la liste d'octets de résultat en une longueur non signée qui est de 32 bits avec le bit le plus significatif (à gauche) contenant la valeur signée (+ ou -). En utilisant la même astuce que précédemment, nous utilisons une opération bit à bit AND pour les extraire. 0x7fffffff (en hexadécimal et équivalent à 01111111 11111111 11111111 11111111 en binaire). Résultant en une valeur de 31 bits en ignorant le 0 en tête.

# https://docs.python.org/3/library/struct.html#struct.unpack
extract_31 = struct.unpack('>L', dynamic_truncation)[0] & 0x7fffffff
dynamic_truncation(bin): 10010100 00001100 11011000 1010001
dynamic_truncation(hex): 4a 06 6c 51


extract_31(bin) => 10010100 00001100 11011000 1010001
extract_31(hex) => 4a 06 6c 51
extract_31(dec) => 1241934929
Chiffres tronqués

Maintenant que nous avons notre nombre final après avoir extrait les derniers 31 bits, nous devons cette fois tronquer la valeur décimale. Sur la base de la valeur digits configurée, nous voulons uniquement conserver les X derniers chiffres de la valeur décimale de extract_31. X est la longueur du code TOTP que nous voulons et doit être comprise entre 6 et 10. 6 minimum en raison des exigences de sécurité et 10 maximum car la valeur maximale d'un entier signé 32 bits est 2147483647, une valeur numérique 10.

L'utilisation d'une longueur de 10 sur 9 n'ajoute pas beaucoup de sécurité car le premier chiffre ne peut prendre que les valeurs 0, 1, 2.

digits_count = 6

hotp = str(extract_31)[-digits_count:]
extract_31(dec): 1241934929

digits_count: 6


hotp => 934929

Dans le cas où extract_31 est une valeur trop faible où le nombre de chiffres est inférieur au nombre digits, nous ajoutons des 0 au début à gauche du code HOTP pour lui donner la bonne longueur.

hotp = hotp.zfill(digits_count)
hotp: 934929

hotp => 934929

Conclusion

Maintenant, vous savez vraiment comment les codes TOTP sont générés. Comme on peut le voir, c'est assez simple et vous pouvez développer votre propre script pour les générer.

Code complet

import base64
import hmac
import struct
import time

def hotp(counter, key, digits_count=6):
  bytes_counter = struct.pack('>Q', counter)

  key = key + '=' * ((8 - len(key)) % 8)
  bytes_key = base64.b32decode(key)

  mac = hmac.new(bytes_key, bytes_counter, "SHA1").digest()

  offset = mac[-1] & 0x0F
  dynamic_truncation = mac[offset:offset+4]
  extract_31 = struct.unpack('>L', dynamic_truncation)[0] & 0x7fffffff

  return str(extract_31)[-digits_count:].zfill(digits_count)


def totp(key):
  counter = int(time.time() / 30)

  return hotp(counter, key)


if __name__ == "__main__":
  key = input("Enter secret key : ").upper()

  print("TOTP code :", totp(key))

Références