Nous avons déjà, par le passé, exploré les technologies NFC [1] dans les pages du magazine, et récemment, fait connaissance avec la programmation d'un petit outil en ligne de commandes pour les tags ST25 de chez STMicroelectronics [2] ou encore détourné un jouet pour enfant [3] basé sur cette même technologie. Il est temps maintenant de se pencher sur quelque chose d'un peu plus sérieux, un peu plus complexe et surtout d'un peu plus utile : un outil permettant de configurer un token NFC dédié à l'authentification à deux facteurs.
Pour comprendre de quoi nous allons parler, il est nécessaire, avant tout, de rapidement décrire ce qu'est une authentification à deux facteurs. Déjà, éliminez de votre esprit toutes images en rapport, de près ou de loin, avec un quelconque service postal et/ou une personne à vélo. Plus sérieusement, également appelée double authentification ou encore vérification en deux étapes, souvent abrégée 2FA (pour 2-Factor Authentication), il s'agit là une méthode d'authentification dite « forte » où l'utilisateur doit utiliser deux méthodes différentes pour prouver son identité. La première, très classique, est celle reposant sur l'utilisation d'un mot de passe, par définition secret. La seconde, consiste à fournir un code, à usage unique, que lui seul peut avoir généré.
Google Authenticator est le parfait exemple d'une authentification à deux facteurs, car il est très connu. Ainsi, lorsque vous tentez de vous connecter à un service, vous devrez fournir votre nom d'utilisateur et le mot de passe correspondant, mais également, juste après, un code à 6 chiffres qu'une application dédiée sur votre smartphone aura généré. Si les deux authentifications réussissent, vous serez connecté. Si la seconde échoue, c'est que vous n'êtes pas celui ou celle que vous prétendez être.
On parle d'authentification forte, car un simple mot de passe peut être volé, capturé, espionné ou tout simplement deviné. Mais le code généré par l'application nécessitera que la personne malveillante, soit dérobe votre téléphone, soit en extraie le secret permettant de générer le code, qui par définition change très fréquemment. Car c'est justement là tout l'intérêt du système. Il ne s'agit pas d'un second mot de passe, mais d'un code qui est différent à chaque demande (HOTP - RFC4226) ou en fonction du temps qui passe (TOTP - RFC6238).
Tout le monde, ou presque, possède un smartphone d'une marque ou d'une autre (avec Android ou iOS), c'est donc une solution simple ne nécessitant pas d'investissement supplémentaire. Google Authenticator n'est d'ailleurs pas non plus la seule application utilisable, puisque le système repose sur un standard ouvert (spécifié par l'OATH) parfaitement utilisable d'un côté ou de l'autre du miroir (pour générer ou valider le code). Vous pouvez donc parfaitement utiliser une application open source comme le fantastique Aegis Authenticator [4] pour Android, pour vous authentifier de cette manière sur n'importe quel service qui propose une authentification à deux facteurs OATH, y compris ceux de Google.
Il ne faut cependant pas oublier qu'un smartphone n'est rien d'autre qu'un ordinateur dans votre poche et qu'en tant que tel, il souffre des mêmes problèmes de sécurité que n'importe quel système. Autrement dit, si on a accès à votre appareil et qu'on dispose des compétences, rien n'empêche la personne malveillante d'extraire le secret utilisé pour générer ces codes et donc de le faire à votre place.
La solution à ce problème consiste à investir quelques euros dans un matériel dédié, se présentant sous la forme d'un token, d'un porte-clés ou d'une carte, équipés d'un écran (LCD ou e-paper) et d'un bouton. À l'appui sur le bouton, un code est généré et affiché, et pourra être saisi lors de l'authentification. La différence entre un tel matériel et votre smartphone tient dans la sécurisation du produit. Basiquement, il est déjà beaucoup plus difficile d'extraire des données, et ce type de produit est généralement conçu pour non seulement rendre l'opération très difficile, mais également pour détruire toute information sensible à la moindre tentative. En pratique, on parle de Tamper Protection et cela induit une conséquence fâcheuse, mais nécessaire : ces produits sont scellés et « piégés », on ne peut pas les ouvrir et... on ne peut pas changer la pile/batterie. Ils possèdent donc une durée de vie limitée (entre 4 et 7 ans en fonction de l'usage) et se périmeront inéluctablement.
Ce que font effectivement ces produits en interne, et la manière de développer une application qui fait de même (ou vérifiera les codes générés) sort du cadre de cet article. Je couvrirai plus en détail et plus techniquement ce que fait un token TOTP, et comment développer un outil capable de générer/valider de tels codes dans une autre publication (GNU/Linux Magazine France 262, très certainement). Ici, nous allons nous pencher sur le matériel lui-même et nous intéresser à la façon de lui communiquer sa configuration.
En effet, il existe deux types de produits :
- ceux configurés d'usine et intégrant un secret qui pourra vous être transmis lors ou après l'achat, via un canal sécurisé ;
- et ceux, configurables, qu'il est possible de programmer afin d'utiliser un secret arbitrairement choisi et des paramètres propres à l'utilisateur ou au service qu'il utilise.
J'ai déjà utilisé, à plusieurs reprises, le mot « secret » dans ce début d'article, mais de quoi s'agit-il ? Le code qui est généré n'est, bien entendu, pas aléatoire. Il découle d'un calcul fait à partir de l'heure courante (le matériel intègre une RTC) et d'un ensemble d'octets constituant les données secrètes, comme une phrase passe. C'est cette information qui est soit configurée dans une application comme Aegis Authenticator, soit dans un token programmable, soit inscrit d'usine dans un token « classique ». Souvent, ce code est présenté sous la forme d'un code QR, mais il s'agit simplement d'une suite de valeurs (octets) pouvant également prendre la forme d'une série en notation hexadécimale ou, le plus souvent, encodée en base32 [5].
Ce qui nous amène précisément au sujet du présent article, « programmer le token » pour lui spécifier :
- le secret à utiliser, généralement appelé « graine » (seed) ou clé ;
- la date et l'heure actuelles (puisque tout doit être correctement synchronisé, même si le protocole supporte certaines tolérances) ;
- le mode de calcul, qui repose sur l'utilisation d'une fonction de hachage cryptographique ou HMAC (SHA-1, SHA-256 ou parfois SHA-512) ;
- et la période de changement du code (30 secondes par défaut, mais 60 s est une option existante).
1. Les tokens
Il existe plusieurs types et formes de token TOTP et, bien entendu, plusieurs fabricants. Personnellement, j'ai jeté mon dévolu sur plusieurs produits de l'entreprise suisse (Genève) Token2 [6] proposant une sélection intéressante de matériels possédant différentes caractéristiques attrayantes. À toutes fins utiles, et même si cela semblera évident pour le lecteur de longue date, je préciserai qu'il s'agit d'un achat et qu'il n'existe aucun arrangement particulier avec cette entreprise. Tel est le prix de l'indépendance et de l'objectivité rédactionnelles.
Le matériel acquis est le suivant :
- un modèle c203 SHA256 [7] n'entrant pas dans le champ d'application de cet article puisqu'il s'agit d'un modèle non programmable ayant donc une graine fixe définie à l'usine (17 €) ;
- un C302-i [8], programmable, affichant un code à 6 chiffres sur écran LCD, supportant une graine jusqu'à 63 octets, avec un hachage cryptographique configurable SHA-1 ou SHA-256. Sa durée de vie est donnée entre 5 et 6 ans et l'ajustement de la date/heure (et des autres paramètres) n'invalide pas la graine déjà configurée (22 €) ;
- et un OTPC-P2-i [9] reprenant les caractéristiques du modèle précédent, mais sous forme d'une carte au format ISO 7810 ID-1 (85,60 mm x 53,98 mm avec une épaisseur de 0,84 mm), avec un afficheur e-paper (sexy), une durée de vie de 4 à 5 ans et une synchronisation temporelle invalidant la graine déjà configurée (il faut donc reprogrammer la graine en même temps que le réglage d'horloge). À noter que ce modèle n'est pas donné comme étant « Tamper Evident / Tamper Protection » (22 €).
Les deux modèles programmables s'accèdent via NFC pour ajuster les paramètres. Il n'existe aucune autre interface de communication avec le produit et la programmation se fait, en principe, en utilisant l'application Android dédiée [10] et iOS [11] ou un autre outil proposé par le « fabricant » [12]. Un script Python est également proposé, token2_config.py, mais celui-ci n'est pas publiquement disponible et uniquement fourni à la condition d'acheter un ou plusieurs tokens programmables et le périphérique USB NFC proposé par Token2 [13]. Le produit est une simple clé USB construite autour d'une puce NXP PN533, compatible LibNFC et avec nfcpy, le module Python standard pour le support NFC.
Qu'on adhère ou non avec une telle approche commerciale importe peu. Le fait est que le script n'est proposé qu'à ces conditions et qu'il ne permet d'utiliser que le périphérique USB en question (vID/pID 04cc:2533). Étant donné que la licence (Fair Source License) associée au script ne permet pas (semble-t-il) d'apporter des modifications et de redistribuer le code, l'approche consiste donc à réimplémenter un outil équivalent, supportant d'autres lecteurs NFC (y compris interfacés en liaison série, SPI ou i2c) via la LibNFC. Ceci permet de non seulement parfaitement comprendre le protocole utilisé (que je suppose non exclusif à ces produits), mais également de corriger certains bugs présents dans le code Python (cas particulier des graines de plus de 32 octets) qui ont été remontés à Token2 via leur système de ticket/support.
Les expérimentations menées pour cet article font apparaître que la configuration des tokens passe par une phase d'authentification préalable, reposant sur l'utilisation d'une clé de 16 octets, stockée dans le script, et d'un algorithme de chiffrement SM4 (alias ShangMi 4) non ISO, initialement développé par le bureau des standards chinois [14]). SM4 est d'ailleurs utilisé exclusivement pour tous les aspects cryptographiques de la communication NFC avec ces tokens (pas de DES, pas d'AES). Dans le script, la clé est parfaitement accessible (même si étrangement chiffrée, avec Fernet [15] et une autre clé figurant en clair dans le code) et désignée par la variable customer_key. Après prise de contact avec le service client de Token2, qui a promptement pointé une page du site qui m'avait échappé, il s'avère que cette clé n'est en rien secrète, elle est d'ailleurs affichée sur la page en question [16]. Le fin mot de l'histoire est que plusieurs produits partagent la même technologie (Java-chips), mais que les simples tokens programmables comme le C302-i ne possèdent pas un firmware capable de permettre la modification de cette clé par l'utilisateur (contrairement aux tokens « multiprofils »). Ainsi plutôt que de retirer complètement la routine d'authentification, ce qui aurait augmenté le prix du produit, la clé par défaut est tout simplement publique, ce qui, comme nous le verrons dans la suite, ne pénalise en rien la sécurité des tokens.
Ceci étant dit, l'objet de l'article n'est pas de décrire l'outil, mais de parler de son implémentation et des différentes étapes très intéressantes (et parfois étranges) de la communication entre l'hôte et le périphérique NFC. Ceci est également une opportunité pour explorer une des manières d'implémenter un tel utilitaire et les surprises que l'on peut rencontrer dans une telle activité.
Je préciserai également que nous ne couvrirons pas ici l'ensemble des fonctionnalités de configuration, mais uniquement quelques points spécifiques dont la collecte d'informations, le chiffrement, l'authentification, la manipulation de la graine ou encore le décodage base32. Je vous recommande également la lecture de l'article paru dans le numéro 42 [2] où j'ai couvert les bases de l'utilisation de la LibNFC avec les tags NFC type 4 ST25TA de STMicroelectronics.
2. La base : obtenir des informations du token
Je ne reviendrais pas outre mesure ici sur la manière d'initialiser la libNFC ou d’entamer le dialogue avec le ou les tags. Ceci a déjà été traité dans l'article sur les ST25TA [2]. Nous allons, au contraire, construire sur l'existant, et en particulier sur les deux fonctions que nous avions développées pour envoyer des messages (APDU pour Application Protocol Data Unit) aux tags :
- cardtransmit(nfc_device *pnd, uint8_t *capdu, size_t capdulen, uint8_t *rapdu, size_t *rapdulen, int notimeerr) : celle-ci se charge d'envoyer un APDU au tag, spécifié sous forme d'un tableau de uint8_t (capdu) de taille capdulen et de stocker la réponse dans un autre tableau rapdu de taille rapdulen. Une petite nuance ici par rapport à l'article sur les ST25TA est l'ajout d'un argument notimeerr permettant d'ignorer un timeout après l'envoi d'un APDU (nous ne considérons alors plus cela comme une erreur). Ceci est nécessaire, car comme nous le verrons plus loin, le token ne répond rien et redémarre simplement une fois la nouvelle graine configurée (un manque de politesse qui m'a fait perdre un temps assez phénoménal).
- strcardtransmit(nfc_device *pnd, const char *line, uint8_t *rapdu, size_t *rapdulen) est une déclinaison de la fonction précédente, mais prenant en argument une chaîne (line) représentant l'APDU sous la forme d'une succession de valeurs hexadécimales (en ignorant les espaces). Ceci permet de plus facilement composer des APDU entièrement statiques, et donc sans éléments ou données créées dynamiquement, tout en permettant un peu de liberté concernant la façon d'écrire les APDU (en groupant les octets, par exemple).
Nous pouvons d'ailleurs nous empresser d'illustrer l'intérêt de cette seconde fonction en interrogeant le token à propos des informations qu'il peut nous fournir, dont son numéro de série et la date/heure qu'il utilise. L'APDU à utiliser n'ayant aucun élément variable, strcardtransmit() fait parfaitement l'affaire via la chaîne 80 41 0000 02 0211. Nous avons là le classique format APDU avec :
- la classe : 0x80
- la commande : 0x41
- les paramètres P1 et P2 : 0x00 et 0x00
- la taille des données envoyées (Lc) : 0x02
- et les données elles-mêmes : 0x02 et 0x11.
La réponse du token ressemblera à ceci :
Avec :
- le 9000 de fin est le code de réponse standard signifiant le succès de l'opération, puis nous avons dans l'ordre et depuis le début...
- 951502 : mystère et boule de gomme ;
- 0d correspondant à la taille des données qui suivent et qui correspondent au numéro de série ;
- 3836353936323130353935313 étant la représentation ASCII du numéro de série inscrit à l'arrière du token lui-même, « 866 963 377 152 3 » ;
- 11 qui n'est pas un caractère imprimable et dont l'utilité reste mystérieuse ;
- 04 un nouveau spécificateur de taille ;
- et 63809900 qui, si vous vous amusez avec les dates/heures UNIX, devrait titiller votre imagination.
Note de mon moi du futur
Une autre façon de voir les choses est de ne pas considérer les tailles individuellement, mais comme complément d'une notation TLV (voir la partie sur la configuration à la fin de l'article). Nous avons alors 9515, 020d et 1104, qui peuvent être un marqueur 0x95 de taille 0x15 (21) pour l'ensemble des informations, puis parmi elles, un premier marqueur 0x01 de taille 0x0d (13) et un second, 0x11 de taille 0x04 (4). 13+11 à quoi s'ajoutent deux marqueurs/entêtes type+taille de 2 octets chacun, nous donnant bien 21 pour la totalité.
Et nous pouvons alors représenter cette hiérarchie ainsi :
Non, je n'ai pas pour autant modifié mon article, et ce précisément pour parfaitement illustrer la manière dont une interprétation correcte des données peut voir le jour après avoir touché à une autre partie d'une analyse. Il ne faut donc jamais hésiter à revisiter quelque chose qui semble acquis, mais qui conserve quelques points obscurs.
Astuce : de manière générale, une valeur hexadécimale sur 32 bits démarrant par 6 et qui change régulièrement a toujours 99 % de chance d'être le nombre de secondes écoulées depuis le 1er janvier 1970 à minuit UTC. Il n'est même pas utile de regarder le script Python pour vérifier, car effectivement :
Pour stocker et interpréter tout cela dans notre code, le plus simple est d'utiliser une structure :
Puis de la remplir via une fonction dédiée (gettokeninfo()) envoyant l'APDU via strcardtransmit() et décortiquant la réponse. Une autre fonction, printtokeninfo(tokeninfo *info), est ensuite chargée de donner forme à tout cela pour l'affichage, et en particulier la date/heure :
Nous n'avons presque rien à faire étant donné que la valeur sur 32 bits est un long et qu'un time_t est précisément cela. localtime() traduira ceci très facilement, nous permettant un formatage humainement lisible. À noter que nous avons là déjà une évolution par rapport au script Python de Token2 puisque nous affichons, en prime, l'heure du système et l'écart avec celle du token (si elle est trop importante, on peut commencer à rencontrer des difficultés pour valider les codes TOTP).
Autre remarque importante qui prend la forme d'une question : que ce passera-t-il avec ces matériels le 19 janvier 2038 à 03:14:08 UTC ? Moment précis où une heure UNIX sur 32 bits va boucler et revenir au 13 décembre 1901 ? On peut supposer/espérer que la valeur soit en réalité non signée, ce qui repousse l'échéance à février 2106, mais ceci dépend totalement du firmware du produit (même si le script Python vérifie effectivement que la valeur passée est inférieure à 0xFFFFFFFF et donc sans traiter cela comme une valeur signée).
3. Authentification et SM4
La collecte d'informations auprès du tag ne nécessite pas de mesure particulière, il suffit d'envoyer le bon message pour avoir une réponse. Il n'en va pas de même pour les opérations de configuration comme le réglage de la graine. Là, il est nécessaire de s'authentifier auprès du token et c'est ici que les choses prennent une tournure particulière.
En effet, l'authentification préalable consiste en un challenge que nous devons réussir et celui-ci repose sur le scénario suivant :
- nous envoyons une demande via l'APDU 80 4B 0800 00 ;
- le token répond en nous fournissant 16 octets maximum ;
- nous chiffrons ces données avec notre clé ;
- et renvoyons le tout au token qui confirme ou non la validation du challenge.
La clé dont il est question ici est celle dont je vous parlais en début d'article et qui est une clé générique publiquement connue (voir le paragraphe « Default access keys » sur le site officiel [16]) : 8AD206883CA369482AB27182B6E83224.
Mais la particularité vient plutôt de l'algorithme de chiffrement ShangMi 4 ou SM4. Celui-ci n'est pas particulièrement complexe et utilise des blocs de données et une clé de 128 bits. L'algorithme de déchiffrement est identique à celui de chiffrement, mais dans l'ordre inversé. SM4 peut être utilisé en mode ECB (Electronic Code Book) où chaque bloc de 128 bits est chiffré individuellement, ou en mode CBC (Cipher Block Chaining) avec un chaînage des blocs. Nous le verrons dans la suite, le protocole de communication des tokens utilise les deux modes.
Implémenter un algorithme de chiffrement est généralement une très mauvaise idée, mais nous nous heurtons à un problème. SM4 n'est pas un algorithme standardisé ISO bien qu'il ait été déclassifié en 2006 et ait fait l'objet d'une proposition de standard par la suite. Il existe même un draft IETF incluant une implémentation de référence en C [17]. À ce jour, SM4 est effectivement présent dans OpenSSL, mais uniquement dans les dernières versions et à la condition que l'algorithme ait été activé lors de la compilation de la bibliothèque, ce qui n'est souvent jamais le cas. Nous pourrions donc lier notre programme à OpenSSL et reposer sur l'interface de haut niveau EVP, facile à utiliser, mais nous introduirions une dépendance difficile à satisfaire. L'alternative consiste à trouver une implémentation en C que nous pouvons directement intégrer dans notre code. Et c'est, bien entendu, cette seconde voie qui a été choisie grâce au code proposé par un certain « siddontang » [18].
En intégrant sm4.c et sm4.h dans le projet, nous disposons donc de plusieurs fonctions satisfaisant nos besoins :
- sm4_setkey_enc() pour spécifier la clé de chiffrement ;
- sm4_crypt_ecb() pour chiffrer en mode ECB ;
- sm4_crypt_cbc() pour chiffrer en mode CBC.
Pour suivre le protocole d'authentification, il nous suffit d'écrire une courte fonction, reposant sur strcardtransmit() et cardtransmit() tout en utilisant ces nouvelles fonctions de chiffrement SM4 :
Rien de bien transcendant ici, il ne s'agit que d'un petit jeu de tableaux et d'agencement des valeurs s'y trouvant. Nous utilisons sm4_crypt_ecb() étant donné que les octets du challenge doivent nécessairement faire moins de 16 octets, correspondants précisément aux 128 bits d'un bloc et de la clé. À noter toutefois, cette phase d'authentification, même si sans intérêt d'un point de vue de la sécurité, ne doit pas être prise à la légère. Le peu de documentation, qui se résume à un simple paragraphe sur le site à ce sujet, précise simplement qu'il est possible de « bloquer le token » en n'utilisant pas « la bonne application ». On ne peut que supposer que plusieurs tentatives d'authentification échouées peuvent avoir ce genre de conséquences, sans pour autant savoir précisément combien et sous quelles conditions...
4. La partie difficile : la graine
L'authentification auprès du token est une phase préliminaire à n'importe quelle « écriture » sur ce dernier. Mais la configuration de la graine est une tout autre affaire, où bien des surprises nous attendent. Le protocole est le suivant :
- obtenir la graine depuis la ligne de commandes sous la forme d'un argument encodé en base32 ;
- vérifier et décoder l'information en base32 pour obtenir une série de valeurs binaires ;
- adapter cette valeur (d'un minimum de 20 octets semble-t-il) en un multiple de 16 (padding) ;
- chiffrer la graine « paddée » avec la clé ;
- composer un APDU pour le calcul d'un MAC (Message Authentication Code) permettant d'en assurer l'intégrité et l'authenticité ;
- calculer le MAC ;
- composer un APDU intégrant la graine chiffrée et le MAC ;
- et envoyer le tout au token.
Voilà donc le programme pour la suite de l'article, en commençant par le début...
4.1 Base32
Base32, tout comme base64, est un système de codage d'informations permettant de représenter des données binaires sous une forme n'utilisant que des symboles « imprimables » (voir RFC 4648). Alors que base64, généralement d'usage pour l'encodage MIME dans les mails (mais pas seulement, loin de là), fait usage d'un alphabet de 64 caractères, base32 n'en utilise que 32 (« A » à « Z » et « 2 » à « 7 », soit 26+6=32), le tout complété, dans les deux cas avec, un padding utilisant des « = » (sur un multiple de 4 pour base64 et 8 pour base32).
Il existe de nombreux outils, à commencer par la commande base32 elle-même, permettant de transcoder vers et depuis base32, mais nous ne voulons pas reposer sur un programme externe. Il pourrait être intéressant de supporter une notation hexadécimale en lieu et place de base32, mais ceci demanderait un travail équivalent et nous éloignerait de l'usage commun vis-à-vis de TOTP. En effet, les applications comme Aegis Authenticator permettent d'utiliser un code QR pour ajouter une graine dans leur configuration. Ce code correspond à la chaîne, ou plus exactement l'URI, « otpauth://totp/?secret= » suivi de l'information encodée en base32 (plus éventuellement d'autres informations, comme l'algorithme HMAC, la source, la période, etc.). Il est donc normal qu'on s'en tienne à cet encodage.
Comme pour SM4, il existe pléthore d'implémentations d'algorithmes d'encodage/décodage base32 sur GitHub/GitLab et ceci est suffisamment simple pour ne pas avoir à reposer sur une bibliothèque et donc ajouter une dépendance. Ayant précédemment développé un autre code en rapport (celui dont je vous parlerai dans un autre article), reposant sur une implémentation simple de TOTP en C par Francesco Pantano [19], j'ai tout simplement réutilisé les mêmes routines qui se résument à deux fonctions :
- validate_b32key() pour s'assurer que le format en entrée est bien le bon ;
- decode_b32key() pour décoder les données en base32 et obtenir un tableau de uint8_t à la même adresse (puisque l'encodage base32 occupe toujours davantage de place que les mêmes données binaires).
Les données encodées en base32 sont « paddées » sur 8 caractères, ce qui signifie par exemple que la chaîne de caractères « 123 » (0x31 0x32 0x33) nous donne, en principe, GEZDG=== et non simplement GEZDG, mais il est d'usage d'oublier le padding dans la plupart des cas. Nous avons là l'occasion d'implémenter quelque chose qui n'est pas supporté par le script Python de Token2 : accepter les graines au format base32 non « paddé ».
La graine sera passée via un argument en ligne de commandes et traitée par getopt() et un simple strdup() pour initialiser b32seed. Sur cette base, nous pouvons ensuite nous occuper du padding :
Une fois cette étape passée, nous pouvons utiliser les deux fonctions de validation et de décodage base32 :
Nous obtenons ainsi realseed qui est la version binaire de la graine et realseedlen correspondant à sa taille. Mais nous n'en avons pas fini, car le protocole utilisé impose certaines restrictions :
Il semblerait que la graine doivent faire au minimum 20 octets (qui seront « paddés » par la suite sur un multiple de 16 pour les opérations de chiffrement), ce qui signifie qu'une graine comme notre « 123 » précédent (GEZDG=== en base32) deviendra en réalité 3132330000000000000000000000000000000000) et sera utilisée en tant que telle pour calculer le code TOTP. D'autre part, elle ne pourra pas non plus être d'une taille supérieure à 63 octets telle que spécifiée sur les pages produits du site de Token2.
4.2 Chiffrement
Le décodage base32 se fait directement dans main() juste après la gestion d'arguments en ligne de commandes, mais le reste de la procédure sera gérée par une fonction dédiée (seedtoken()) prenant en argument la graine binaire et sa taille. Là, la première chose que nous devrons faire est de nous assurer que la graine possède une taille qui soit un multiple de 16, puisque l'algorithme de chiffrement SM4 travaille avec des blocs de 128 bits. Le padding utilisé cependant sera différent et doit correspondre à la méthode décrite dans la norme ISO/IEC 9797-1. Celle-ci décrit la façon de construire le MAC (Message Authentication Code) qui sera également utilisé pour assurer l'authenticité du message envoyé et son intégrité et précise plusieurs méthodes de padding utilisables. Celle qu'il nous faut est la seconde, consistant à ajouter un bit à 1 à la fin des données, puis à compléter de 0 jusqu'à arriver à une taille multiple de 16.
Étant donné que ce type de padding est utilisé à la fois pour la graine et pour le calcul du MAC, le plus simple est d'y dédier une nouvelle fonction :
J'ai choisi ici de systématiquement retourner un tableau « paddé » même lorsque ce n'est pas nécessaire (et que le tableau de départ a déjà une taille multiple de celle d'un bloc de chiffrement). Ceci simplifie le reste du code et évite d'avoir à faire un test et de jongler avec encore plus de malloc(). Remarquez le 0x80 ajouté comme premier octet suivant la graine lorsqu'il est nécessaire de « padder ». 0x80 = 0b10000000, c'est notre fameux bit à 1 qui débute le padding.
Dans notre fonction, une fois la graine correctement « paddée », nous pouvons passer au chiffrement. Si nous reprenons notre exemple de notre mini graine, GEZDG est devenu GEZDG===, puis 313233, 3132330000000000000000000000000000000000 et maintenant 3132330000000000000000000000000000000000800000000000000000000000. Mais une petite surprise nous attend. En effet, nous avons clairement ici 32 octets de graine « paddée », soit deux blocs de 128 bits, et une erreur à ne pas commettre est de penser que le fait d'avoir plusieurs blocs signifie forcément un chiffrement en mode CBC. Ce n'est pas le cas ! Même si pour le calcul du MAC, ci-après, c'est bien CBC et non ECB qui est utilisé (cf. ISO/IEC 9797-1, algorithme 1 également appelé, justement, « CBC-MAC » [20]).
Ceci veut dire que nous n'utilisons pas sm4_crypt_cbc(), mais une boucle for avec plusieurs appels à sm4_crypt_ecb() :
Et les surprises ne sont pas finies. En effet, une fois la graine chiffrée obtenue, il nous faut nous pencher sur le MAC.
4.3 MAC
Toujours dans la même fonction, nous devons maintenant nous assurer de satisfaire aux critères de sécurité demandés et cela veut dire accompagner les données d'un MAC qui sera calculé sur l'ensemble du message envoyé au token. Ceci comprend donc les cinq octets constituant le début de l'APDU. Et c'est justement là que nous avons une nouvelle étrangeté : l'APDU utilisé pour le MAC, composé d'une classe, une commande, deux paramètres, une taille et les données, n'est pas celui qui sera effectivement envoyé. C'est la classe qui, pour une raison mystérieuse, change de 0x80 pour le MAC à 0x84 dans la réalité.
Nous devons donc composer un « faux » APDU débutant par 84 C5 01 00 00, ajuster le dernier 0x00 pour lui faire correspondre la taille des données (ici, 32 et donc 0x20) et calculer le MAC en fonction du tableau résultant. Bien entendu, puisque le MAC est également utilisé par ailleurs, nous lui dédions une fonction :
Remarquez qu'ici c'est bien sm4_crypt_cbc() qui est utilisé et un argument en plus, iv, doit être spécifié. C'est le vecteur d'initialisation de l'algorithme en mode CBC où les blocs sont chaînés entre eux. À noter au passage que si vous appelez sm4_crypt_cbc() à plusieurs reprises, vous obtiendrez des résultats sans cesse différents, à moins de réinitialiser le tableau iv[] avec ses valeurs de départ. Ce n'est pas important ici, mais en phase de test, pour valider l'implémentation de SM4, cela peut vous faire perdre un temps non négligeable (oui, je me suis fait avoir).
Le MAC est constitué des 4 premiers octets du dernier bloc chiffré et c'est ce groupe d'octets qui sera alors ajouté après la graine chiffrée dans l'APDU final. APDU qu'il ne faudra pas oublier de modifier, tant au niveau de la classe qui passe à 0x80, mais aussi de la longueur des données (en position 4 du tableau).
Ce qui nous amène à parler d'un problème avec le script Python de Token2. À cette date, et l'information a été remontée aux développeurs, le script ne sait gérer que les graines de moins de 32 octets. En effet, les données utilisées pour calculer le MAC débutent, en dur dans le code, par 80C5010020. Ce dernier 20 est le problème, car tant que la graine reste petite, elle fera toujours 32 octets suite au premier « grossissement » à 20 octets et au padding qui passe forcément sa taille à 32. Mais si la graine initiale est de, par exemple, 33 octets, le padding nous donne 48 octets, soit 0x30 et non 0x20. De ce fait, le MAC est calculé sur les mauvaises données, et le token refuse la configuration. Il est possible que ce problème ait été corrigé au moment où cet article sera publié, mais dans le doute, jetez un œil au script (tokens/otpc_p2_token.py, fonction set_seed()).
Et enfin, nous arrivons à la dernière surprise de cette partie : le token n'est pas très poli et au contraire, très taciturne. En envoyant l'APDU final au token, ne vous attendez pas à une réponse, sauf s'il s'agit d'une erreur. En cas de succès, celui-ci va se réinitialiser (je pense) et, de ce fait, cesser toute communication. Ceci pose un petit problème qui, là aussi, peut causer une belle perte de temps, puisque nous obtenons un timeout. Or, si vous avez une erreur dans votre APDU et que, par exemple, la taille des données n'est pas correctement indiquée, c'est exactement ce genre d'erreur que vous allez obtenir de la part de la LibNFC. On se retrouve donc dans une situation où une configuration réussie et une condition d'erreur découlent sur le même résultat. Il faut donc systématiquement vérifier que la graine ait été correctement prise en compte en comparant un code TOTP obtenu du token avec celui provenant d'un outil comme oathtool, par exemple.
5. Réglage de la configuration
La graine est sans doute l'élément le plus important, mais aussi celui qui est le plus délicat à configurer. Cependant, d'autres paramètres peuvent être ajustés au niveau de la configuration du token et l'un d'entre eux s'avère tout aussi critique : l'horloge interne. La génération du code TOTP passe par le hachage cryptographique de la date/heure courante (ou un compteur dans le cas HOTP) et celle-ci doit donc être correctement synchronisée avec celle du système chargé de l'authentification.
Les éléments de configuration du token inclus :
- la date/heure au format « UNIX Epoch time » (nombre de secondes écoulées depuis le 1er janvier 1970 00:00:00 UTC) ;
- le HMAC utilisé (ici SHA-1 ou SHA-256) ;
- l'intervalle de temps pour la génération de code TOTP (30 s ou 60 s) ;
- et le délai d'affichage à l'écran LCD (ou e-paper) qui correspond tout simplement au temps avant mise en sommeil du périphérique (15 s, 30 s, 60 s ou 120 s).
Ces 4 éléments ne peuvent être, semble-t-il, configurés individuellement. C'est un unique APDU qui est envoyé au token, sans chiffrement, mais en intégrant un MAC comme précédemment. Là encore, ce dernier est calculé sur des données qui ne sont pas celles effectivement intégrées à l'APDU final, la classe est différente, ainsi que la taille des données qui n'inclue forcément pas le MAC lui-même. L'opération s'avère en revanche plus simple qu'avec la configuration de la graine, puisque nous n'avons aucune information de taille variable. L'encodage des informations est défini ainsi :
- début de l'APDU : 0x80 0xd4 0x00 0x00 0x13 où 0x80 devient 0x84 et 0x13 est Lc, la taille des données (19 octets) sans le MAC ;
- un entête pour les données de configuration avec un schéma d'encodage TLV (Type-Length-Value) : 0x81 0x11 ou 0x81 est le type et 0x11 (17) est la taille de la configuration qui suit ;
- toujours au format TLV, nous avons l'entête pour le délai d'affichage : 0x1f 0x01 de taille 1 ;
- le délai lui-même encodé sur un octet avec 0x00 = 15 secondes, 0x01 = 30 s, 0x02 = 60 s et 0x03 = 120 s ;
- l'entête pour l'heure : 0x0f 0x04 de taille 4 ;
- l'heure sur 4 octets correspondant à l'heure UNIX sur 32 bits non signés (probablement) ;
- l'entête pour le type de HMAC : 0x0a 0x01 de taille 1 ;
- le HMAC utilisé encodé avec 0x01 = SHA-1 et 0x02 = SHA-256 ;
- l'entête pour la période TOTP : 0x0d 0x01 de taille 1 ;
- et enfin la période elle-même, sur un octet, encodée sous la forme : 0x1e = 30 s et 0x3c = 60 s.
Sur cette base, il est donc relativement simple de créer une série de macros pour nous simplifier la vie :
Dans la fonction de configuration dédiée (configtoken()), nous adoptons une approche assez similaire à celle de la fonction seedtoken(), en fabriquant un APDU de base :
De là, nous intégrons les éléments de configuration passés en argument de la fonction, un à un, avant d'utiliser les 24 premiers octets du tableau pour produire le MAC avec makemac(). Enfin, nous changeons la taille (Lc) des données (apdu[4] = 0x17) et la classe (apdu[0] = 0x84) et envoyons le tout au token avec cardtransmit(). Là encore, le même petit problème se pose qu'avec la configuration de la graine, puisque le token ne confirme pas la bonne prise en compte des paramètres ici non plus.
J'avoue ne pas avoir tenté de composer des APDU permettant de configurer individuellement ces éléments. C'est peut-être possible, en confectionnant un APDU avec un entête de configuration spécifiant une taille plus petite et en incluant qu'un seul paramètre. Ceci ne présente cependant pas réellement d'intérêt à mon sens, car la configuration est une opération peu courante (en dehors des expérimentations) et on souhaite généralement utiliser les paramètres par défaut (SHA-1, 30 s et 30 s) et l'heure courante. De plus, ceci voudrait dire qu'on envoie plusieurs APDU au token, un pour chaque élément de configuration, ce qui constitue une perte de temps. Au contraire, j'ai préféré implémenter une option d'autoconfiguration reprenant précisément ces choix, sous la forme d'un simple -a sur la ligne de commandes, laissant à l'utilisateur la possibilité d'utiliser des options individuelles en ligne de commandes, mais devant forcément être utilisées de concert.
Un autre point notable à prendre en considération est le délai de réponse du token et/ou du lecteur NFC. En effet, pour une raison qui m'est inconnue pour l'instant (je n'ai pas cherché), il semblerait que la LibNFC 1.8.0 soit sensiblement plus lente que la 1.7.0 avec des lecteurs USB. Mon code détecte automatiquement les lecteurs présents, utilise le premier, procède à l'authentification auprès du token et, enfin, envoie la configuration (puis la graine, si nécessaire). En récupérant l'heure courante au niveau de la gestion des options avec getopt(), cette succession d'opérations introduit un décalage entre l'heure du système et celle appliquée au token, de l'ordre de 2 ou 3 secondes. Sachant qu'une dérive d'horloge est inéluctable au niveau de la RTC du token, ceci n'est pas acceptable.
La solution adoptée consiste à tout simplement gérer cette information le plus tard possible. La routine de gestion d'option ne fait alors que déterminer si la date/heure UNIX spécifiée est valide et la transforme en un tableau de 4 uint8_t. Mais si l'utilisateur choisit l'autoconfiguration ou utilise « now » en guise de paramètre, alors l'adresse du tableau est NULL et, se faisant, la fonction configtoken() récupère l'heure courante avec time(NULL) et utilise cette valeur pour composer l'APDU. Le délai de réaction du lecteur est toujours présent, mais drastiquement réduit à une demi-seconde ou moins, ce qui descend à un dixième de seconde en utilisant un PN532 interfacé en USB/série.
6. Pour finir
Cette petite exploration a été un peu plus aventureuse que je ne l'avais imaginé au départ et les surprises n'ont pas manqué. Mais au final, nous avons été en mesure de décortiquer très majoritairement le fonctionnement de ces tokens et le code résultant propose quelques fonctionnalités qui ne sont pas prises en charge par le script Python de Token2. Mais mieux encore, l'objectif est atteint puisque la restriction concernant le lecteur NFC n'est plus d'actualité et il devient possible de configurer les tokens avec, par exemple, un système ne disposant pas d'interface USB (comme un système embarqué aux ressources modestes). Le code découlant de ces travaux est (ou sera) disponible sur mon compte GitLab [21], mais vous devrez garder à l'esprit que tout ceci a été développé sans documentation officielle et que vous êtes seuls responsables d'éventuels problèmes découlant de son utilisation (c'est une version 0.0.1 pour une bonne raison).
Parlant de documentation justement, celle-ci est potentiellement disponible par ailleurs. En effet, selon toute vraisemblance, une partie des tokens vendus par Token2 sont des produits personnalisés provenant d'un constructeur chinois appelé ExcelSecu. Ce dernier propose à la vente du matériel très proche du C302-i et du OTPC-P2-i sur AliExpress [22] à un tarif similaire (hors promotion). Un bref échange avec le vendeur lors de l'achat (forcément, je n'ai pas pu résister) m'a d'ailleurs confirmé qu'ExcelSecu serait effectivement fournisseur de Token2 (« Token2 we are the supplier, its the same », m'a-t-il dit) et en mesure de me fournir une documentation technique intégrant les APDU utilisables (que j'attends toujours).
Il serait tentant de se dire qu'on fera une économie en passant directement commande auprès d'ExcelSecu, en particulier sur des quantités importantes (pour équiper une entreprise, par exemple), mais ce serait oublier l'importance du support technique.
Mes échanges avec Token2 via leur système de tickets, que ce soit pour des questions stupides (comme la fameuse customer_key) ou pour remonter un bug dans le code Python, ont été traités avec une efficacité exemplaire et se sont soldés par des réponses rapides, claires et précises. En d'autres termes, il y a effectivement quelqu'un pour vous répondre (en français, qui plus est), alors que le pendant chinois via AliExpress est pour le moins chaotique et aléatoire. Je ne suis pas même certain, à ce stade, que les trois tokens commandés seront effectivement programmables à leur arrivée dans ma boîte à lettres ou qu'une quelconque documentation (ou code de démonstration) me sera effectivement fournie. Nous verrons bien de quoi il retourne une fois que le matériel sera entre mes mains, mais cela reste expérimental. Pour une utilisation « en situation réelle », et avec des prix, somme tout, très proches, je préfère l'indéfectible efficacité suisse au côté « bazar » d'AliExpress. Ce à quoi j'ajouterai également que Token2 fait l'effort de créer et fournir un outil multiplateforme et non simplement une sélection de binaires (Windows, Android et iOS) qu'on est censé aveuglement exécuter sur son ou ses systèmes. Et ça, voyez-vous, est à mes yeux quelque chose qui mérite d'être salué.
Références
[1] https://connect.ed-diamond.com/Hackable/hk-010
[2] https://connect.ed-diamond.com/hackable/hk-042/manipuler-les-tags-st25-avec-la-libnfc
[3] https://connect.ed-diamond.com/hackable/hk-045/reutilisation-d-un-lecteur-audio-de-figurines
[4] https://github.com/beemdevelopment/Aegis
[5] https://en.wikipedia.org/wiki/Base32
[6] https://www.token2.eu/home
[7] https://www.token2.eu/shop/product/token2-c203-sha256-totp-hardware-token
[8] https://www.token2.eu/shop/product/token2-c302-i-programmable-hardware-token-iphone-compatible
[10] https://play.google.com/store/apps/details?id=com.token2.nfcburner2
[11] https://apps.apple.com/us/app/token2-nfc-burner/id1499100334
[12] https://www.token2.eu/site/page/tools-for-programmable-tokens
[13] https://www.token2.eu/shop/product/stickid-nfc-writer-for-python-nfcpy
[14] https://en.wikipedia.org/wiki/SM4_(cipher)
[15] https://cryptography.io/en/latest/fernet/
[16] https://www.token2.com/site/page/tools-for-programmable-tokens
[17] https://datatracker.ietf.org/doc/html/draft-ribose-cfrg-sm4-10
[18] https://github.com/siddontang/pygmcrypto/blob/master/src/sm4.c
[19] https://github.com/fmount/c_otp
[20] https://en.wikipedia.org/wiki/CBC-MAC