Stocker les mots de passes de vos utilisateurs

Divulgâchage : Dès qu’on a besoin de contrôler les accès des utilisateurs, il faut les authentifier et même si des alternatives existent, le couple identifiant + mot de passe reste une méthode simple et relativement efficace. Aujourd’hui, nous allons voir comment sécuriser le stockage de ce sésame (en les hashant après les avoir salés en prenant son temps, bref, ce que fait password_hash() en PHP).

Aujourd’hui, vous avez décidé de développer votre propre application et avez besoin de vérifier l’identité de vos utilisateurs… On est tous passé par là. Et parmi toutes les solutions possibles, vous avez opté pour l’authentification par un mot de passe (personne ne vous juge).

Pour éviter qu’un espion ne lise les mots de passes, vous avez donc créé des certificats et mis en place une connexion sécurisée via HTTPS. Pour rendre les mots de passes difficile à deviner, vous avez même mis en place une politique de mots de passes (taille, caractères nécessaires, …) et des protections anti brute force.

Et maintenant, vous vous demandez quoi faire de plus… Vous êtes au bon endroit 😉.

Pourquoi faire plus ?

Effectivement, votre périmètre défensif est protégé. Un attaquant à l’extérieur ne peut ni intercepter ni deviner les mots de passes.

Citadelle de Lille @ Wikipedia

Le problème, de manière générale, c’est que vous ne pouvez pas être sûr à 100% qu’aucune vulnérabilité n’existe ailleurs dans votre système. Les failles potentielles sont nombreuses et même si vous y mettez toute votre énergie, il y a toujours une petite probabilité qu’il reste quelque chose (ne serait-ce qu’un insider).

L’actualité ne manque pas d’exemples de ces fuites de données dans les entreprises de toutes tailles. Qu’importe les moyens mis en œuvres, une (petite) porte a été trouvée et a permis d’exporter la base. Si vos mots de passes sont en clair, vous exposez vos utilisateurs à des usurpations de leurs identités.

Même si vous jugez l’exploit peu probable, vous devez donc partir du principe qu’un attaquant obtiendra un accès à l’intérieur du périmètre et, in fine, lira le contenu de votre base de donnée avec entre autres, les mots de passes.

En plaçant une deuxième protection à l’intérieur de votre périmètre, vous faites ce qu’on appelle de la défense en profondeur et en matière de sécurité, c’est toujours une bonne idée.

Rendre illisible…

L’idée est donc de rendre les mots de passes illisibles et si vous pensez à la cryptographie, vous êtes sur la bonne voie.

Pourquoi pas chiffrer ?

Contre-intuitivement, chiffrer les mots de passes ne va pas nous aider. Bien sûr ils seront illisibles mais qui dit chiffrement dit clé de déchiffrement qu’il faut stocker quelque part… Si vous la chiffrez, il vous faut alors une deuxième clé, qu’il faudra aussi protéger… Le système est de plus en plus complexe et donc fragile.

On retrouve ce genre de construction dans des applications qui ont besoin de lire les données après les avoir stocké, dont certaines applications de paiement par carte bancaire (cf. conditions 3.4 à 3.6 de la norme PCI-DSS). Dans ce cas, on parle alors de DEK et de KEK :

  • DEK pour Data Encryption Key, une clé pour chiffrer les données,
  • KEK pour Key Encryption Key, une clé pour chiffrer les clés.

Et si vous partez sur ce genre de système, il va vous falloir protéger la KEK, soit avec une autre KEK (et retour à la case départ), soit sur d’autres supports (i.e. des HSM). C’est de plus en plus complexe et vous en viendrez à célébrer des Cérémonie de Clés, une partie du budget devant alors être consacrée aux bougies, encens et autres chapeaux pointus.

Heureusement, comme nous n’avons pas besoin de relire les mots de passes, une autre méthode cryptographique existe, les fonction de hachage. Mais comme on va le voir, il faut bien les choisir et ajouter un sel.

Hacher

Le premier écueil est dans le choix de la fonction de hachage. Celle-ci doit être robuste à une attaque de pré-image, autrement dit, un attaquant disposant de l’empreinte du mot de passe ne devrait pas pouvoir le retrouver plus facilement qu’en testant exhaustivement tous les mots de passes (on dit aussi par brute force).

Oubliez définitivement md5 et sha1 ! Ces fonctions sont cassées, et depuis bien trop longtemps.

stevepb @ pixabay

Lors de l’enregistrement de vos utilisateurs (ou lorsqu’ils changent leur mot de passe), vous allez calculer une empreinte cryptographique et c’est cette empreinte qui sera stockée. Pour l’exemple, voici une première ébauche d’une fonction pour rendre le mot de passe illisible :

function transform($password) {
    return hash("sha512", $password) ;
}

Lors de l’authentification de vos utilisateurs, lorsqu’ils vous fournissent leur mot de passe, vous allez en calculer l’empreinte et la comparer avec celle dans la base de donnée. Voici une première ébauche de ce que ça donnerait :

function verify($password, $transformed) {
    return transform($password) == $transformed ;
}

Nouvel écueil ici lors de la comparaison des chaînes. D’habitude, pour gagner du temps de calcul, on s’arrête de comparer dès qu’on trouve un caractère différent dans les deux chaînes (ça évite de comparer celles qui suivent). Dans notre cas, c’est problématique car en mesurant le temps mis pour répondre, un attaquant aura une information sur la longueur du préfixe commun entre l’empreinte stockée en base, et celle du mot de passe qu’il soumet et par essais et erreurs, de trouver l’empreinte en base (et donc le mot de passe de votre utilisateur).

Pour faire les choses bien, on va donc utiliser la fonction hash_equals pour comparer les empreintes car elle est conçue spécifiquement pour prendre le même temps, qu’importe quand les chaînes diffèrent. C’est plus long mais la sécurité est à ce prix.

function verify($password, $transformed) {
    return hash_equals(transform($password), $transformed) ;
}

Saler

Si on en reste là, en utilisant une fonction de hachage seule, vos mots de passes seront tous transformés de la même façon. Deux mots de passes identiques mais d’utilisateurs différents auront la même empreinte. Et ça va poser problème.

Pourquoi ? Les dictionnaires

Plutôt que de casser les mots de passes en tentant exhaustivement toutes les possibilités (ça peut prendre un temps fou), on pourrait ici utiliser un dictionnaire. En stockant les mots de passes possibles avec leur empreinte, il vous suffira de trouver l’empreinte dans le dictionnaire et ensuite lire le mot de passe correspondant.

Par exemple. Si on construit toutes les empreintes SHA256 des mots de passes de 6 caractères alphanumériques, notre dictionnaire nécessitera 62662^6 (nombre de mots de passes possibles) fois 32 octets (taille d’une empreinte), soit 1,8 To ce qui est largement acceptable.

Si la recherche exhaustive de tous les mots de passes a une complexité en O(n)O(n), la recherche dans un dictionnaire bien rangé tombe à O(log(n))O(log(n)). L’investissement initial pour créer le dictionnaire sera ainsi rentabilisé dès le deuxième mot de passe à cracker.

Certains pourraient se dire qu’il suffit d’imposer des contraintes aux mots de passes pour rendre ces dictionnaires impossibles à stocker mais c’est une erreur car on a trouvé des astuces pour contourner ces limitations de stockage.

Mots de passes fréquents. La plupart des utilisateurs manquent d’originalité et finissent par utiliser, encore et encore, les mêmes mots de passes. Vous pourriez alors créer un dictionnaire avec ces mots de passes et gagner en espace disque. Bien sûr, vous ne casserez pas les trucs vraiment compliqué mais vous devriez avoir un bon taux de réussite sur une base complète.

D’ailleurs, vous n’avez parfois même pas à créer le dictionnaire ni même à faire la recherche vous même s’ils sont indexés (i.e. merci google).

Les tables arc-en-ciel. Sans rentrer dans les détails, il s’agit d’un truc pour compresser un dictionnaire. Plutôt que de stocker les empreintes de tous les mots de passes, vous pouvez en fait en éliminez une bonne proportion que vous pouvez en fait recalculer.

C’est un exemple de compromis temps/mémoire. Par rapport aux dictionnaires, les tables prennent moins de place mais nécessite plus de temps pour trouver un mot de passe. Par rapport à une attaque exhaustive, les tables prennent plus de mémoire mais restent plus rapides.

Pour contrer ces attaques par dictionnaire, il faut donc trouver un moyen pour que deux mots de passes pourtant identiques soient stockés sous des formes différentes…

Comment ? Ajouter de l’aléa

Pour que le résultat du hachage varie d’un mot de passe à l’autre, on va lui ajouter un sel ; c’est à dire des caractères aléatoires et différents pour chaque utilisateur.

Daria-Yakovleva @ pixabay

En effet, si le sel était commun à toute la base, ou s’il était dérivé à partir du mot de passe, un attaquant pourrait créer un dictionnaire spécifique à votre algorithme de stockage, rendant le sel à peine gênant pour l’attaquant.

Lors de l’enregistrement de vos utilisateurs (ou lorsqu’ils changent leur mot de passe), vous allez donc générer une chaîne aléatoire (le sel) et l’ajouter au mot de passe puis calculer une empreinte cryptographique de l’ensemble. C’est le sel et l’empreinte qui seront stockés.

function transform($password) {
    $salt = bin2hex(random_bytes(16)) ; // 128 bits d'aléa
    $hash = hash("sha512", $password . $salt) ;
    return [ $salt, $hash ] ;
}

Lors de l’authentification de vos utilisateurs, lorsqu’ils vous fournissent leur mot de passe, vous allez extraire le sel, le joindre au mot de passe et hacher l’ensemble pour comparer leur empreinte à celle stockée.

function verify($password, $transformed) {
    list($salt, $hash) = explode(".", $transformed) ;
    return hash_equals(hash("sha512", $password . $salt), $hash) ;
}

Ralentir

Jusqu’ici, nous avons garanti qu’un attaquant ne puisse que tenter une attaque par tentatives exhaustives. C’est très bien mais un dernier problème se pose : la vitesse de calcul d’une empreinte.

andrewhatton123 @ pixabay

Pourquoi ? Les GPU

De base, la plupart des fonctions de hachage ont été construite pour aller vite. Elles ont été conçues pour effectuer des contrôles d’intégrité et dans ce domaine, on apprécie que ces calculs se fassent rapidement pour que le processeur puisse passer à autre chose rapidement.

Pour ne rien arranger, des petits malins ont trouvé moyen de faire ces calculs d’empreintes sur les cartes graphiques (abrégés GPU) qui, pour ce type de calcul particulier, explosent tous les records car elles peuvent en calculer plein en parallèle.

Dans notre cas, cette rapidité se retourne contre nous puisque si nous pouvons allons vite pour calculer une empreinte (via un processeur), un attaquant ira encore plus vite (via ses cartes graphiques)…

Pour donner un ordre d’idée, sur notre carte graphique GTX 1050 TI sortie en octobre 2016, hashcat calcule 130 millions de sha512 par seconde. Pour un mot de passe de 6 caractères alphanumériques, il ne lui faudra que 7 minutes pour les tester tous.

En fait, après avoir « protégé » le mot de passe aze123 avec la fonction transform() précédente, il n’a fallu que 2 secondes pour que hashcat ne casse l’empreinte.

Après 2 secondes, Hashcat a cassé le mot de passe

Pour les curieux, la ligne de commande à taper est un peu technique…

hashcat64.exe -a 3 -w 3 -m 1710 -p . -1 ?l?u?d <hash>.<sel> ?1?1?1?1?1?1

Comment ralentir

Il faut donc ralentir ces attaques en utilisant des algorithmes de hachage plus lents. Si c’est plus lent pour nous, ça n’est pas très grave puisque ça n’est pas une opération que nous faisons souvent. Par contre, ça pénalisera l’attaquant puisque lui, ne fait que ça pour trouver les mots de passes.

Plutôt que SHA512, il est alors plus pertinent d’utiliser d’autres fonctions comme bcrypt, scrypt, argon2 ou encore PBKDF2 qui sont conçues pour prendre leur temps et fournissent même un paramètre pour configurer le coût du calcul. Notez que, tout comme le sel, le coût doit aussi être inscrit à côté de l’empreinte pour qu’on puisse la recalculer ensuite.

En PHP, on pourrait vouloir définir une fonction de hachage similaire à bcrypt en utilisant la fonction crypt comme suit :

function mySlowHash($password, $salt, $cost) {
    $options = sprintf('$2a$%\'.02d$%\'.22s$', $cost, $salt) ;
    return crypt($password, $options) ;
}

Lors de l’enregistrement la méthode ne change pas, on calcul un sel puis une empreinte.

function transform($password) {
    $salt = bin2hex(random_bytes(16))    ;
    $hash = mySlowHash($password, $salt, 10) ;
    return $hash ;
}

Lors de l’authentification de vos utilisateurs, vous pouvez utiliser la version stockée comme option pour que crypt() recalcule l’empreinte.

function verify($password, $transformed) {
    $hash = crypt($password, $stored) ;
    return hash_equals($transformed, $hash) ;
}

En maintenant ?

Si vous développez une application, franchement, je vous déconseille de réinventer la roue comme je l’ai fait dans cet article. C’était juste pour vous montrer le pourquoi du comment.

Non seulement faire les choses soi-même, c’est un nid à erreurs potentielles lorsqu’on parle de cryptographie, mais aussi (surtout ?) parce que vous devriez aussi gérer la notion de mise à jours de l’algorithme (que feriez-vous si vous devez augmenter le coût du hachage ? la longueur du sel ? ou l’algorithme ?).

Parce que PHP est quand même un truc sérieux et pratique, on dispose des deux fonctions password_hash() et password_verify() qui font exactement de dont on a besoin :

  1. Elles vont hacher les mots de passes en utilisant les fonctions les plus adaptées du moment,
  2. Elles vont saler les empreintes, en utilisant une génération aléatoire sûre,
  3. Elles vont effectuer leurs opérations en temps constant pour éviter les attaques temporelles,
  4. Elles vont s’occuper des problèmes de formatage pour fonctionner sans accroc.

Je devrais en fait remplacer les deux fonctions transform() et verify() par ces deux-ci :

function transform($password) {
    return password_hash($password, PASSWORD_DEFAULT) ;
}

function verify($password, $transformed) {
    return password_verify($password, $transformed) ;
}

Pour les autres langages, vous n’aurez peut être pas, nativement, de fonctions similaires. Je vous dirais bien de passer à PHP mais je sais que ça n’est pas toujours possibles 😉. Alors voici quelques pistes…