Ces dernières années, différentes failles majeures dans des librairies cryptographiques connues ont montré qu'au-delà du bon choix des primitives cryptographiques, leur bonne implémentation est essentielle. Nous présentons ici quelques conseils pour correctement implémenter (ou attaquer...) des fonctions cryptographiques.
Introduction
L'implémentation de fonctions cryptographiques est délicate, et la moindre erreur peut empêcher l'atteinte de l'objectif de sécurité initial (intégrité, authentification, confidentialité, etc.). Il y a essentiellement deux types d'erreurs : les erreurs d’algorithmie et les erreurs d'implémentation. Les premières consistent à mal choisir les primitives cryptographiques (algorithmes « maison », algorithmes cassés, mauvais choix de taille de clés, etc.) ou à mal les ordonnancer. Ce type d'erreurs est assez largement couvert dans la littérature et ne constitue pas le sujet de cet article. Nous nous intéressons ici au deuxième type : les soucis d'implémentation. Une fois la séquence de primitives cryptographiques correctement choisie, une simple erreur peut rendre complètement inopérant le système ou créer une faille exploitable. L'historique récent des failles sur les implémentations cryptographiques nous le montre, l'erreur de code peut :
- permettre le vol de tout ou partie des secrets ;
- baisser le niveau de sécurité final du système ;
- introduire des failles exploitables par un attaquant.
Nous essayons dans cet article de lister les problématiques le plus souvent rencontrées par l'auteur, et les conseils pour les éviter.
1. Récupération des secrets
Toute la sécurité apportée par la cryptographie repose sur une seule chose : la confidentialité des secrets. Nous allons explorer les différentes erreurs qui portent atteinte à cette confidentialité :
- une mauvaise génération des clés ;
- le vol de clés en mémoire ;
- les attaques dites « par canaux cachés ».
Par « porter atteinte », nous entendons toute attaque permettant à l'attaquant de retrouver le secret plus facilement qu'en essayant toutes les combinaisons possibles.
Encore une fois, nous ne traiterons pas ici des problématiques de choix de primitives : les attaques en brute force liées à des algorithmes faibles, des clés ou mots de passe trop courts (ce qui ne doit surtout pas vous empêcher d'y faire attention avant implémentation). Cependant, une partie des erreurs mentionnées permettent à un attaquant de récupérer assez d'informations sur le secret pour permettre une attaque brute force non réalisable autrement (i.e. si l'attaquant récupère les trois quarts d'une clé de 128 bits, il ne lui reste plus qu'à réaliser une attaque force brute sur les 32 bits restants), nous traiterons ces cas.
1.1 Mauvaise génération des secrets
Un secret généré par une implémentation défectueuse peut facilement être retrouvé par un attaquant. L'exemple le plus clair est celui d'un mauvais générateur d'aléa, qui générerait beaucoup plus de bits à 1 qu'à 0. Un attaquant conscient de ce défaut, peut l'exploiter en modifiant son attaque en force brute pour essayer d'abord les clés contenant plus de 1 que de 0. C'est ce qui s'appelle une attaque par biais statistique. En 2013, une telle attaque a été publiée pour l'algorithme symétrique RC4 [1].
Ce que nous appelons ici une mauvaise génération est une implémentation qui permet à l'attaquant de prévoir les clés générées ou de les retrouver autrement que par une attaque en force brute complète.
1.1.1 Bien implémenter le générateur
Un bon générateur cryptographique est constitué d'une source d'aléa (appelé TRNG pour True Random Number Generator), d'une fonction de retraitement algorithmique à sens unique (PRNG pour Pseudo Random Number Generator) et d'un état interne secret mis à jour périodiquement (appelé graine). La fonction à sens unique peut être de forme fonction de hachage ou de forme fonction à double sens à clé secrète. Pour un niveau de sécurité optimal, la graine doit être persistante entre deux instances du programme.
La première cause de biais est bien sûr le PRNG en lui-même. C'est ici une question de choix de primitive cryptographique, et nous ne traiterons pas de ce point qui mérite un article complet. [2] est une bonne référence sur le sujet.
L'attaquant ne doit pas avoir accès ni à la graine ni à la source d'aléa en même temps, l'accès à l'un ou l'autre en lecture ou en écriture diminue la sécurité du système global. Sans aller dans le détail des attaques possibles, s'assurer des hypothèses de base est suffisant pour assurer un bon développement de générateur.
La source d'aléa doit fournir une sortie difficilement prévisible par l'attaquant. Pour respecter cette hypothèse :
- Utilisez des données variant suffisamment. Méfiez-vous des données type Date, événement souris sur un serveur embarqué, etc. Il faut également se méfier des grosses structures système. Le générateur d'aléa de Truecrypt utilise par exemple des structures noyaux Windows pour récupérer certains événements (écriture disque, réseau, etc.), seule une partie de ces structures varie vraiment d'un événement sur l'autre ;
- N'utilisez pas de données manipulables par un attaquant (charge CPU) ;
- Utilisez plusieurs types de sources de données.
La graine doit être inconnue de l'attaquant et persistante :
- Pour la confidentialité de la graine, respectez les règles de protection des secrets en mémoire ;
- Pour la persistance, assurez-vous de mettre à jour la graine le plus souvent possible en mémoire persistante. Un attaquant qui maîtrise la source d'aléa, mais pas la graine va typiquement essayer d'empêcher la mise à jour de la graine (protection en écriture du fichier, crash volontaire du programme avant mise à jour, etc.). La première chose à faire au moment du chargement du générateur d'aléa est de mettre à jour la graine pour éviter ces attaques ;
- En cas de doute au lancement (la graine n'est pas trouvée, ou pas accessible en écriture), ayez une procédure de régénération de graine à l'initialisation du générateur ou refusez la délivrance d'aléa ;
Enfin, si vous vous reposez sur un générateur fourni par le système (API crypto Windows, Java, dev/rand, etc.), il est conseillé, pour plus de confiance, de considérer ces générateurs comme des sources d'aléa à retraiter (TRNG) et de réimplémenter son propre système de PRNG.
1.1.2 Bien utiliser le générateur
Le générateur fournit une suite de bits uniformément distribués. Cette suite peut être utilisée telle quelle pour générer des éléments sans structure mathématique (clé AES, IV, Salt, bourrage, etc.), mais doit être encore transformée lorsqu'une structure mathématique doit être respectée (clés RSA, clés Diffie-Hellman, point sur courbe elliptique, etc.). La transformation est une étape pouvant biaiser toute la génération, et fait l'objet d'une littérature étendue. Pour ne pas avoir de doutes, voici le type d'algorithme de génération qu'il faut utiliser :
1) Générer une suite de bits aléatoires de la bonne taille ;
2) Tester si cette suite répond aux exigences souhaitées (par exemple, nombre premier de n bits pour RSA) ;
3) Si non, revenir à 1.
Toute optimisation est risquée, et doit faire l'objet de vérifications. Vérifiez si oui ou non ce genre d'optimisation a déjà été étudié d'un point de vue formel, et si oui, si elle ne biaise pas le résultat. On n'insistera jamais assez : en cas de doute, n'optimisez pas !
1.2 Vol des clés en mémoire
Évidemment, le vol des secrets complets est encore le plus simple. Ceux-ci peuvent se retrouver en clair, en mémoire morte ou vive.
1.2.1 Vol en mémoire morte
La première recommandation est évidemment de ne stocker aucun secret en clair en mémoire persistante. Nous parlons ici de tous les types de stockage en clair :
- Base de données de mots de passe en clair ;
- Fichiers de clés privées en clair ;
- Secrets en dur et en clair dans le code source ;
- Données sensibles (état interne du générateur d'aléa, etc.) ;
- …
La manière de stocker les secrets correctement protégés est d'abord une histoire de choix de primitives cryptographiques et de leur bonne implémentation. Tous les conseils de l'article doivent être appliqués à l'implémentation des fonctions de protection des secrets (oui, cet article est récursif).
1.2.2 Vol en mémoire vive
Le cas de la mémoire vive est un peu plus complexe, les secrets doivent forcément se retrouver en clair en mémoire vive pour pouvoir être manipulés. Il y a deux axes pour réduire ce risque : limiter l'accès à la mémoire vive par un attaquant et limiter le temps d'exposition des secrets.
Le premier axe n'est pas sujet de cet article, mais il consiste soit à ajouter de la sécurité matérielle (co-processeur sécurisé avec mémoire vive réservée, gestionnaire matériel de mémoire vive, Secure Data Path, etc.), soit à renforcer les aspects sécurité système pour rendre difficile l'accès à la RAM à un programme malveillant.
Le deuxième axe est lui dépendant de l'implémentation, nous le traitons donc ici. Le but est de limiter la fenêtre temporelle d'attaque.
La première règle est de ne charger les secrets en clair en RAM qu'au moment de leur utilisation : évitez à tout prix les chargements intempestifs pour « gagner du temps plus tard », demandez le mot de passe utilisateur au moment où vous en avez besoin dans les calculs et pas avant, etc. Cette règle n'est pas toujours respectable selon les fonctionnalités du programme attendues, mais tentez toujours de repousser le plus longtemps possible le chargement des secrets en RAM.
La deuxième est évidemment d'effacer les secrets dès qu'ils ne sont plus nécessaires. C'est la règle qui demande le plus d'attention, et doit être mise en place dès le premier développement. La méthode est pourtant simple : écraser la zone mémoire du secret (avec des '0' par exemple) dès qu'il n'est plus utile. Posez-vous la question lors du développement, ai-je encore besoin de cette donnée après ce calcul ? Si non, effacez-la ! La mise en œuvre, elle, demande d'être attentif.
Il faut être capable de garder trace de la zone mémoire exacte du secret :
- Essayez de garder votre secret dans une zone mémoire unique, d'adresse fixe, que vous maîtrisez (en C, vous pouvez déclarer vos zones en statique par exemple) ;
- Faites attention aux fonctions d'allocation dynamique qui peuvent faire varier votre adresse (ou dupliquer votre secret) comme realloc en C ;
- Redoublez d'attention lorsque vous développez dans un langage dans lequel la gestion des adressages mémoire n'est pas explicite (Java).
Il faut de plus s'assurer d'effacer le secret dans toutes les situations possibles :
- Encore une fois, effacez explicitement la zone dès que possible, surtout si vous développez dans un langage dans lequel la gestion des adressages mémoire n'est pas explicite. Ne comptez pas sur le ramasse-miettes ;
- Faire attention aux sorties en cas d'erreur. Trop souvent, on efface les secrets proprement à la fin de la fonction concernée, mais pas en cas d'erreur. Chaque sortie de fonction/programme doit être précédée par une phase de nettoyage des zones mémoire contenant des secrets.
1.3 Attaques canaux cachés
Les attaques par canaux cachés sont une catégorie d'attaque qui s'attachent à retrouver des informations sur le secret en étudiant des informations annexes dépendantes du secret, telles la consommation de courant, le temps d'exécution, les accès au cache... Elles peuvent être dévastatrices, notamment sur les systèmes de cryptographie asymétriques. Les algorithmes de cryptographie asymétrique sont en effet souvent déséquilibrés, contrairement aux algorithmes symétriques, rendant les attaques par canaux cachés sur les implémentations logicielles directes. Cependant, même la cryptographie symétrique est sensible à ces attaques, que ce soit sur les implémentations hardware ou software (i.e. les attaques sur le cache lors du calcul d'un AES, voir [15]).
Un exemple parlant d'une telle attaque est celui de l'exponentiation modulaire lors des calculs RSA ou Diffie Hellman. L'algorithme le plus connu est appelé « square and multiply », dont voici l'implémentation proposée par Bruce Schneier et reproduite sur Wikipédia [3] :
Bignum modpow(Bignum base, Bignum exp, Bignum m) {
Bignum result = 1;
while (exp > 0) {
if (exp & 1 > 0) result = (result * base) % m;
exp >>= 1;
base = (base * base) % m;
}
return result;
}
Qui calcule (base)exp mod m. Dans les algorithmes comme déchiffrement RSA ou échange de clé Diffie Hellman, exp est le secret. Or, on le voit ici, à chaque passage dans la boucle, un calcul supplémentaire est effectué si le bit regardé de exp est à 1. Ce calcul implique forcément un temps de calcul ou une consommation CPU supplémentaire qui permet à un attaquant ayant accès à ces mesures de savoir lorsqu'un « 1 » est lu plutôt qu'un 0 et lui permet de deviner le secret. Une variante de cet algorithme, dite Échelle de Montgomery, ne pose pas du tout ce problème.
Les attaques par canaux cachés sont extrêmement diversifiées et touchent autant le software que le hardware.
Voici quelques règles à respecter pour diminuer leur impact dans le cas software :
- Choisir des algorithmes équilibrés, c'est-à-dire pour lesquels la complexité n'est pas dépendante du secret ;
- Coder des fonctions dont le temps d'exécution ne dépend pas du secret : évitez les méthodes qui sont fonction de conditions sur le secret (i.e. le nombre est pair, je choisis une autre fonction par exemple) ;
- Éviter les fonctions systèmes non équilibrées. Par exemple, memcmp en C, qui compare deux chaînes de caractères ensemble, s'arrête dès la première différence trouvée, indiquant à l'attaquant à quel endroit environ les deux chaînes divergent.
Encore une fois, par « secret » nous entendons toute donnée qui ne doit pas être connue par l'attaquant (mot de passe en clair, graine du générateur d'aléa, etc.).
2. Mauvaises implémentations
Nous allons maintenant parler des erreurs d'implémentation qui baissent le niveau général de sécurité attendu. Soit parce qu'elles exposent les clés, soit parce qu'elles permettent de contourner les mesures cryptographiques mises en œuvre ou qu'elles baissent simplement leur efficacité.
2.1 TL:DR
Le premier type d'erreur consiste en une mauvaise implémentation du standard. Les schémas cryptographiques les plus étudiés sont tous standardisés. Le standard contient les éléments théoriques attestant de la sécurité du système, et les hypothèses utilisées pour démontrer cette sécurité. Il contient également les règles d'implémentation à respecter pour conserver le niveau de sécurité théorique.
Par exemple, le standard vous permettra de savoir que le schéma est sûr à condition que telle ou telle donnée soit confidentielle, ou unique. Un bel exemple d'échec de lecture du standard est l'attaque des signatures ECDSA sur PS3 [4]. Le standard impose l'utilisation d'un aléa, en précisant que celui-ci doit être renouvelé à chaque signature sous peine d'exposer la clé secrète de signature. L'implémentation de Sony utilisait effectivement un aléa, mais le même pour toutes les signatures, la clé secrète de Sony a donc été perdue, avec des conséquences dramatiques.
Les standards vous permettent également de vérifier que votre implémentation est bonne, par le jeu des vecteurs de tests : ils comportent une entrée et une clé, et vous indiquent la sortie qui doit être obtenue. Si vous ne les respectez pas, corrigez ! Votre implémentation est fausse et son niveau de sécurité inconnu. Il est plus que conseillé d'embarquer des vecteurs de tests dans votre implémentation et de les passer au chargement de l'algorithme concerné, pour vérifier, par exemple, que votre code n'a pas été modifié.
Pour résumer, lorsque vous implémentez une primitive cryptographique, prenez le temps de lire l'ensemble du standard, et de vérifier à la fin que vous suivez effectivement ce standard.
Les standards cryptographiques les plus connus ([10][11][12]) offrent des vecteurs de tests et conseils d'implémentation.
2.2 Fail
Un autre type d'erreur d'implémentation assez courant est la mauvaise gestion des erreurs. Nous ne parlons pas du fait de ne pas gérer les erreurs, qui est un problème non spécifique aux implémentations cryptographiques, mais d'ignorer leurs conséquences sur la sécurité du programme.
Un exemple parlant est celui de la faille « goto fail » d'Apple [5]. Le double goto n'est déjà pas très joli, mais d'un point de vue cryptographique il y a encore plus problématique : l'erreur est en fait ignorée. Le problème ici, c'est que la valeur par défaut du champ d'erreur qui sera retournée est « tout va bien ». Une interruption imprévue de la fonction va donc indiquer au programme que la vérification de signature s'est bien déroulée, quand c'est l'inverse. Si le champ d'erreur avait par défaut une valeur indiquant une erreur, le bug n'aurait pas été exploitable (la fonction non plus d'ailleurs).
Assurez-vous que vos variables de retour, ou que vos contextes, sont correctement initialisés. En cas d'interruption du calcul, les données permanentes du programme doivent avoir un état reflétant le mauvais déroulement du calcul. Cela peut se faire en mettant à jour les données permanentes à la toute fin de la fonction, ou en embarquant un élément dans la structure/classe codant le bon déroulement. Par exemple, un contexte de générateur d'aléa peut avoir un booléen indiquant si la graine a bien été mise à jour. La valeur par défaut est FALSE et sa mise à TRUE n'est réalisée que lorsqu'il n'y a aucun doute sur le bon déroulement de la mise à jour de graine.
Dans la même catégorie, l'erreur peut être mal interprétée par la méthode appelante. Si une fonction cryptographique échoue, la sécurité du système doit être remise en cause, et le programme agir en conséquence :
- Si le générateur d'aléa renvoie une erreur, aucun aléa issu de ce générateur ne devrait être utilisé ;
- Si la fonction de vérification de signature/intégrité échoue, la donnée doit être considérée comme invalide ou corrompue (en particulier, il ne faut pas déchiffrer les données ou il faut effacer les données en clair en cas d'échec de vérification de signature ou d'intégrité) ;
- Si une fonction cryptographique échoue, il faut s'assurer que toutes les données sensibles sont bien effacées ;
- etc.
Une erreur sur une fonction cryptographique indique plus qu'un simple problème de comportement, elle indique une faille de sécurité potentielle.
2.3 Non, mais je connais un raccourci
Méfiez-vous des optimisations malheureuses, ou des corrections de code hâtives. Il y a souvent une très bonne raison pour laquelle les choses sont spécifiées ou codées comme elles le sont, et improviser dans ce cas peut coûter très cher [6]. Si vous touchez à un code qui n'est pas le vôtre, vérifiez avant toute optimisation que l'auteur n'avait pas une bonne raison d'implémenter les choses de cette façon. Si vous développez, pensez à commenter les endroits que vous avez choisi de ne pas optimiser. Indiquez par exemple, que vous avez choisi de ne pas utiliser memcmp parce que cette fonction est sensible aux attaques par canaux cachés.
Une autre illustration : une dérivation de mot de passe est faite pour être coûteuse et longue. Stocker une partie de la dérivation pour que la vérification de mot de passe soit plus rapide détruit complètement l'intérêt premier d'une telle dérivation : rendre les attaques en force brute trop coûteuses.
Sur les algorithmes eux-mêmes, nous avons vu en 2.3 que pour se prémunir des attaques par canaux cachés il fallait utiliser des algorithmes équilibrés. Ceux-ci ne sont pas forcément optimaux, certains rajoutent même des opérations inutiles, mais pour une bonne raison.
Il faut rajouter que le conseil concerne également les optimisations réalisées par le compilateur, celui-ci peut se débarrasser d'opérations qu'il estime inutiles, optimiser des boucles, etc. [9] fournit une série d'exemples d'optimisations du compilateur non souhaitables, nous rapportons le premier exemple basé sur le code suivant :
int
crypto_pk_private_sign_digest(...)
{
char digest[DIGEST_LEN];
(...)
memset(digest, 0, sizeof(digest));
return r;
}
Le memset, effaçant les secrets en mémoire, est supprimé par le compilateur concerné.
Il n'est pas possible de faire une liste exhaustive des optimisations dangereuses, tant elles dépendent du contexte. Les standards précisent parfois que telle ou telle optimisation est prohibée, mais le seul moyen de vérifier qu'une optimisation ne pose pas de souci est de demander l'avis d'un cryptologue familier du cryptosystème développé.
2.4 Je ne suis pas un numéro
Toutes les bonnes pratiques de développement incluent la même recommandation : vérifiez vos entrées de programme/fonction. Les implémentations cryptographiques n'échappent pas à cette règle, mais il faut y rajouter la vérification de la correction mathématique des entrées.
Si vous attendez de la fonction un élément d'un groupe mathématique particulier, assurez-vous avant de commencer les calculs qu'il appartient bien à ce groupe.
Cela paraît évident, mais est trop souvent non respecté. Apple en aurait fait les frais dans son implémentation ECDHE sur Safari [7]. En cryptographie sur courbes elliptiques, le groupe sur lequel nous travaillons est l'ensemble des points (x,y) vérifiant l'équation d'une courbe. La courbe est choisie pour ses bonnes propriétés cryptographiques, le logarithme discret étant trivial sur certaines courbes elliptiques. Les calculs effectués en eux-mêmes fonctionnent tant que x et y sont des entiers de la bonne taille, différents de 0. Ce qui implique que si vous fournissez à la fonction cryptographique un point x,y ne vérifiant pas l'équation de la courbe, vous risquez deux comportements :
- Un arrêt non prévu des calculs suite à une division par 0 (voir 4 .1) ;
- Un calcul se déroulant tout à fait normalement, mais au résultat faux, et donc non sûr.
C'est ce qu'on appelle une attaque par point invalide.
Toujours en courbe elliptique, nous utilisons un point théorique « le point à l'infini » pour construire le schéma mathématique. Celui-ci ne se « code » pas dans la plupart des systèmes de coordonnées (vu qu'il n'a qu'une existence théorique), il faut donc vérifier dès que l'on utilise un point, que celui-ci n'est pas le point à l'infini.
Nous avons donné une série d'exemples sur les courbes elliptiques, parce que ces erreurs sont faciles à faire et coûteuses, mais ceci est valable sur d'autres schémas, par exemple un groupe pour réaliser un Diffie-Hellman doit avoir une structure particulière pour être sûr. Encore une fois, une lecture attentive du standard vous permettra de savoir quoi tester.
N'oubliez pas que l'attaquant pourrait chercher à modifier les valeurs des variables « à la volée », les vérifications doivent se faire durant l'exécution du programme.
2.5 Chut
Nous reparlons encore dans ce paragraphe des erreurs, mais trop parlantes cette fois. Les erreurs ou arrêts impromptus du programme sont une source d'information très importante pour un attaquant. Le chapitre 2.3 nous en a donné un exemple avec les attaques sur memcmp par exemple.
Dans le même esprit, mais moins connues, sont les attaques sur les protocoles d'authentification ou d'échanges de clés. Les preuves de sécurité de ces protocoles démontrent qu'un attaquant en écoute ne peut découvrir les secrets, ou que l'une des parties ne peut pas découvrir les secrets de l'autre partie. La plupart de ces preuves reposent sur le fait qu'un attaquant ne peut pas faire la différence entre une exécution correcte ou incorrecte du protocole avant la fin de celui-ci.
Ce qui implique que tout arrêt prématuré d'un de ces protocoles (le message que j'ai reçu ne me convient pas, j'arrête de répondre) est une atteinte à la sécurité globale du protocole. Le bon comportement consiste alors à continuer de répondre, mais par des données aléatoires, indifférentiables d'une bonne réponse par un attaquant ne disposant pas des secrets.
Ceci est valable pour d'autres aspects, posez-vous la question : « l'attaquant peut-il être aidé par le fait de savoir que l'exécution a échouée pour telle ou telle raison » ? Dans des cas d'attaques par fuzzing, une telle indication peut être d'une grande valeur à l'attaquant.
2.6 Les indiens et les signes
Lorsque vous développez de la cryptographie, retenez un élément important : les données que vous manipulez ont un sens mathématique et la sécurité d'une primitive repose sur des hypothèses sur ce sens. Aussi, la façon de représenter ces données dans votre code est un sujet essentiel.
Une inconstance dans votre représentation de nombre (grand boutien et petit boutien) peut rendre l'ensemble de vos calculs faux, sans pour autant déclencher d'erreur. Quelques fois, l'algorithme fait lui-même l'hypothèse de la représentation (i.e. le premier bit est celui de poids fort). Encore une fois, une lecture attentive des standards et des documentations des bibliothèques que vous utilisez (bibliothèque grand nombre par exemple) est indispensable pour éviter ce genre d'erreur.
Toujours en parlant de représentation de données dans une implémentation cryptographique, faites attention aux types. Les tampons de données doivent toujours être basés sur un type non signé. Pratiquement systématiquement, les données se retrouvent découpées en petites unités basiques, sur lesquelles des calculs sont effectués et ces unités sont attendues non signées. Un simple décalage à droite en C, par exemple, aura un résultat complètement différent entre un type signé et non signé (complétion à gauche par des 1, respectivement des 0).
2.7 Couteau suisse
Ce conseil ne devrait pas se trouver dans cet article, puisqu'il concerne le bon choix des primitives. Mais nous avons tellement observé cette erreur dans des implémentations cryptographiques qu'il nous semble essentiel de rappeler cette bonne pratique : une clé par usage.
Un secret ne doit en effet jamais être utilisé pour deux usages différents, que ce soit deux algorithmes ou deux fonctionnalités. De la même manière que les experts sécurité recommandent la bonne pratique d'un mot de passe différent par usage, et pour les mêmes raisons. Un algorithme peut être attaqué et exposer les clés utilisées, une ségrégation des clés par usage permet de limiter les conséquences dans un tel cas. D'autre part, certains schémas cryptographiques ne sont sûrs que dans le cas où ils utilisent des clés différentes pour chacune de leur sous-fonction [13].
3. Crash program
Cette dernière partie se consacre aux erreurs pouvant entraîner un arrêt impromptu du programme (crash), comme les dépassements de tampons, ou des divisions par 0.
3.1 NaN
La tentative de division par 0 en informatique est une des erreurs les plus connues, en raison de ses résultats souvent catastrophiques. Un bel exemple historique d'une telle erreur est celui du croiseur américain USS Yorktown, dont le système informatique complet fut victime d'un crash suite à une division par zéro, obligeant le vaisseau à être remorqué au port le plus proche [8].
Toute implémentation mathématique est évidemment plus prône à entraîner des divisions par zéro, ce qui nous concerne ici directement. Les conseils de la section 3.4 doivent évidemment être appliqués ici. Et pas seulement pour la fonction « division », les algorithmes cryptographiques impliquant presque tous des divisions de nombres plus ou moins grands. Si vous implémentez l'ensemble des opérations, vérifiez avant chaque division unitaire (opérateur de base '/') que votre dénominateur ne vaut pas zéro, si vous utilisez des bibliothèques intermédiaires, assurez-vous si possible (code disponible) qu'elles font ces vérifications et pensez évidemment à la gestion d'erreur associée.
3.2 Ce n'est pas la taille qui compte
Nous allons dans cette sous-section parler encore une fois des problèmes de représentation des données cryptographiques, plus exactement de la taille de ces données.
En cryptographie asymétrique, on manipule des grands entiers. Les opérations sur ceux-ci peuvent faire varier leur taille. Le résultat d'une multiplication sans réduction modulaire peut avoir une représentation beaucoup plus grande que les multiples de départ. Le résultat d'une multiplication avec réduction modulaire peut être beaucoup plus petit que les entrées de l'opération.
Ceci peut être source d'erreur quand l'implémentation ne prend pas en compte ces aspects. Un exemple souvent rencontré est celui de l'exposant privé RSA (d), résultat d'une inversion modulaire de l'exposant public e. Il peut arriver que celui-ci soit plus petit que la taille attendue. Par exemple, pour une RSA 2048 bits, la taille de l'exposant privé attendue est de 2048 bits. Mais, par le jeu du hasard (si l'exposant public est généré aléatoirement), d peut être plus petit. Nous avons rencontré beaucoup d'implémentations ne gérant pas cette possibilité. Par exemple, l'implémentation cherchant à limiter l'utilisation mémoire et représentant un nombre par le nombre minimum d'unités nécessaires (en n'encodant pas les '0' de poids fort), peut se retrouver avec un exposant privé codé sur 2040 bits (255 octets au lieu de 256). Un autre morceau de l'implémentation (ou une autre bibliothèque) vérifie que les éléments en entrée ont la bonne taille et remonte une erreur, puisqu'elle attend 256 octets en entrée. Dans le pire des cas, ce morceau ne vérifie pas la taille des entrées, et réalise un dépassement de tampon (travail sur 256 octets alors que le tampon de donnée en fait 255).
En cryptographie symétrique, ces problèmes sont résolus si le bourrage est correctement implémenté.
Dans tous les cas, notre conseil est d'utiliser des tampons de taille fixée statiquement et d'être consistant sur la façon de bourrer ces tampons.
3.3 Papiers s'il vous plaît
Cette dernière recommandation est une des bonnes pratiques d'implémentation les plus partagées, mais malheureusement trop souvent non appliquée : la vérification et le nettoyage des entrées de fonctions. Une grande partie des bonnes pratiques détaillées dans cet article reposent d'ailleurs sur une bonne vérification des entrées. Nous l'avons vu, en cryptographie, un bug peut causer aussi bien un crash qu'une exécution sans faute informatique, mais au résultat mathématiquement faux ou non sûr. Aussi, cette bonne pratique est ici essentielle.
Une des manières les plus simples de mettre en œuvre cette bonne pratique est de forcer les entrées au format attendu et de ne jamais prendre comme hypothèse d'implémentation que votre entrée est valide.
Par exemple :
- si vous prenez un pointeur en entrée, vérifiez qu'il n'est pas à NULL ;
- fixez statiquement les tailles de tampons, ne travaillez pas sur un tampon dont l'allocation exacte (taille) est non vérifiable ;
- forcez le type des données (cast explicite) ;
- si vous prenez comme hypothèse qu'une donnée est initialisée à 0, réalisez cette initialisation ;
- etc.
La faille Heartbleed [14] est un cas d'école sur l'importance de correctement vérifier ses entrées, et d'imposer les formats attendus en cas de doute.
Conclusion
La plupart des bonnes pratiques indiquées dans cet article sont des bonnes pratiques communes à n'importe quelle implémentation que l'on souhaite sûre. La particularité de la cryptographie dans ce domaine est le fait qu'une mauvaise implémentation ne va pas forcément générer d'erreur détectable, mais affecter silencieusement la sécurité globale du système.
Nous avons parlé des erreurs que nous estimions les plus spécifiques à la cryptographie, mais tous les conseils que vous trouverez dans le dossier de MISC n°82 doivent être appliqués à votre implémentation. Considérez votre bibliothèque cryptographique comme une fonction particulièrement sensible.
Côté attaquants, ces erreurs sont fréquentes, pensez-y à votre prochain audit ou challenge.
Le lecteur attentif l'aura remarqué, une partie des recommandations de l'article sont (très) coûteuses à implémenter. Malheureusement, il est très difficile d'atteindre les deux objectifs d'une implémentation sûre et peu coûteuse. Prenons l'exemple des « dummy operation », opérations inutiles rajoutées pour équilibrer une implémentation et la protéger contre les attaques par canaux cachés : les objectifs « optimisation » et « sécurité de l'implémentation » sont ici complètement opposés. Ce sujet complexe fait l'objet d'un champ de recherche à part entière en cryptographie (les conférences Fast Software Encryption – FSE et Cryptographic Hardware and Embedded Systems – CHES traitent beaucoup du sujet).
Nous conseillons au lecteur intéressé la référence [9], initiative de plusieurs cryptologues souhaitant améliorer le niveau de sécurité des implémentations cryptographiques.
Bibliographie
[1] http://blog.cryptographyengineering.com/2013/03/attack-of-week-rc4-is-kind-of-broken-in.html
[2] http://blog.cryptographyengineering.com/2012/02/random-number-generation-illustrated.html
[3] https://fr.wikipedia.org/wiki/Exponentiation_modulaire
[5] https://www.imperialviolet.org/2014/02/22/applebug.html
[6] https://wiki.debian.org/SSLkeys
[7] https://twitter.com/WatsonLadd/status/630936267224526848
[8] Zéro: La biographie d'une idée dangereuse - Charles Seife - ISBN-10: 2818500273
[9] https://cryptocoding.net/index.php/Coding_rules
[10] http://csrc.nist.gov/groups/ST/toolkit/examples.html
[11] https://www.emc.com/emc-plus/rsa-labs/standards-initiatives/public-key-cryptography-standards.htm
[12] https://www.cosic.esat.kuleuven.be/nessie/
[13] https://en.wikipedia.org/wiki/CBC-MAC
[14] http://www.theregister.co.uk/2014/04/09/heartbleed_explained/
[15] http://cr.yp.to/antiforgery/cachetiming-20050414.pdf