Générez des mots de passe à usage unique pour 2FA dans JS à l'aide de l'API Web Crypto

Présentation


L'authentification à deux facteurs est partout aujourd'hui. Grâce à elle, pour voler un compte, un simple mot de passe ne suffit pas. Et bien que sa présence ne garantisse pas que votre compte ne sera pas supprimé afin de le contourner, une attaque plus complexe et multi-niveaux sera nécessaire. Comme vous le savez, plus une chose est compliquée dans ce monde, plus elle ne fonctionnera probablement pas.


Je suis sûr que tous ceux qui lisent cet article ont utilisé au moins une fois l'authentification à deux facteurs (ci-après - 2FA, une phrase longue et douloureuse) dans leur vie. Aujourd'hui, je vous invite à découvrir comment cette technologie fonctionne, qui protège quotidiennement d'innombrables comptes.


Mais pour commencer, vous pouvez jeter un œil à la démo de ce que nous allons faire aujourd'hui.


Les bases


La première chose à mentionner à propos des mots de passe à usage unique est qu'ils sont de deux types: HOTP et TOTP . À savoir, mot de passe unique basé sur HMAC et OTP basé sur le temps . TOTP n'est qu'un complément à HOTP, parlons donc d'abord d'un algorithme plus simple.


HOTP est décrit par la spécification RFC4226 . Il est petit, seulement 35 pages, et contient tout ce dont vous avez besoin: une description formelle, un exemple d'implémentation et des données de test. Regardons les concepts de base.


Tout d'abord, qu'est-ce que le HMAC ? HMAC signifie Hash-based Message Authentication Code , ou "message authentication code using hash functions" en russe. MAC est un mécanisme de vérification de l'expéditeur d'un message. L'algorithme MAC génère une balise MAC à l' aide d'une clé secrète connue uniquement de l'expéditeur et du destinataire. Après avoir reçu le message, vous pouvez générer vous-même la balise MAC et comparer les deux balises. S'ils coïncident - tout est en ordre, il n'y a pas eu d'interférence dans le processus de communication. En bonus, de la même manière, vous pouvez vérifier si le message a été endommagé lors de la transmission. Bien sûr, cela ne fonctionnera pas pour distinguer les interférences des dommages, mais le fait de la corruption d'informations est suffisant.


Étiquette MAC


Qu'est-ce qu'un hachage? Un hachage est le résultat de l'application d'une fonction de hachage à un message. Les fonctions de hachage prennent vos données et en font une chaîne de longueur fixe. Un bon exemple est la fonction MD5 bien connue, qui a été largement utilisée pour vérifier l'intégrité des fichiers.


MAC lui-même n'est pas un algorithme spécifique, mais seulement un terme général. HMAC, à son tour, est déjà une mise en œuvre concrète. Plus précisément, HMAC- X , où X est l'une des fonctions de hachage cryptographique. HMAC prend deux arguments: une clé secrète et un message, les mélange d'une certaine manière, applique la fonction de hachage sélectionnée deux fois et renvoie une balise MAC.


Si vous pensez actuellement à tout ce que cela a à voir avec les mots de passe à usage unique - ne vous inquiétez pas, nous avons presque atteint le point principal.


Selon la spécification, HOTP est calculé sur la base de deux valeurs:


  • K est la clé secrète que le client et le serveur connaissent. Il doit être d'au moins 128 bits, et de préférence 160, et créé lors de la configuration de 2FA.
  • C est le compteur .

Un compteur est une valeur de 8 octets synchronisée entre le client et le serveur. Il est mis à jour lorsque vous générez de nouveaux mots de passe. Dans le schéma HOTP, un compteur côté client est incrémenté chaque fois que vous générez un nouveau mot de passe. Côté serveur, chaque fois que le mot de passe passe la validation avec succès. Puisqu'il est possible de générer un mot de passe, mais de ne pas l'utiliser, le serveur permet à la valeur du compteur de s'exécuter un peu en avant dans la fenêtre établie. Cependant, si vous jouez trop avec le générateur de mot de passe dans le schéma HOTP, vous devrez le synchroniser à nouveau.


Alors. Comme vous l'avez probablement remarqué, HMAC prend également deux arguments. RFC4226 définit la fonction de génération HOTP comme suit:


HOTP(K,C) = Truncate(HMAC-SHA-1(K,C)) 

Comme on pouvait s'y attendre, K est utilisé comme clé secrète. Le compteur, à son tour, est utilisé comme message. Une fois que la fonction HMAC a généré une balise MAC, la mystérieuse fonction Truncate extrait le mot de passe à usage unique qui nous est déjà familier, que vous voyez dans votre application de générateur ou sur le jeton.


Commençons à écrire le code et traitons le reste au fur et à mesure.


Plan de mise en oeuvre


Pour obtenir des mots de passe à usage unique, nous devons suivre ces étapes.


Implémentation


  • Générez un hachage HMAC-SHA1 à partir des paramètres K et C. Ce sera une chaîne de 20 octets.
  • Tirez 4 octets de cette chaîne d'une manière spécifique.
  • Convertissez la valeur extraite en nombre et divisez-la par 10 ^ n, où n = le nombre de chiffres du mot de passe à usage unique (généralement n = 6). Et enfin, prenez le reste de cette division. Ce sera notre mot de passe.

Cela ne semble pas trop dur, non? Commençons par la génération de hachage.


Générer HMAC-SHA1


C'est peut-être la plus simple des étapes répertoriées ci-dessus. Nous n'essaierons pas de recréer l'algorithme par nous-mêmes (nous n'avons jamais besoin d'essayer de mettre en œuvre quelque chose à partir de la cryptographie par nous-mêmes). À la place, nous utiliserons l' API Web Crypto . Le petit problème est que cette API de spécification est uniquement disponible sous Secure Context (HTTPS). Pour nous, cela est lourd du fait que nous ne pouvons pas l'utiliser sans configurer HTTPS sur le serveur de développement. Un peu d'histoire et de discussion sur la façon dont c'est la bonne décision peut être trouvé ici .


Heureusement, dans Firefox, vous pouvez utiliser Web Crypto dans un contexte non sécurisé, et vous n'avez pas à réinventer la roue ou à faire glisser dans des bibliothèques tierces. Par conséquent, pour développer une démo, je suggère d'utiliser FF.


L'API Crypto elle-même est définie dans window.crypto.subtle . Si vous êtes surpris par le nom, je cite le cahier des charges:


L'API est appelée SubtleCrypto pour refléter le fait que de nombreux algorithmes ont des exigences d'utilisation spécifiques. Ce n'est que lorsque ces exigences sont remplies qu'elles conservent leur durabilité.

Passons en revue les méthodes dont nous avons besoin. Remarque: toutes les méthodes mentionnées ici sont asynchrones et renvoient Promise .


Tout d'abord, nous avons besoin de la méthode importKey , car nous utiliserons notre clé privée et ne la générerons pas dans le navigateur. importKey prend 5 arguments:


 importKey( format, keyData, algorithm, extractable, usages ); 

Dans notre cas:


  • format sera 'raw' , c'est-à-dire nous fournirons la clé sous forme de tableau d'octets ArrayBuffer .
  • keyData est le même ArrayBuffer. Très bientôt, nous parlerons de la façon de le générer.
  • algorithm , selon la spécification, sera HMAC-SHA1 . Cet argument doit correspondre au format de HmacImportParams .
  • extractable sur false, car nous n'avons pas l'intention d'exporter la clé secrète
  • Et enfin, de tous les usages nous n'avons besoin que du 'sign' .

Notre clé secrète sera une longue chaîne aléatoire. Dans le monde réel, il peut s'agir d'une séquence d'octets, qui peut ne pas être imprimable, cependant, pour plus de commodité, dans cet article, nous considérerons une chaîne. Pour le convertir en ArrayBuffer nous utiliserons l'interface TextEncoder . Avec elle, la clé est préparée en deux lignes de code:


 const encoder = new TextEncoder('utf-8'); const secretBytes = encoder.encode(secret); 

Maintenant, mettons tout cela ensemble:


  const Crypto = window.crypto.subtle; const encoder = new TextEncoder('utf-8'); const secretBytes = encoder.encode(secret); const key = await Crypto.importKey( 'raw', secretBytes, { name: 'HMAC', hash: { name: 'SHA-1' } }, false, ['sign'] ); 

Super! La cryptographie a été préparée. Nous allons maintenant nous occuper du compteur et enfin signer le message.


Selon la spécification, notre compteur doit avoir une longueur de 8 octets. Nous travaillerons à nouveau avec, comme avec ArrayBuffer . Pour le traduire dans ce formulaire, nous utiliserons l'astuce qui est généralement utilisée dans JS pour enregistrer des zéros dans les chiffres supérieurs d'un nombre. Après cela, nous mettrons chaque octet dans un ArrayBuffer aide d'un DataView . Gardez à l'esprit que par spécification pour toutes les données binaires, le format est big endian .


 function padCounter(counter) { const buffer = new ArrayBuffer(8); const bView = new DataView(buffer); const byteString = '0'.repeat(64); // 8 bytes const bCounter = (byteString + counter.toString(2)).slice(-64); for (let byte = 0; byte < 64; byte += 8) { const byteValue = parseInt(bCounter.slice(byte, byte + 8), 2); bView.setUint8(byte / 8, byteValue); } return buffer; } 

Compteur de pads


Enfin, après avoir préparé la clé et le compteur, vous pouvez générer un hachage! Pour ce faire, nous utiliserons la fonction de sign de SubtleCrypto .


 const counterArray = padCounter(counter); const HS = await Crypto.sign('HMAC', key, counterArray); 

Et sur ce point, nous avons terminé la première étape. À la sortie, nous obtenons une valeur légèrement mystérieuse appelée HS. Et bien que ce ne soit pas le meilleur nom pour une variable, c'est ce qu'elle (et certains des éléments suivants) est appelée dans la spécification. Nous laisserons ces noms pour faciliter la comparaison du code avec celui-ci. Et ensuite?


Étape 2: générer une chaîne de 4 octets (troncature dynamique)
Soit Sbits = DT (HS) // DT, défini ci-dessous,
// renvoie une chaîne de 31 bits

DT signifie Dynamic Truncation. Et voici comment cela fonctionne:


 function DT(HS) { // First we take the last byte of our generated HS and extract last 4 bits out of it. // This will be our _offset_, a number between 0 and 15. const offset = HS[19] & 0b1111; // Next we take 4 bytes out of the HS, starting at the offset const P = ((HS[offset] & 0x7f) << 24) | (HS[offset + 1] << 16) | (HS[offset + 2] << 8) | HS[offset + 3] // Finally, convert it into a binary string representation const pString = P.toString(2); return pString; } 

Troncature


Remarquez comment nous avons appliqué AND au niveau du bit au premier octet de HS. 0x7f dans le système binaire est 0b01111111 , donc essentiellement nous 0b01111111 simplement le premier bit. Dans JS, c'est là que se termine le sens de cette expression, mais dans d'autres langues, elle permettra également de couper le bit de signe pour éliminer la confusion entre les nombres positifs / négatifs et présenter ce nombre comme non signé.


Presque terminé! Il ne reste plus qu'à convertir la valeur obtenue de DT en un nombre et à passer à la troisième étape.


 function truncate(uKey) { const Sbits = DT(uKey); const Snum = parseInt(Sbits, 2); return Snum; } 

La troisième étape est également assez petite. Il suffit de diviser le nombre résultant par 10 ** ( ) , puis de prendre le reste de cette division. Ainsi, nous avons coupé les N derniers chiffres de ce nombre. Selon la spécification, notre code devrait pouvoir extraire au moins les mots de passe à six chiffres et potentiellement ceux à 7 et 8 chiffres. Théoriquement, puisqu'il s'agit d'un nombre de 31 bits, nous aurions pu retirer 9 caractères, mais en réalité, je n'ai personnellement jamais vu plus de 6 caractères. Et vous?


Le code de la fonction finale, qui combine toutes les précédentes, dans ce cas ressemblera à ceci:


 async function generateHOTP(secret, counter) { const key = await generateKey(secret, counter); const uKey = new Uint8Array(key); const Snum = truncate(uKey); // Make sure we keep leading zeroes const padded = ('000000' + (Snum % (10 ** 6))).slice(-6); return padded; } 

Hourra! Mais comment vérifier maintenant que notre code est correct?


Test


Afin de tester l'implémentation, nous utiliserons des exemples du RFC. L'annexe D comprend des valeurs de test pour la clé secrète "12345678901234567890" et des valeurs de compteur de 0 à 9. Il existe également des hachages HMAC comptés et des résultats intermédiaires de la fonction Tronquer. Assez utile pour déboguer toutes les étapes de l'algorithme. Voici un petit exemple de ce tableau (il ne reste que le compteur et HOTP):


  Count HOTP 0 755224 1 287082 2 359152 3 969429 ... 

Si vous n'avez pas regardé la démo , c'est le moment. Vous pouvez y insérer les valeurs du RFC. Et revenez parce que nous commençons TOTP.


Totp


Nous sommes donc finalement arrivés à la partie la plus moderne de 2FA. Lorsque vous ouvrez votre générateur de mot de passe à usage unique et que vous voyez un petit temporisateur comptant combien de code supplémentaire sera valide, c'est TOTP. Quelle est la différence?


Basé sur le temps signifie qu'au lieu d'une valeur statique, l'heure actuelle est utilisée comme compteur. Ou, plus précisément, "l'intervalle" (pas de temps). Ou même le numéro de l'intervalle en cours. Pour le calculer, nous prenons le temps Unix (le nombre de millisecondes depuis minuit le 1er janvier 1970 UTC) et divisons par la fenêtre la validité du mot de passe (généralement 30 secondes). Le serveur tolère généralement de petites déviations dues à une synchronisation d'horloge imparfaite. Habituellement 1 intervalle d'avant en arrière selon la configuration.


De toute évidence, c'est beaucoup plus sûr que le schéma HOTP. Dans un schéma limité dans le temps, le code valide change toutes les 30 secondes, même s'il n'a pas été utilisé. Dans l'algorithme d'origine, un mot de passe valide est déterminé par la valeur actuelle du compteur sur le serveur + la fenêtre de tolérance. Si vous ne vous authentifiez pas, le mot de passe ne sera pas modifié indéfiniment. Vous pouvez en savoir plus sur TOTP dans la RFC6238 .


Étant donné que le schéma basé sur le temps est un ajout à l'algorithme d'origine, nous n'avons pas besoin de modifier l'implémentation d'origine. Nous utiliserons requestAnimationFrame et vérifierons pour chaque image si nous sommes toujours dans l'intervalle de temps. Sinon, générez un nouveau compteur et calculez à nouveau HOTP. En omettant tout le code de contrôle, la solution ressemble à ceci:


 let stepWindow = 30 * 1000; // 30 seconds in ms let lastTimeStep = 0; const updateTOTPCounter = () => { const timeSinceStep = Date.now() - lastTimeStep * stepWindow; const timeLeft = Math.ceil(stepWindow - timeSinceStep); if (timeLeft > 0) { return requestAnimationFrame(updateTOTPCounter); } timeStep = getTOTPCounter(); lastTimeStep = timeStep; <...update counter and regenerate...> requestAnimationFrame(updateTOTPCounter); } 

Touches de finition - Prise en charge du code QR


Habituellement, lorsque nous configurons 2FA, nous analysons les paramètres initiaux avec un code QR. Il contient toutes les informations nécessaires: le schéma sélectionné, la clé secrète, le nom du compte, le nom du fournisseur, le nombre de chiffres du mot de passe.


Dans un article précédent, j'ai expliqué comment vous pouvez numériser des codes QR directement à partir de l'écran à l'aide de l'API getDisplayMedia . Sur la base de ce matériel, j'ai créé une petite bibliothèque, que nous utiliserons maintenant. La bibliothèque s'appelle stream-display , et en plus nous utilisons le merveilleux paquet jsQR .


Le lien encodé en QR a le format suivant:


 otpauth://TYPE/LABEL?PARAMETERS 

Par exemple:


 otpauth://totp/label?secret=oyu55d4q5kllrwhy4euqh3ouw7hebnhm5qsflfcqggczoafxu75lsagt&algorithm=SHA1&digits=6&period=30 

Je vais omettre le code qui configure le processus de démarrage de la capture et de la reconnaissance d'écran, car tout cela peut être trouvé dans la documentation. Au lieu de cela, voici comment analyser ce lien:


 const setupFromQR = data => { const url = new URL(data); // drop the "//" and get TYPE and LABEL const [scheme, label] = url.pathname.slice(2).split('/'); const params = new URLSearchParams(url.search); const secret = params.get('secret'); let counter; if (scheme === 'hotp') { counter = params.get('counter'); } else { stepWindow = parseInt(params.get('period'), 10) * 1000; counter = getTOTPCounter(); } } 

Dans le monde réel, la clé secrète sera une chaîne codée en base 32 (!), Car certains octets peuvent ne pas être imprimables. Mais pour simplifier la démonstration, nous omettons ce point. Malheureusement, je n'ai pas pu trouver d'informations sur la base 32 ou tout simplement sur un tel format. Apparemment, il n'existe aucune spécification officielle pour ce format d'URL, et le format lui-même a été inventé par Google. Vous pouvez en lire un peu plus ici.


Pour générer des codes QR de test, je recommande d'utiliser FreeOTP .


Conclusion


Et c'est tout! Encore une fois, n'oubliez pas de regarder la démo . Il existe également un lien vers le référentiel avec le code qui est derrière tout cela.


Aujourd'hui, nous avons démonté une technologie assez importante que nous utilisons quotidiennement. J'espère que vous avez appris quelque chose de nouveau par vous-même. Cet article a pris beaucoup plus de temps que je ne le pensais. Cependant, il est assez intéressant de transformer une spécification de papier en quelque chose de fonctionnel et de si familier.


A très bientôt!

Source: https://habr.com/ru/post/fr462945/


All Articles