Les vols de comptes, en particulier à cause de l'utilisation de mots de passe trop faibles, sont monnaie courante et la situation ne risque pas de s'améliorer dans le futur. Une solution possible pour se protéger de cette menace consiste à utiliser une authentification multifacteurs. Le nom de compte et le mot de passe seuls ne sont alors plus suffisants pour se connecter, et un code temporaire à usage unique vient renforcer la sécurité. Il existe des matériels et logiciels (sur smartphone) dédiés à cela, mais pourquoi ne pas créer le sien avec une petite touche personnelle ?
Le principe de l'authentification à deux facteurs, ou 2-Factor Authentication (abrégé 2FA), est relativement simple en soi. Votre mot de passe est un facteur et on en ajoute un autre. Il faut donc deux informations secrètes pour vous authentifier et non une seule. Ceci peut être un code, envoyé par SMS sur votre mobile, une clé de sécurité type U2F/FIDO2 comme une YubiKey, ou n'importe quel mécanisme permettant de prouver qui vous êtes, en plus d'un simple mot de passe. L'une de ces solutions repose sur un standard appelé TOTP (Time-based One-Time Password) lui-même construit sur HOTP (HMAC-based One-Time Password), les deux étant respectivement couverts par les RFC-4226 et RFC-6238.
Voilà, d'entrée de jeu, un gros paquet de jargon technique. En pratique, les choses sont beaucoup plus simples : vous disposez d'une application ou d'un petit périphérique qui dispense un code, changeant à intervalle régulier, que vous ajoutez après avoir saisi un identifiant et un mot de passe. Si le code est valide, vous vous connectez, et si ce n'est pas le cas, la connexion sera refusée. Quelqu'un souhaitant « pirater » votre compte, qu'il s'agisse d'un service en ligne, une connexion à un serveur ou quelque chose de local, devra disposer de ce « générateur ». Bien entendu, les codes n'étant valides que durant une plage de temps très réduite, réutiliser un code qu'il aura vu passer ne lui servira à rien.
De nombreux services en lignes permettent de configurer une authentification à deux facteurs et parmi tous les choix possibles, le standard TOTP est celui que l'on trouve le plus fréquemment (après U2F/FIDO2 ou des mécanismes plus spécifiques comme la confirmation via un code mail ou SMS). Un excellent exemple est Google Authenticator, une application Android et iOS, bien entendu utilisable avec les services Google (en méthode alternative d'authentification), mais il en existe tout un tas d'autres. Petit problème cependant, cette application n'est plus open source depuis quelques années [1] et le dépôt GitHub est d'ailleurs archivé. Heureusement, des alternatives existent comme FreeOTP [2], basé sur la dernière version sous licence Apache de Google Authenticator, mais aussi et surtout Aegis Authenticator [3] sous licence GPLv3, les deux étant disponibles en version source, mais également directement installables via les stores habituels (Google Play, App Store, F-Droid, etc.).
Une autre solution possible est totalement matérielle, sous la forme d'un petit périphérique de la taille d'une clé USB, comme celui que nous avons exploré dans le numéro 46 [4] de chez Token2, similaire aux produits ExcelSecu que l'on trouve sur AliExpress, par exemple. Le principal avantage de ce type de solutions matérielles est la sécurité accrue puisque, comme nous allons le voir, l'élément secret au cœur de l'algorithme utilisé ne peut être extrait de ce genre de produit sans le détruire (dispositif anti-tamper), contrairement à une application pour smartphone qui offre une surface d'attaque plus importante. En revanche, plusieurs points négatifs accompagnent ce type d'implémentation matérielle, dont la durée de vie limitée du produit dépendant totalement de l'accu intégré, la taille du code à usage unique généralement limité à 6 chiffres ou encore, pour certains modèles d'entrée de gamme, l'absence de possibilité d'ajuster la configuration à ses besoins ou son niveau de paranoïa (secret partagé, algorithme de hachage cryptographique utilisé, etc.).
L'objectif du présent projet n'est pas de simplement reproduire quelque chose qui existe déjà dans le commerce et est, relativement au gain de sécurité, assez économique. Nous allons, au contraire, viser un objectif permettant d'avoir la souplesse de l'application smartphone, tout en ayant l'indépendance et l'autonomie des tokens TOTP. Attention cependant, ceci est entièrement expérimental dans le sens où cela fonctionne, certes, mais n'est clairement pas plus sécurisé qu'une application Android et très loin des bénéfices d'une solution comme celle proposée par Token2 ou ExcelSecu. Nous utiliserons ici une carte Raspberry Pi Pico ne disposant d'absolument aucune fonction cryptographique matérielle et le secret qui y sera stocké pourra sans trop de difficulté être récupéré, si l'attaquant à un accès physique au montage. Ne prenez pas cela à la légère, dérober le secret sans que l'utilisateur ne le sache est bien pire que de simplement lui voler le périphérique, et donc de rendre la compromission évidente.
C'est peut-être un point que je revisiterai dans l'avenir en utilisant, par exemple, un ESP32-S2 disposant des fonctionnalités nécessaires (voir [5]) ou en utilisant un composant dédié, un secure element, comme l'ATECC508B de Microchip. Cette dernière option est peu probable étant donné que l'accès à la documentation complète, exactement comme avec d'autres fabricants de composants de ce type, est conditionné par un NDA ou Non-Disclosure Agreement, puisque la sécurité par obscurantisme a encore de beaux jours devant elle, apparemment (je vous recommande d'ailleurs le très intéressant, mais vieux, billet à ce sujet sur le blog des créateurs du Trezor Wallet [6]).
1. Qu'allons-nous construire ?
Notre objectif ici sera de créer un montage autonome (comprendre « non connecté » et non « sur batterie ») fonctionnant comme un croisement entre l'application Google Authenticator ou Aegis Authenticator, et un token TOTP. Celui-ci doit être configurable pour supporter différents algorithmes de hachage (SHA-1, SHA-256, SHA-512), avoir un secret partagé modifiable ou encore permettre une mise à jour de l'horloge, conserver cette configuration entre les redémarrages et disposer d'une console via une interface série pour la configuration et l'ajustement des paramètres. Tout ceci sera basé sur une carte Raspberry Pi Pico équipée d'un convertisseur USB/série (je n'ai pas envie de m'amuser avec l'interface USB/CDC soulevant un certain nombre de problématiques), d'un module i2c RTC DS3231 équipé d'une pile bouton et d'un afficheur à LED de 8 fois 7 segments contrôlé par un MAX7219 interfacé en SPI.
Le but n'est pas d'avoir quelque chose de portable, bien au contraire. L'idée, une fois la preuve de concept validée, est de changer d'échelle et d'avoir un dispositif mural avec d'énormes afficheurs LED 7 segments (idéalement 10 chiffres) présentant de manière continue le code à usage unique. Un simple coup d’œil à cet afficheur permettra, à terme, de connaître et saisir le code en question lors d'une connexion à un service ou un autre configuré dans ce sens. Il est parfaitement clair que quiconque voyant cet afficheur sera en mesure de fournir le code en complément du mot de passe. Le dispositif n'a pas pour objet d'être visible en dehors d'un environnement privé ou professionnel avec un accès restreint.
Ce projet, qui est par définition abouti au moment où je rédige cet article, couvre un grand nombre de problématiques intéressantes, tant au niveau de la programmation du RP2040 de la Pico que de l'utilisation de fonctions cryptographiques, de la gestion des dates et heures, des accès concurrents aux variables ou encore des petites subtilités concernant la manipulation de la mémoire flash.
Le code formant la base du firmware est à la fois inspiré d'un autre de mes projets, TinyTOTP [7], faisant peu ou prou la même chose (et plus encore), mais sur PC en reposant sur OpenSSL et de différents éléments externes sous licence MIT, dont un code de Francesco Pantano [8] retravaillé de manière conséquente et un support pour RTC DS3231 développé par Bryan Nielsen [9]. Le tout sera probablement disponible sur mon GitLab [10] au moment où vous lirez ceci. À toutes fins utiles, précisons que la partie stockage de configuration se base sur l'un de mes articles précédent, paru dans le numéro 42, et détaillant comment utiliser une partie de la flash d'une Pico pour émuler une zone EEPROM comme celle dont dispose certains microcontrôleurs Atmel AVR des Arduino (« Arduini », du coup ?).
2. TOTP, HOTP, HMAC et SHA
Avant d'entrer dans le détail de TOTP et donc également de HOTP, il est important de s'arrêter un instant sur la notion de code d'authentification de message (MAC en anglais) et celle de fonction de hachage.
2.1 Hachage
Commençons donc par cette dernière puisqu'elle forme la base de tout le système. Une fonction de hachage prend en entrée un volume variable de données de grande taille et retourne une donnée de taille fixe et aux caractéristiques précises (comme un ensemble de caractères donnés, par exemple). Un excellent exemple d'utilisation ce type de fonctions est la commande Unix md5sum :
Nous utilisons deux fois la commande, tout d'abord sur un fichier texte contenant le mot « coucou » puis sur un fichier contenant des données aléatoires issues de /dev/urandom. Le premier fichier fait 6 octets et le second 1 Mio, mais le résultat est une chaîne de caractères représentant une valeur hexadécimale sur 32 positions, soit 16 octets dans les deux cas. Les deux valeurs de hachage ont une même taille qui est fixe et répondent à des caractéristiques identiques, elles sont composées uniquement de chiffres de « 0 » à « 9 » et de lettres de « a » à « f ». md5sum utilise la fonction de hachage MD5 (pour Message Digest 5).
Mais plus important encore, les deux valeurs de hachage sont différentes et si nous modifions le contenu du fichier test, même très légèrement en y ajoutant simplement « x », la valeur de hachage change totalement :
Autre point important, l'opération inverse consistant à retrouver les données à partir de la valeur de hachage n'est pas possible. Pas plus que de trouver ou composer un second jeu de données qui produira la même valeur de hachage qu'un autre (notion de collision). On parle alors de fonctions de hachage cryptographique et celles-ci sont massivement utilisées pour vérifier l'intégrité des données, comme base pour la signature électronique ou encore pour les codes d'authentification.
Notez que MD5 est aujourd'hui obsolète et depuis 2011 (RFC6151) n'est plus considéré comme une fonction de hachage cryptographique puisqu'il n'est pas résistant aux collisions. Le plus démonstratif exemple est celui de Marc Stevens, mettant à disposition [11] deux fichiers de 64 octets, possédant deux octets de différence et donnant exactement la même sortie avec md5sum. MD5 est depuis remplacé, plus ou moins partout, par des fonctions de hachage conçues par la NSA appelée SHA pour Secure Hash Algorithm regroupant SHA-1, la famille SHA-2 (SHA-224, SHA-256, SHA-384 et SHA-512) et plus récemment la famille SHA-3 (SHA3-224, SHA3-256, SHA3-384 et SHA3-512). Des commandes sont également disponibles sur de nombreux systèmes pour remplacer md5sum : sha1sum, sha224sum, sha256sum, sha384sum, sha512sum...
2.2 HMAC
Une valeur de hachage permet de s'assurer, par exemple, de l'intégrité des données, mais ne donnera absolument aucune indication quant à son authenticité. En d'autres termes, une valeur de hachage confirmera que les données sont les bonnes, mais pas qu'elles proviennent effectivement d'une origine spécifique. Pour régler ce problème, et valider les deux informations, il est possible de combiner une fonction de hachage cryptographique avec un secret prenant la forme d'une clé.
On parle alors de HMAC pour Hash-based Message Authentication Code ou code d'authentification basé sur un hachage. Différents algorithmes existent permettant de combiner une clé, typiquement une série d'octets, avec un message via une fonction de hachage et, bien entendu, un certain nombre d'entre eux sont standardisés et reposent sur des fonctions de hachage cryptographique qui le sont tout autant : HMAC-MD5 (obsolète donc), HMAC-SHA-1, HMAC-SHA-256 ou encore HMAC-SHA-512.
Un HMAC-SHA-1 ressemblera à une valeur de hachage SHA-1, soit une série de 20 octets représentés en notation hexadécimale sur 40 positions, mais il ne s'agit pas simplement d'une donnée permettant de vérifier les données associées. En utilisant le secret partagé entre les deux intervenants, produire à un bout comme à l'autre un HMAC-SHA-1 avec les mêmes données et la même clé donnera le même résultat. Si les données changent ou si la clé n'est pas la bonne, le résultat sera différent et on en déduira que soit les données sont corrompues, soit que l'un des deux intervenants n'est pas celui qu'il prétend être puisqu'il ne dispose pas de la bonne clé.
Comme pour md5sum ou plus exactement sha1sum, nous pouvons rapidement faire la démonstration de l'utilisation d'un HMAC-SHA-1 par exemple, en ligne de commande grâce à OpenSSL :
Ou, pour la version hexa :
Notre donnée de base, ou message, est toujours « coucou », mais nous spécifions dans les options d'openssl que nous souhaitons produire un HMAC en utilisant la fonction de hachage SHA-1 et en utilisant le secret « 12345678 ». Le destinataire de notre message pourra alors faire de même en connaissant le secret partagé et s'assurer que c'est bien moi qui ai envoyé le message, et que celui-ci est bien « coucou ».
Imaginons un instant ce qu'il est possible de faire avec ce genre de choses dans un contexte d'authentification multifacteur. Partons du principe que moi, comme le serveur ou service partageons le fameux secret (« 12345678 ») et qu'à chaque tentative de connexion un tel message authentifié doit être présenté. Bien sûr, si nous ne changeons pas le message, le HMAC sera toujours le même, ça n'a pas d'intérêt. Mais que se passe-t-il si, lui comme moi, maintenons à jour un compteur dont la valeur remplace « coucou » et qui s'incrémente à chaque authentification réussie ? Nous obtenons une solution d'authentification à mot de passe à usage unique, car :
- un attaquant espionnant la communication ne peut déduire ni le secret ni la valeur du compteur à partir du HMAC ;
- le message vient forcément de moi, sinon le HMAC sera différent, car la clé invalide ;
- la validation, et donc l'authentification seront uniques, car le compteur augmente toujours et le message change à chaque fois.
Vous avez donc là le début de ce que permettent des choses comme Aegis Authenticator ou un produit comme ceux de Token2. Mais juste le début...
2.3 HOTP
Le mécanisme hypothétique que je viens d'expliquer pourrait parfaitement fonctionner à condition de saisir votre login, votre mot de passe et quelque chose comme 3bc0ca897a41cd6952f6b0c3489e640252955b99, sans faire la moindre faute de frappe. Ce n'est pas très réaliste. Heureusement pour nous, quelqu'un de plus intelligent a également eu cette idée qui est, depuis, devenue un standard défini par la RFC-4226 [12].
HOTP, pour HMAC-based One-Time Password est l'algorithme permettant de non seulement produire un HMAC (SHA-1 dans la RFC) en combinant le secret partagé avec la valeur du compteur (en big-endian), mais en en découlant ensuite un code de 4 à 10 chiffres (6 par défaut et 8 recommandé) en notation décimale, facile à mémoriser et à saisir par un humain. Pour obtenir ce code, on commence par extraire les 4 derniers bits (bits de poids faible sur les 160 pour SHA-1) du HMAC. Ceux-ci sont ensuite utilisés comme index pour sélectionner 31 bits du HMAC (31 pour éviter que le bit de poids le plus fort ne serve comme bit de signe et que la valeur ne puisse être négative). Cette première opération ressemble à ceci une fois implémentée en C :
Avec digest un pointeur vers le HMAC sous la forme d'un tableau de uint8_t et tthmac la taille du HMAC en octets (20 pour SHA-1, 32 pour SHA-256 et 64 pour SHA-512). Mais ce n'est pas tout, ce bin_code de 31 bits (ou 32 mais forcément positif), est ensuite utilisé pour créer le fameux code qui est le reste de la division de bin_code par 10 puissance le nombre de chiffres à obtenir (opération modulo). Là encore en C, ceci deviendrait :
En résumé, à ce stade nous avons un HMAC du compteur et du secret, une troncature pour obtenir 31 bits et un modulo pour avoir un code de la taille demandée (entre 4 et 10). Si vous avez compris ces étapes, vous avez compris la majeure partie de ce que raconte la RFC-4226 !
2.4 TOTP
Et si là, vous vous dites « mais je n'ai pas besoin d'avoir un compteur, puisque le nombre de secondes écoulées depuis une certaine date en est un qui fonctionne tout seul », bravo, vous venez d'inventer TOTP ou Time-based One-Time Password et pouvez vous attacher à la rédaction d'une RFC en tout point identique à la RFC-6238.
Cette dernière, bien plus courte que la RFC-4226 (8 pages au lieu de 26), décrit tout simplement comment utiliser HOTP, mais en remplaçant le compteur par le nombre de secondes écoulées depuis le 1er janvier 1970 à minuit (également appelée heure Unix ou heure Posix) sur 64 bits (sinon il y aura un problème le 19 janvier 2038 à 03:14:08 UTC. Pour vous faire peur, c'est par ici [13]), mais aussi comment éviter d'avoir un nouveau code à chaque seconde, en introduisant la notion de time-step. En arrondissant l'heure Unix à un intervalle déterminé (30 secondes comme recommandé par la RFC), ceci laisse le temps à l'utilisateur de saisir le code avant qu'il ne change et, dans le même temps, assure une sécurité suffisante pour éviter que quelqu'un réutilise ledit code passé la période en question.
Un autre point qu'il faut adresser lorsqu'on utilise un repère temporel est la désynchronisation des horloges entre plusieurs systèmes. Arrondir à un multiple de 30 secondes ne règle pas le problème et même si les deux systèmes ne sont désynchronisés que de quelques secondes, un code valable pour l'un ne l'est pas forcément pour l'autre. Pour régler cela, la RFC indique qu'il suffit de considérer comme valide un certain nombre de codes après ou avant le code courant et ainsi offrir un peu plus de souplesse. Mieux encore, il devient possible, après validation d'un des codes, de déduire la dérive d'horloge et donc d'utiliser cette information pour avertir l'utilisateur, ajuster automatiquement l'horloge ou déclencher une synchronisation par un autre biais.
Enfin, alors que la RFC-4226 ne parle que de HMAC-SHA-1, la RFC-6238 précise clairement qu'il est possible (MAY) d'utiliser HMAC-SHA-256 ou HMAC-SHA-512, et ce, avec le même mécanisme de troncature et de modulo que pour HMAC-SHA-1.
3. Passons à la pratique
La théorie c'est bien amusant, surtout avec des petits bouts de codes, mais le but du jeu est d'avoir quelque chose de fonctionnel et pratique (peut-être même de réellement utilisable, qui sait). L'objectif est simple, nous allons créer, sur la base d'une carte Raspberry Pi Pico, un montage qui affiche un code TOTP. Ceci implique plusieurs choses, à commencer par le matériel nécessaire :
- Une carte Raspberry Pi Pico parfaitement standard.
- Un module RTC pouvant fonctionner en 3,3 V et s'interfaçant très classiquement en i2c. Celui utilisé ici repose sur un Maxim DS3231 et est initialement prévu pour une Raspberry Pi (grand modèle). Ce genre de module se trouve pour une paire d'euros sur les sites habituels et intègre généralement une pile soudée qui, en fin de vie, demande alors un peu de bricolage pour réanimer le module (il existe des versions avec des piles amovibles, mais c'est plus cher).
- Une solution pour afficher entre 4 et 10 chiffres entre 0 et 9. Typiquement, comme notre projet est censé être fixe, inutile de s'embêter avec quelque chose d'économe en énergie et l'option « LED » vient alors immédiatement à l'esprit. Cherchez simplement quelque chose comme « 7 segment display 8 digit » sur un site tel qu'AliExpress et vous serez submergé d'offres proposant des modules tout faits avec 8 chiffres, pilotés en SPI par un MAX7219 pour moins d'un euro. Vu le prix, prévoyez de suite de jouer du fer à (des)souder pour aligner correctement les deux afficheurs 7 segments à 4 chiffres l'un par rapport à l'autre (c'est à peine visible éteint, mais sous tension, on ne voit que ça). Notez qu'il existe aussi des versions utilisant un registre à décalage 74HC595, étrangement plus cher, mais disponible en d'autres couleurs que rouge. Ceci n'est pas pris en charge par mon code pour le moment.
- Un module USB/série permettant de faire dialoguer le montage avec un PC pour obtenir des informations et surtout configurer les différents paramètres comme la date/heure, le secret, le nombre de chiffres du code, le type de HMAC, etc. On pourrait se passer temporairement ou définitivement de ce composant en utilisant l'USB OTG du RP2040 ou en n'ayant recours à l'interface série que ponctuellement (pour le pénible changement d'heure saisonnier, par exemple). Je préfère cependant la version UART pure et dure, en particulier avec un projet aussi complexe impliquant l'utilisation des deux cœurs ARM du RP2040.
Deux cœurs ? Oui, c'est ici l'occasion d'expérimenter quelque chose de trop souvent laissé de côté. Le microcontrôleur RP2040 dispose d'un processeur ARM avec deux cœurs Cortex-M0+ à 133 MHz et 264 Kio de SRAM intégrés et le fait d'avoir, en parallèle, une fonction périodique pour la génération du code et une gestion de la communication série est une opportunité parfaite de diviser le travail et d’exploiter au mieux le matériel.
L'architecture générale du code est assez simple, nous allons classiquement avoir une boucle principale exécutée juste après la phase de configuration/initialisation des composants. Celle-ci se chargera d'agir en fonction de l'état d'une paire de variables et, en particulier, d'un « drapeau » déclenchant l'accès à la RTC et la génération du code TOTP. Ce drapeau sera levé, à intervalle régulier, via un timer appelant une fonction callback. Tout ceci se passera sur le premier cœur, alors que sur le second tournera une autre boucle infinie, chargée de scruter les caractères provenant du port série et de traiter les demandes de l'utilisateur. Personnellement, je suis encore et toujours émerveillé (et enchanté) qu'on puisse envisager ce type de structure avec une carte fabriquée en Europe continentale, vendue seulement 5 € et disposant d'un SDK léger, souple et compréhensible, contrairement à d'autres (*tousse* STM32CubeMX *tousse*).
3.1 HMAC, SHA et HOTP
PicoTOTP, c'est le nom que j'ai donné à ce projet [14], n'est pas ma première expérience dans l'univers de l'authentification deux facteurs, et TOTP en particulier. Il s'agit, en effet, d'un descendant d'un autre projet nommé TinyTOTP. TinyTOTP [7] est un outil fonctionnant sur plateforme GNU/Linux ou [Free|Open|Net]BSD. Il se base initialement un code de Francesco Pantano (« c_otp » [8]) et se veut être un outil en ligne de commande permettant de générer et valider des codes d'authentification TOTP. TinyTOTP repose initialement sur OpenSSL pour ce qui relève des fonctions cryptographiques (HMAC) puisqu'il s'agit de quelque chose d'omniprésent sur les systèmes Unix open source. Cependant, l'idée germant de porter ce code vers un microcontrôleur 32 bits, j'ai créé une branche spécifique du projet (« nossl ») se détachant de cette dépendance à OpenSSL et celle-ci peut être vue comme une version préliminaire à ce dont il est question ici.
La principale difficulté qu'on rencontre lorsqu'on n'a plus accès aux facilités offertes par OpenSSL est de trouver des implémentations fiables des algorithmes dont on a besoin. Car, voyez-vous, il est excessivement risqué de s'improviser spécialiste en sécurité et de jouer les apprentis sorciers en voulant implémenter, à partir de rien, ces algorithmes. Fort heureusement, un certain nombre d'implémentations éprouvées existent et utilisent des licences permettant de les intégrer sans problème dans un nouveau projet. C'est le cas, par exemple, de l'implémentation de Simon Tatham pour PuTTY, qu'on retrouve un peu partout dans de très nombreux dépôts GitHub/GitLab, et c'est bien entendu, celle que j'ai choisi d'utiliser, puisque ne reposant que sur les fonctions classiques de la libc.
Le code de Francesco Pantano portant sur la génération HOTP/TOTP a donc été adapté, trituré et révisé pour devenir tout d'abord TinyTOTP, puis TinyTOTP branche « nossl » et enfin un élément important de PicoTOTP. Et tout cela commence par la fonction chargée de générer les HMAC :
Les fonctions hmac_sha256(), hmac_sha512() et hmac_sha1() proviennent toutes du code de PuTTY, intégré au projet sous la forme de fichiers sha256.c, sha256.h, sha512.c, sha512.h, sha.c et sha.h. Aucune modification nécessaire, c'est du C bien propre comme on l'aime. Remontons alors d'un cran avec la fonction générant effectivement le code HOTP :
DT() a été vu précédemment, et le modulo est directement intégré (plutôt qu'un appel inutile à une fonction). Le uint32_t est le code HOTP résultant de l'opération, avec key un pointeur vers les uint8_t du secret, klen sa taille, interval le compteur, digits le nombre de chiffres en sortie et tthmac le type de HMAC utilisé, avec :
Une seconde ! HOTP ?! Oui, rappelez-vous, TOTP n'est rien d'autre que HOTP, mais avec interval correspondant au nombre de secondes écoulées depuis le 01/01/1970 à minuit. La fonction nous permettant de faire du TOTP est donc la même, mais Francesco a préféré créer une fonction dédiée pour éviter la confusion (oui, on pourrait aussi utiliser l'attribut alias, mais je trouve que cela rendrait le code moins lisible) :
Vous remarquerez que, partout, nous utilisons le nombre de chiffres du code à obtenir en argument alors que, matériellement, nous en avons une quantité fixe (8). Ceci est fait dans le but de rendre (ou laisser) le code générique et adaptable, en plus de permettre, éventuellement, l'utilisation d'une partie seulement des afficheurs 7 segments.
Tout ceci est placé dans des fichiers sources trouvant place dans un sous-répertoire libs/ du projet et, dans notre main.c, nous finalisons le tout en créant la vraie fonction de génération TOTP :
C'est ici que nous utilisons la date/heure de l’heure Unix tirée de la RTC, arrondie au time-step précisé par la macro VALIDITY (qui vaut 30). Et voilà qui nous amène donc directement à la partie concernant l'utilisation du DS3231.
3.2 Gestion de la RTC
La partie RTC utilise le code de Bryan Nielsen sans trop de modifications. Il est d'ailleurs assez étonnant de constater la rareté de ce genre de support alors même que, du côté d'Arduino, il existe une profusion de bibliothèques permettant de gérer DS1307, DS1338, DS3231, etc., et ce, de manière très complète (avec alarme et NVRAM). L'intégration dans le projet se fait sans aucune difficulté en copiant simplement ds3231.c et ds3231.h dans notre libs/ et en incluant le header file dans notre main.c. Ceci nous donne accès aux fonctions d'initialisation, de lecture et d'écriture dans les registres du DS3231.
Quelques lignes sont alors suffisantes pour prendre en charge le composant :
Lire la date et l'heure dans le DS3231 est tout aussi facile, comme le montre la fonction totp() présentée ci-devant. Mais nous allons rencontrer un problème d'accès concurrent au matériel. En effet, nous allons mettre en place un timer avec add_repeating_timer_ms() et en argument, une période de répétition et une fonction callback ressemblant à :
La variable globale gentotp, déclarée avec volatile bool gentotp = false; sera ensuite utilisée dans la boucle principale pour savoir s'il faut rafraîchir l'afficheur et donc faire un nouvel appel à totp(). Mais, en parallèle, nous aurons sur le second cœur une interface utilisateur permettant entre autres de configurer la date/heure dans le DS3231. Faire le pari qu'un accès simultané au composant par les deux morceaux de code est peu probable serait une erreur grossière. Nous avons là un cas typique d'accès concurrent à une ressource et il existe une solution tout aussi typique : un mutex.
Un mutex, pour MUTual EXclusion, est une primitive de synchronisation qu'on peut facilement illustrer dans la vie réelle. Imaginez une ressource comme des toilettes dans une station-service et une clé pour y accéder. Si vous voulez utiliser les toilettes, vous demandez la clé et si elle n'est pas déjà empruntée par quelqu'un, vous la recevez, empêchant quiconque d'autre d'utiliser les lieux en même temps que vous. Lorsque vous avez fini vos petites affaires, vous rendez la clé (après vous être lavé les mains) et les toilettes sont à nouveau disponibles pour quelqu'un d'autre.
Dans notre code, les toilettes représentent l'accès à la RTC et le SDK Pico met à notre disposition quelques fonctions intéressantes. Dans un premier temps, nous avons besoin du mutex lui-même :
Qui sera initialisé dans main() avec :
Dès lors, notre boucle principale dans main() ressemblera à ceci :
mutex_try_enter() retourne VRAI si la tentative d'avoir la propriété du mutex réussit. De ce fait, l'exécution du corps de l’if implique que nous nous sommes approprié le mutex et pouvons utiliser totp() et donc accéder à la RTC, afficher le code, mettre à jour gentotp et rendre le mutex. Si ce n'est pas le cas, parce qu'un autre morceau de code possède la propriété du mutex, ce n'est pas grave, nous ferons autant de tours dans la boucle que nécessaire jusqu'à ce que le mutex soit disponible.
Par ailleurs, dans la gestion de l'interface série utilisateur que nous verrons plus loin, nous retrouvons le même genre d'approche :
Ici, l'appel est bloquant et l'accès à la RTC, en écriture cette fois, ne pourra se faire qu'une fois le mutex libéré. Là encore, ce n'est pas un problème, si la RTC est accédée par main(), nous attendons simplement que l'affichage soit fait pour enregistré la date/heure.
3.3 Gestion de l'affichage
Vous avez sans doute remarqué l'appel à max7219dispall(code) dans la boucle principale et cette fonction provient directement d'un exemple livré avec le SDK Raspberry Pi Pico (pico-examples/spi/max7219_8x7seg_spi/max7219_8x7seg_spi.c). Le fait que cet exemple corresponde précisément au circuit utilisé sur le module en ma possession (qui est un reste du projet lié à Home Assistant dans un précédent numéro [15]) est un pur hasard mais une véritable aubaine. Le support du MAX7219 est sommaire, mais amplement suffisant ici avec une initialisation sensiblement modifiée et prenant en compte un paramètre réglant l'intensité des LED (entre 0 et 15) :
La fonction max7219dispall() précédemment utilisée est relativement simple et se contente d'écrire les registres correspondant aux 8 chiffres dans une boucle :
À ce stade du projet, c'est suffisant, mais ce code fera l'objet d'une réécriture pour supporter davantage de fonctionnalités, voire de modules d'affichage. L'une des pistes que je compte suivre est, par exemple, le fait d'utiliser les points des afficheurs 7 segment comme indicateur du temps de validité du code, à l'instar de l'application Aegis Authenticator ou des produits Token2.
3.4 Utilisation de la flash pour la configuration
Il est temps à présent de se pencher sur la partie que j'ai trouvée la plus intéressante à développer et qui demandera encore quelques affinages futurs : la configuration. Comme spécifié précédemment, l'idée n'est pas d'avoir un montage statique avec des paramètres ou un secret stockés « en dur » dans le code, mais de permettre une (re)configuration via l'interface série. Bien entendu, ceci ne présente que peu d'intérêt si la configuration ne survit pas à une coupure d'alimentation. Une option envisageable aurait été de stocker ces informations dans la mémoire non volatile (NVRAM) de la RTC, mais la DS3231 n'en dispose pas. Seule la DS1307 semble posséder un espace mémoire maintenu par la pile bouton, mais ce composant ne fonctionne qu'avec un VCC de 4,5V minimum et 56 octets ne sont, de plus, pas suffisants pour nos besoins.
Nous voulons stocker dans la configuration le type de HMAC, le nombre de chiffres du code TOTP à obtenir, mais aussi et surtout le secret et sa taille en octets. Si nous choisissons arbitrairement une taille maximum du secret à 64 octets (512 bits), nous avons besoin de quelque chose comme 80 octets en tout devant survivre à une coupure ou un reset (davantage en augmentant la taille maximum du secret).
Le RP2040 ne disposant pas d'une zone EEPROM comme les AVR des Arduino, nous devons stocker cela en flash. C'est là quelque chose que nous avons déjà abordé en explorant la plateforme Raspberry Pi Pico dans le numéro 42 [16]. L'astuce consiste à modifier l'organisation mémoire utilisée par la chaîne de compilation de manière à se ménager un petit espace, en flash, qui n'est pas utilisable pour le programme. Ceci se fait en utilisant un script personnalisé pour l'éditeur de liens et un argument spécifique dans notre CMakeLists.txt :
Le fichier memmap_custom.ld, placé dans le répertoire du projet, est copié du SDK et modifié ainsi :
Nous nous ménageons donc un espace de 4096 octets en fin de flash pour y stocker ce que bon nous semble. Dans le précédent article sur le sujet, nous n'avons pas été confrontés au moindre problème, et pour cause : nous n'avions utilisé qu'un seul cœur. Ici, les choses sont un peu plus intéressantes. Je ne vais pas paraphraser ici ce qui a été détaillé dans le précédent article pour passer directement à la pratique. Avant toute chose, nous avons besoin des symboles provenant du script de l'éditeur de liens :
Ceci fait, nous avons accès aux données en flash et pouvons créer une structure pour stocker notre configuration :
Nous reviendrons dans un instant sur ce membre checksum, mais avant, jetons un œil à la fonction qui récupère les données de la flash :
Et celle qui les enregistre :
Il s'agit là principalement de copies de données en prenant en compte que l'adresse des données en flash est relative à l'espace d'adressage tel que vu par le programme, et surtout qu'il est capital de suspendre des interruptions avant écriture. Nous n'avons que 80 octets à écrire, mais la flash n'est pas accédée comme la SRAM en écriture. Il faut effacer la zone puis l'écrire, et ce, en utilisant des multiples de 4096 et 256 octets. Techniquement, il n'est pas nécessaire ici d'allouer et d’écrire les 4 Kio après copie des données puisqu'une écriture de 256 octets suffirait. Cependant, tout réécrire simplifie le code et nous permet de changer la structure à l'avenir sans rencontrer de problème (parce qu'on va forcément oublier ce point). On peut envisager de supporter plusieurs configurations par exemple, ce qui serait assez amusant (en multipliant des afficheurs).
Au démarrage, il nous suffit de lire la configuration et c'est là qu'intervient le fameux checksum. En effet, comment savoir si les données sont programmées ou non la première fois ? On pourrait envisager une configuration par défaut, mais ceci supposerait de flasher deux fois la Pico (une fois avec la configuration qui se copie en flash et une seconde fois, sans cette étape). Ma solution est de tout simplement ajouter une somme de contrôle (pour l'instant uniquement sur le secret) en partant du principe que, si elle est invalide, la configuration ne sera pas utilisée, le code TOTP pas calculé et que l'utilisateur devra utiliser la console série pour configurer (ou reconfigurer) le montage.
Notre checksum est un CRC32b relativement courant :
Et le début de notre main() se verra étoffé de :
Mais ce n'est pas tout. Nous avons, ou dans le déroulé de l'article « allons avoir », un gros problème parfaitement exposé dans une discussion sur le forum Raspberry Pi [17] : « if the 2nd core is active and you do anything to the flash, it will likely crash ». Il ne suffit pas, en effet, de désactiver les interruptions pendant l'effacement et l'écriture, il faut que cet accès soit exclusif. Or, contrairement à l'article du numéro 42, nous avons ici deux codes fonctionnant en parallèle et, effectivement, tenter d'utiliser saveconfig() plante totalement l'exécution (sauf, étrangement, si les données sont identiques).
La technique consiste donc à n'écrire en flash qu'après avoir arrêté l'autre cœur et pour ce faire, j'ai décidé d'utiliser un autre flag de manière à transférer cette opération de la boucle traitant les commandes utilisateur vers celle présente dans main(). Ainsi, si l'utilisateur désire enregistrer la configuration, nous commençons par permettre l'arrêt du cœur avec multicore_lockout_victim_init() avant de passer saveflag à true. C'est ensuite dans main() que tout se joue, avec une nouvelle version du while() infini :
multicore_lockout_start_blocking() ne pourra être utilisé que sur un cœur ayant accepté de se faire arrêter (via multicore_lockout_victim_init()) et nous embrayons directement sur l'écriture de la configuration avant de provoquer un reset et donc relire et valider la configuration. Pour cela, il suffit d'activer le watchdog puis de ne rien faire pendant plus de 250 ms. Cette valeur arbitraire est choisie afin de laisser le temps aux messages d'arriver sur la console série, c'est purement cosmétique.
Ce problème réglé, nous pouvons enfin nous pencher que la partie la plus interactive...
3.5 Console série
Le code permettant la gestion des échanges avec l'utilisateur sera stocké dans console.c et la fonction contenant la boucle principale, console(), sera appelée depuis main() juste avant le while() qui vient d'être listé, avec multicore_launch_core1(console). Cette fonction se résumera à un while(1) dont le corps est un énorme ensemble d'if/else et de switch/case. Nous divisons le jeu de commandes en deux catégories, celles permettant l'affichage d'informations, composées d'une unique lettre sans argument et celles modifiant la configuration active.
La libc utilisée par la chaîne de compilation ARM, et donc le SDK Pico, ne met pas à disposition de fonction permettant de traiter des entrées. La première chose à faire est donc de créer une fonction getline() pour créer une chaîne de caractères à partir des char obtenus, un à un, depuis l'UART :
buf sera déclaré dans console() et possédera une taille fixe permettant de supporter toutes les commandes que nous avons à créer (y compris la configuration du secret) :
S'en suit un simple test via strlen(buffer), si c'est un unique caractère, alors c'est une commande de consultation, sinon c'est une modification avec argument. Les commandes d'une lettre sont l'affichage de la date (d), la même chose mais en heure Unix (t), l'affichage du code TOTP (c), du HMAC utilisé (h), du nombre de chiffres du code (n), du secret (k), et de l'aide (?). À cela s'ajoute la réinitialisation de la configuration (z), l'enregistrement en flash avec redémarrage (s) et le redémarrage seul (r). Tout ceci prend la forme d'un simple switch/case ne présentant aucune difficulté particulière.
C'est du côté de la modification de la configuration que les choses deviennent plus intéressantes puisqu'il faut gérer les arguments. Ces commandes utilisent les mêmes lettres que pour la consultation, mais sont complétées d'informations directement accolées (n affiche le nombre de chiffres, mais n6 change cette valeur à 6). La plupart des commandes ne posent pas de souci particulier puisqu'il suffit de détecter un simple chiffre ASCII qui, déduit de 48, nous donne directement la valeur à utiliser. D'autres ont besoin d'une conversion de chaîne vers un entier, comme t pour définir date/heure en temps Unix :
Pour le secret (commande k), les choses se compliquent un peu, car nous devons convertir la chaîne en un tableau de valeur 8 bits. Pour cela, nous créons une fonction spécifique :
La fonction pourrait être plus simple, mais nous choisissons de supporter la présence d'espaces et de tabulations dans la chaîne afin de faciliter la saisie manuelle (même si un copier-coller est plus que probable). Nous traitons alors les caractères reçus par groupes de deux que nous vérifions avec isxdigit() pour nous assurer qu'il s'agit bien de notation hexadécimale, avant de convertir avec sscanf(). À la moindre erreur, la fonction retourne 0 qui indique la taille du tableau et donc du secret final.
Mais la partie la plus pénible est sans le moindre doute le traitement d'une date et heure fournie dans un format humainement intelligible. Pénible, jusqu'à ce qu'on se rappelle l'existence de strptime() et on peut alors composer cela ainsi :
Et on se heurte alors à un problème, puisque le compilateur précise immédiatement que nous avons là une déclaration implicite de la fonction, alors même que nous avons correctement inclus time.h (via libs/ds3231.h). La page de manuel de la fonction précise qu'il faut définir la macro _XOPEN_SOURCE, mais là encore, ce n'est pas suffisant. En réalité, vous devez en définir deux en tout début de source :
Et pour finir le tout, il ne nous reste plus qu'à ajouter une fonction pour établir une configuration par défaut :
Et une autre pour afficher le code TOTP en console avec une légère mise en forme :
Conclusion
Comme me l'a fait remarquer une personne qui se reconnaîtra sur Twitter, l'un des principes de base de TOTP est aussi d'avoir l'authenticateur sur soi, comme on a son mot de passe dans sa tête (en principe). Mais on peut également voir au-delà du concept initial en se détachant de la notion de « prouver qui on est » pour plutôt aller vers le « prouver où on est », et ce, indépendamment de la méthode de connexion (Wi-Fi, Ethernet, 4G, etc.). Ainsi, avec un tel afficheur dans un lieu sécurisé, il est parfaitement possible de restreindre l'accès à un service et le rendre dépendant d'une présence physique à cet endroit donné. Le tout, en permettant à certaines personnes de tout de même y accéder de n'importe où, en utilisant, par exemple, Aegis Authenticator sur leur smartphone, avec une configuration identique. Eh oui, ceci revient plus ou moins à avoir un périphérique de type Token2 C302-i rangé dans un tiroir sur place, mais sans les disputes à propos de « qui l'a mal rangé où » et sans le risque de vol, de disparition ou de perte.
Bien entendu, cela ne règle pas le principal problème de ce type de création « maison », à savoir le manque de sécurité matérielle. J'ai choisi de mettre à disposition une commande permettant d'afficher le secret via la liaison série pour une raison très simple : si vous avez accès au montage, rien ne vous empêche de lire la flash et d'extraire le secret pour l'utiliser par ailleurs. Ceci n'est absolument pas sûr et une version « industrielle » devra trouver une solution au problème. Une option possible consiste à migrer du microcontrôleur RP2040 vers quelque chose offrant davantage de fonctionnalités, en particulier côté cryptographie. Les ESP32-S2 et S3 (Xtensa), ainsi que C3, C6 et H2 (RISC-V) offrent ce type de chose en mettant à disposition non seulement des fonctions cryptographiques matérielles (dont HMAC SHA-256), mais en permettant de stocker les clés de façon sécurisée. D'autres constructeurs proposent également ce type de spécifications pour leurs microcontrôleurs, dont les STM32.
Ensuite, dans les évolutions possibles du projet, nous avons également pas mal de largesse. Certaines d'entre elles seront même probablement implémentées au moment où vous lirez cet article, puisque le développement n'est aucunement arrêté à ce stade. Parmi les possibilités envisageables, nous avons le fait d'ajouter des commandes et d’étoffer la configuration, en incluant par exemple la luminosité de l'afficheur. Il est également possible d'utiliser les points des afficheurs comme indicateur temporel de validité du code TOTP. Mais, par-dessus tout, une évolution majeure pourrait être le support de plusieurs configurations et non d’une seule. Ainsi, le montage serait complété d'autres afficheurs à LED et la configuration divisée en plusieurs structures sous la forme d'une liste chaînée. On pourrait alors voir s'afficher un groupe de 8 chiffres par configuration, avec un secret et un HMAC différent pour chacun d'eux.
Et enfin, cette réalisation m'ayant donné envie d'explorer les fonctionnalités cryptographiques malheureusement absentes du RP2040, je pense probable qu'un portage vers ESP32 (ou peut-être STM32) voie le jour, à un moment ou un autre. La disponibilité du Wi-Fi pourrait permettre, de plus, une parfaite synchronisation en utilisant un protocole comme NTP pour corriger d'éventuelles dérives d'horloge. À suivre, donc...
Références
[1] https://github.com/google/google-authenticator/wiki
[2] https://freeotp.github.io/
[3] https://github.com/beemdevelopment/Aegis
[4] https://connect.ed-diamond.com/hackable/hk-046/creons-un-outil-de-configuration-pour-un-token-totp
[5] https://docs.espressif.com/projects/esp-idf/en/latest/esp32s2/api-reference/peripherals/hmac.html
[6] https://blog.trezor.io/introducing-tropic-square-why-transparency-matters-a895dab12dd3
[7] https://gitlab.com/0xDRRB/tinytotp
[8] https://github.com/fmount/c_otp
[9] https://github.com/bnielsen1965/pi-pico-c-rtc
[10] https://gitlab.com/0xDRRB
[11] https://marc-stevens.nl/research/md5-1block-collision/
[12] https://www.ietf.org/rfc/rfc4226.txt