Bonjour ! Un autre soir, en rentrant chez moi, j'ai décidé de consulter ma boîte aux lettres. Je ne parle pas de ma boîte de réception e-mail, mais de la boîte à l'ancienne où le facteur met les lettres physiques. Et à ma grande surprise, j'y ai trouvé une enveloppe avec quelque chose à l'intérieur ! En l'ouvrant, j'ai passé quelques instants à espérer qu'il s'agissait de la lettre de Poudlard retardée de plusieurs décennies. Mais ensuite j'ai dû revenir sur Terre, une fois que j'ai remarqué qu'il s'agissait d'une lettre ennuyeuse « d'adulte » de la banque. J'ai parcouru le texte et réalisé que ma banque « 100 % numérique » pour cool kids avait été rachetée par le plus gros acteur du marché local. Et en signe d'un nouveau départ, ils ont ajouté ceci à l'enveloppe :
À côté des instructions sur la façon de l'utiliser.
Si vous êtes comme moi et que vous n'avez jamais découvert une telle innovation technologique, permettez-moi de partager ce que j'ai appris de la lettre : les nouveaux propriétaires ont décidé d'appliquer les politiques de sécurité de leur entreprise, ce qui signifie que tous les utilisateurs les comptes auront désormais le MFA activé (félicitations pour cela, d'ailleurs). Et l'appareil que vous pouvez voir ci-dessus génère des jetons uniques à 6 chiffres qui sont utilisés comme deuxième facteur lors de la connexion à votre compte bancaire. Fondamentalement, de la même manière que des applications comme Authy, Google Authenticator ou 2FAS, mais sous une forme physique.
J'ai donc essayé et le processus de connexion s'est déroulé sans problème : l'appareil m'a montré un code à 6 chiffres, je l'ai saisi dans mon application bancaire, et cela m'a permis d'accéder. Hourra ! Et puis quelque chose m’a frappé : comment ça marche ? Il n'est pas possible qu'il soit connecté à Internet d'une manière ou d'une autre, mais il parvient à générer des codes corrects qui sont acceptés par le serveur de ma banque. Hm... Pourrait-il contenir une carte SIM ou quelque chose de similaire à l'intérieur ? Pas question !
Réalisant que ma vie ne sera plus jamais la même, j'ai commencé à m'interroger sur les applications que j'ai mentionnées ci-dessus (Authy et amis) ? Mon chercheur intérieur a été réveillé, j'ai donc mis mon téléphone en mode avion et, à ma grande surprise, j'ai réalisé qu'ils fonctionnent parfaitement hors ligne : ils continuent de générer des codes qui sont acceptés par les serveurs des applications. Intéressant !
Je ne suis pas sûr pour vous, mais j'ai toujours pris le flux de jetons unique pour acquis et je n'y ai jamais vraiment réfléchi (surtout en raison du fait que de nos jours, il est rare que mon téléphone n'ait pas Internet à moins que Je fais des aventures en plein air), c'est donc la cause première de ma surprise. Sinon, il est tout à fait logique du point de vue de la sécurité de fonctionner de cette manière, car le processus de génération est purement local, donc à l'abri des acteurs externes. Mais comment ça marche ?
Eh bien, les technologies modernes comme Google ou ChatGPT permettent de trouver facilement la réponse. Mais ce problème technique m'a semblé amusant, alors j'ai décidé de l'essayer et de le résoudre par moi-même d'abord.
Commençons par ce que nous avons :
La partie validation du serveur laisse entendre que le serveur doit être capable de générer le même code que l'appareil hors ligne pour les comparer. Hm..ça peut être utile.
Mes observations ultérieures de mon nouveau "jouet" m'ont apporté encore plus de découvertes :
La seule explication logique que j'ai pu trouver est que ces codes ont une certaine durée de vie. J'aimerais raconter une histoire de moi essayant de compter la durée à la manière "1-2-3-...-N", mais ce ne sera pas vrai : j'ai reçu un gros indice des applications comme Authy and Co, où j'ai vu le TTL de 30 secondes. Bonne trouvaille, ajoutons ceci à la liste des faits connus.
Résumons les exigences que nous avons jusqu'à présent :
Très bien, mais la question principale reste sans réponse : comment se fait-il que l'application hors ligne puisse générer la valeur qui correspondra à celle de l'autre application ? Qu’ont-ils en commun ?
Si vous êtes dans l'univers du Seigneur des Anneaux, vous vous souviendrez peut-être de la façon dont Bilbo a joué à un jeu d'énigmes avec Gollum et a réussi à résoudre celui-ci :
Cette chose que tout dévore :
Oiseaux, bêtes, arbres, fleurs ;
Ronge le fer, mord l'acier ;
Broie les pierres dures en farine ;
Tue le roi, ruine la ville,
Et bat la haute montagne.
Alerte spoiler, mais M. Baggins a eu de la chance et a accidentellement trouvé la bonne réponse : "Il est temps !". Croyez-le ou non, mais c'est exactement la réponse à notre énigme : 2 applications (ou plus) ont accès à la même heure tant qu'elles ont une horloge intégrée. Ce dernier ne pose plus de problème de nos jours, et l’appareil en question est suffisamment grand pour l’accueillir. Regardez autour de vous et il y a de fortes chances que l’heure sur votre montre, votre téléphone portable, votre téléviseur, votre four et votre horloge murale soit la même. Nous sommes dans quelque chose ici, il semble que nous ayons trouvé la base de l'informatique OTP (mot de passe à usage unique) !
Compter sur le temps comporte son propre ensemble de défis :
Abordons-les un par un :
Ok, c'est réglé, essayons donc d'implémenter la toute première version de notre algorithme en utilisant le temps comme base. Puisque nous sommes intéressés par un résultat à 6 chiffres, il semble judicieux de s'appuyer sur un horodatage plutôt que sur une date lisible par l'homme. Commençons par là :
// gets current timestamp: current := time.Now().Unix() fmt.Println("Current timestamp: ", current)
Selon la documentation Go, le .Unix() renvoie
le nombre de secondes écoulées depuis le 1er janvier 1970 UTC.
Voici ce qui est imprimé sur le terminal :
Current timestamp: 1733691162
C'est un bon début, mais si nous réexécutons ce code, la valeur de l'horodatage changera, alors que nous aimerions la garder stable pendant 30 secondes. Eh bien, c'est un jeu d'enfant, divisons-le par 30 et utilisons cette valeur comme base :
// gets current timestamp current := time.Now().Unix() fmt.Println("Current timestamp: ", current) // gets a number that is stable within 30 seconds base := current / 30 fmt.Println("Base: ", base)
Exécutons :
Current timestamp: 1733691545 Base: 57789718
Et encore :
Current timestamp: 1733691552 Base: 57789718
La valeur de base reste la même. Attendons un peu et réexécutons :
Current timestamp: 1733691571 Base: 57789719
La valeur de base a changé, une fois la fenêtre de 30 secondes écoulée - bon travail !
Si la logique « diviser par 30 » n'a pas de sens, laissez-moi l'expliquer avec un exemple simple :
J'espère que cela a plus de sens maintenant.
Cependant, toutes les exigences ne sont pas encore satisfaites, car nous avons besoin d'un résultat à 6 chiffres, alors que la base actuelle a 8 chiffres à partir d'aujourd'hui, mais à un moment donné dans le futur, elle pourrait atteindre 9 chiffres, et ainsi de suite. . Eh bien, utilisons une autre astuce mathématique simple : divisons la base par 1 000 000 et obtenons le reste, qui aura toujours exactement 6 chiffres, car le rappel peut être n'importe quel nombre de 0 à 999 999, mais jamais plus grand :
// gets current timestamp: current := time.Now().Unix() fmt.Println("Current timestamp: ", current)
La partie fmt.Sprintf(" d", code) ajoute des zéros non significatifs au cas où notre valeur de code aurait moins de 6 chiffres. Par exemple, 1234 sera converti en 001234.
Le code complet de cet article peut être trouvé ici.
Exécutons ce code :
Current timestamp: 1733691162
Très bien, nous obtenons notre code à 6 chiffres, hourra ! Mais quelque chose ne va pas ici, n'est-ce pas ? Si je vous donne ce code et que vous l'exécutez en même temps que moi, vous obtiendrez le même code que moi. Cela n'en fait pas un mot de passe sécurisé à usage unique, n'est-ce pas ? Voici une nouvelle exigence :
Bien sûr, certaines collisions sont inévitables si nous avons plus d'un million d'utilisateurs, car il s'agit du maximum de valeurs uniques possibles pour 6 chiffres. Mais ce sont des collisions rares et techniquement inévitables, et non un défaut de conception d’algorithme comme celui que nous avons actuellement.
Je ne pense pas que des astuces mathématiques intelligentes nous aideront ici à elles seules : si nous avons besoin de résultats séparés par utilisateur, nous avons besoin d'un état spécifique à l'utilisateur pour y parvenir. En tant qu'ingénieurs et, en même temps, utilisateurs de nombreux services, nous savons que pour accorder l'accès à leurs API, les services s'appuient sur des clés privées, uniques par utilisateur. Introduisons également une clé privée pour notre cas d'utilisation afin de distinguer les utilisateurs.
Une logique simple pour générer des clés privées sous forme d'entiers compris entre 1 000 000 et 999 999 999 :
// gets current timestamp current := time.Now().Unix() fmt.Println("Current timestamp: ", current) // gets a number that is stable within 30 seconds base := current / 30 fmt.Println("Base: ", base)
Nous utilisons la carte pkDb comme moyen d'éviter les doublons entre les clés privées, et si le doublon a été détecté, nous exécutons à nouveau la logique de génération jusqu'à ce que nous obtenions un résultat unique.
Exécutons ce code pour obtenir l'exemple de clé privée :
Current timestamp: 1733691545 Base: 57789718
Utilisons cette clé privée dans notre logique de génération de code pour nous assurer que nous obtenons des résultats différents par clé privée. Puisque notre clé privée est de type entier, la chose la plus simple que nous puissions faire est de l'ajouter à la valeur de base et de conserver l'algorithme restant tel quel :
Current timestamp: 1733691552 Base: 57789718
Assurons-nous qu'il produit des résultats différents pour différentes clés privées :
Current timestamp: 1733691571 Base: 57789719
Le résultat semble tel que nous le souhaitions et l'attendions :
// gets current timestamp: current := time.Now().Unix() fmt.Println("Current timestamp: ", current) // gets a number that is stable within 30 seconds: base := current / 30 fmt.Println("Base: ", base) // makes sure it has only 6 digits: code := base % 1_000_000 // adds leading zeros if necessary: formattedCode := fmt.Sprintf("%06d", code) fmt.Println("Code: ", formattedCode)
Fonctionne à merveille ! Cela signifie que la clé privée doit être injectée dans l'appareil qui génère les codes avant d'être envoyée aux utilisateurs comme moi : cela ne devrait pas poser de problème du tout pour les banques.
Avons-nous terminé maintenant ? Enfin, seulement si nous sommes satisfaits du scénario artificiel que nous avons utilisé. Si vous avez déjà activé le MFA pour des services/sites Web sur lesquels vous avez un compte, vous avez peut-être remarqué que la ressource Web vous demande de scanner le code QR avec l'application de deuxième facteur de votre choix (Authy, Google Authenticator, 2FAS, etc. ) qui saisira le code secret dans votre application et commencera à générer un code à 6 chiffres à partir de ce moment. Alternativement, il est possible de saisir le code manuellement.
J'en parle pour mentionner qu'il est possible de jeter un œil au format des vraies clés privées utilisées dans l'industrie. Il s'agit généralement de chaînes codées en Base32 de 16 à 32 caractères qui ressemblent à ceci :
// gets current timestamp: current := time.Now().Unix() fmt.Println("Current timestamp: ", current)
Comme vous pouvez le constater, ceci est assez différent des clés privées entières que nous avons utilisées, et l'implémentation actuelle de notre algorithme ne fonctionnera pas si nous devons passer à ce format. Comment pouvons-nous ajuster notre logique ?
Commençons par une approche simple : notre code ne se compilera pas, à cause de cette ligne :
Current timestamp: 1733691162
car pk est désormais de type string. Alors pourquoi ne pas le convertir en entier ? Bien qu'il existe des moyens bien plus élégants et performants de le faire, voici la chose la plus simple que j'ai trouvée :
// gets current timestamp current := time.Now().Unix() fmt.Println("Current timestamp: ", current) // gets a number that is stable within 30 seconds base := current / 30 fmt.Println("Base: ", base)
Ceci est fortement inspiré de l'implémentation Java hashCode() pour le type de données String, ce qui le rend suffisant pour notre scénario.
Voici la logique ajustée :
Current timestamp: 1733691545 Base: 57789718
Voici la sortie du terminal :
Current timestamp: 1733691552 Base: 57789718
Bien, le code à 6 chiffres est là, bon travail. Attendons d'arriver à la fenêtre horaire suivante et exécutons-la à nouveau :
Current timestamp: 1733691571 Base: 57789719
Hm...ça marche, mais le code est, en gros, l'incrément de la valeur précédente, ce qui n'est pas bon, car de cette façon les OTP sont prévisibles, et avoir sa valeur et savoir à quelle heure il appartient, c'est très simple pour commencer à générer les mêmes valeurs sans avoir besoin de connaître la clé privée. Voici le pseudocode simple de ce hack :
// gets current timestamp: current := time.Now().Unix() fmt.Println("Current timestamp: ", current) // gets a number that is stable within 30 seconds: base := current / 30 fmt.Println("Base: ", base) // makes sure it has only 6 digits: code := base % 1_000_000 // adds leading zeros if necessary: formattedCode := fmt.Sprintf("%06d", code) fmt.Println("Code: ", formattedCode)
où keepWithinSixDigits s'assurera qu'après 999 999, la valeur suivante est 000 000 et ainsi de suite pour maintenir la valeur dans la limite des possibilités à 6 chiffres.
Comme vous pouvez le constater, il s’agit d’une grave faille de sécurité. Pourquoi cela arrive-t-il ? Si l'on regarde la logique de calcul de base, nous verrons qu'elle repose sur 2 facteurs :
Le hachage produit la même valeur pour la même clé, sa valeur est donc constante. Quant au /30 actuel, il a la même valeur pendant 30 secondes, mais une fois la fenêtre passée, la valeur suivante sera l'incrément de la précédente. Ensuite, la base % 1_000_000 se comporte comme nous le voyons. Notre implémentation précédente (avec des clés privées sous forme d'entiers) présentait la même vulnérabilité, mais nous ne l'avons pas remarqué - le manque de tests est à blâmer.
Nous devons transformer le courant / 30 en quelque chose pour rendre le changement de sa valeur plus visible.
Il existe plusieurs façons d'y parvenir, et quelques astuces mathématiques intéressantes existent, mais à des fins éducatives, donnons la priorité à la lisibilité de la solution que nous utiliserons : extrayons le courant / 30 dans une base de variables distincte et incluons dans la logique de calcul de hachage :
// gets current timestamp: current := time.Now().Unix() fmt.Println("Current timestamp: ", current)
De cette façon, même si la base changera de 1 toutes les 30 secondes, après avoir été utilisée dans la logique de la fonction hash(), le poids du diff augmentera en raison de la série de multiplications effectuées.
Voici l'exemple de code mis à jour :
Current timestamp: 1733691162
Exécutons :
// gets current timestamp current := time.Now().Unix() fmt.Println("Current timestamp: ", current) // gets a number that is stable within 30 seconds base := current / 30 fmt.Println("Base: ", base)
Boum ! Comment se fait-il que nous ayons obtenu la valeur négative ici ? Eh bien, il semble que nous ayons manqué de plages int64, nous avons donc plafonné les valeurs à moins et avons recommencé - mes collègues Java le connaissent grâce au comportement hashCode(). La solution est simple : prenons la valeur absolue du résultat, donc le signe moins est ignoré :
Current timestamp: 1733691545 Base: 57789718
Voici un exemple de code complet avec le correctif :
Current timestamp: 1733691552 Base: 57789718
Exécutons :
Current timestamp: 1733691571 Base: 57789719
Exécutons-le à nouveau pour nous assurer que les valeurs OTP sont distribuées maintenant :
// gets current timestamp: current := time.Now().Unix() fmt.Println("Current timestamp: ", current) // gets a number that is stable within 30 seconds: base := current / 30 fmt.Println("Base: ", base) // makes sure it has only 6 digits: code := base % 1_000_000 // adds leading zeros if necessary: formattedCode := fmt.Sprintf("%06d", code) fmt.Println("Code: ", formattedCode)
Bien, enfin une solution décente !
En fait, c'est à ce moment-là que j'ai arrêté mon processus de mise en œuvre manuelle, car j'ai eu ma part de plaisir et j'ai appris quelque chose de nouveau. Cependant, ce n’est ni la meilleure solution ni celle avec laquelle je partirais vivre. Entre autres choses, il présente un gros défaut : comme vous pouvez le constater, notre logique traite toujours de grands nombres en raison de la logique de hachage et des valeurs d'horodatage, ce qui signifie qu'il est très peu probable que nous puissions générer des résultats commençant par 1 ou plus de zéros : par exemple, 012345 , 001234, etc., même s'ils sont totalement valides. Pour cette raison, il nous manque 100 000 valeurs possibles, ce qui représente 10 % du nombre possible de résultats de l'algorithme – les chances de collisions sont ainsi plus élevées. Pas cool !
Je n'entrerai pas dans les détails de l'implémentation utilisée dans les applications réelles, mais pour ceux qui sont curieux, je partagerai deux RFC qui valent la peine d'être consultées :
Et voici l'implémentation du pseudocode qui fonctionnera comme prévu en fonction des RFC ci-dessus :
Current timestamp: 1733692423 Base: 57789747 Code: 789747
Comme vous pouvez le voir, nous en sommes très proches, mais l'algorithme d'origine utilise un hachage plus avancé (HMAC-SHA1 dans cet exemple) et effectue quelques opérations au niveau du bit pour normaliser la sortie.
Cependant, il y a encore une chose que j'aimerais aborder avant de mettre fin à cette journée : la sécurité. Je vous encourage fortement à ne pas implémenter la logique de génération d'OTP par vous-même, car de nombreuses bibliothèques le font pour nous. La marge d’erreur est énorme, et la vulnérabilité qui sera découverte et exploitée par les mauvais acteurs est proche.
Même si vous maîtrisez la logique de génération et la couvrez de tests, il y a d'autres choses qui peuvent mal tourner. Par exemple, combien pensez-vous qu’il faudra pour forcer brutalement le code à 6 chiffres ? Expérimentons :
// gets current timestamp: current := time.Now().Unix() fmt.Println("Current timestamp: ", current)
Exécutons ce code :
Current timestamp: 1733691162
Et encore une fois :
// gets current timestamp current := time.Now().Unix() fmt.Println("Current timestamp: ", current) // gets a number that is stable within 30 seconds base := current / 30 fmt.Println("Base: ", base)
Comme vous pouvez le voir, il faut environ 70 ms pour deviner le code via une simple boucle for de forçage brut. C'est 400 fois plus rapide que la durée de vie de l'OTP ! Le serveur de l'application/du site Web utilisant le mécanisme OTP doit empêcher cela, par exemple en n'acceptant pas de nouveaux codes pendant les 5 ou 10 secondes suivantes après 3 tentatives infructueuses. De cette façon, l'attaquant n'obtient que 18 ou 9 tentatives dans la fenêtre de 30 secondes, ce qui n'est pas suffisant pour le pool de 1 million de valeurs possibles.
Et il y a d’autres choses comme celle-ci qui sont faciles à négliger. Alors, permettez-moi de le répéter : ne construisez pas cela à partir de zéro, mais comptez sur les solutions existantes.
Quoi qu'il en soit, j'espère que vous avez appris quelque chose de nouveau aujourd'hui, et que la logique d'OTP ne sera plus un mystère pour vous à partir de maintenant. De plus, si à un moment donné de votre vie, vous devez faire en sorte que votre appareil hors ligne génère des valeurs à l'aide d'un algorithme reproductible, vous avez une bonne idée par où commencer.
Merci pour le temps que vous avez passé à lire cet article et amusez-vous ! =)
P.S. Recevez un e-mail une fois que je publie un nouvel article - abonnez-vous ici
P.P.S. Comme le reste des cool kids, j'ai créé un compte Bluesky récemment, alors, s'il vous plaît, aidez-moi à rendre mon flux plus amusant =)
Ce qui précède est le contenu détaillé de. pour plus d'informations, suivez d'autres articles connexes sur le site Web de PHP en chinois!