• BLOG

  • Protéger les mots de passe en base pour les nuls (FR)

    Mon 02 October 2017 Dan Lousqui

    Share on: Twitter - Facebook - Google+

    password

    Sur les salons de discussions type IRC (aussi bien les salons de développement que de sécurité), on voit souvent des questions sur le stockage de mot de passe. Parmi les sujets qui reviennent souvent on peut noter:

    • Quel algorithme de chiffrement (quand on ne dit pas "cryptage") utiliser ?
    • C'est quoi le mieux, md5 ou sha1 ?
    • C'est quoi un sel ? Je le stocke où ?

    L'idée de ce billet est d'avoir une approche didactique de l'état de l'art, et plutôt que de corriger ou répondre "bêtement" à ces questions, de voir les problématiques sous-jacentes, et ce qui est mis en place pour se protéger des menaces les plus évidentes.

    Un mot de passe ? Dans quel cas ?

    Afin de partir sur de bonnes bases, parlons définition. Un mot de passe sert à authentifier un utilisateur qui cherche à s'identifier.

    Pour rappel:

    L'identification est une phase qui consiste à établir l'identité de l'utilisateur. Elle permet répondre à la question : "Qui êtes-vous ?". L'utilisateur utilise un identifiant (que l'on nomme "Compte d'accès", "Nom d'utilisateur" ou "Login" en anglais) qui l'identifie et qui lui est attribué individuellement. Cet identifiant est unique.

    L'authentification est une phase qui permet à l'utilisateur d'apporter la preuve de son identité. Elle intervient après la phase dite d'identification. Elle permet de répondre à la question : "Êtes-vous réellement cette personne?". L'utilisateur utilise un authentifiant ou "code secret" que lui seul connait.

    (Source)

    Il existe d'autres façons d'authentifier un utilisateur que de stocker un mot de passe:

    • Authentification par certificat ;
    • Délégation d'authentification (par SSO par ex.) ;
    • Empreinte digitale / biométrique ;
    • Procédé de challenge / réponse ;
    • ...

    Cependant, associer un mot de passe à un utilisateur, et le lui redemander pour l'authentifier reste la méthode la plus courante et la plus simple à implémenter.

    Les premières précautions à avoir

    Ne pas stocker en clair le mot de passe

    Maintenant que l'on souhaite authentifier l'utilisateur avec un mot de passe, il va bien falloir stocker ce mot de passe quelque part.

    Une solution "niaise", serait de stocker directement ce mot de passe en clair.

    Imaginons que l'utilisateur john.doe souhaite utiliser le mot de passe SuperP@ssword01*, le stockage se ferait par la requête suivante:

    INSERT INTO users (login, password) VALUES ('john.doe', 'SuperP@ssword01*');
    

    Et lors de l'authentification de l'utilisateur, il suffira, lors du formulaire d'authentification, de faire un code du style:

    request_user = request.post['user']
    request_password = request.post['password']
    password_sql = execute("SELECT password FROM users WHERE users=?", request_user);
    if password_sql == password_query:
        authenticated()
    else:
        login_failed()
    

    Seulement... IL NE FAUT ABSOLUMENT PAS FAIRE COMME ÇA !!!

    En effet, les fuites de bases de données, ca arrive très souvent :

    Le site haveibeenpwned regorge de leak en tout genre.

    Il faut donc absolument se protéger contre la possibilité de les lire en clair, pour plusieurs raisons évidentes :

    • Vous ne souhaitez pas qu'une personne malveillante usurpe l'identité de vos utilisateurs ;
    • Les utilisateurs restent humains, même si vous déconseillez de le faire, ils utilisent souvent le même mot de passe sur plusieurs services. Avoir le mot de passe de votre application permettrait peut-être à un attaquant de se connecter au webmail d'une victime ;
    • Un mot de passe est considéré comme une information personnelle au niveau de la CNIL et de la GRDP, c'est donc à votre charge de les sécuriser.

    Ne pas crypter chiffrer les mots de passe

    Bref... ok, il ne faut pas stocker les mots de passe en clair dans la base de données, du coup il suffit de crypter chiffrer cette information dans la base de données, comme ça en cas de compromission, impossible de retrouver le mot de passe !

    → NON !

    Si vous chiffrez le mot de passe... où est la clé de déchiffrement ?

    Je me répète: il faut donc absolument se protéger contre la possibilité de les lire en clair.

    Si vous stockez les mots de passe avec un algorithme de chiffrement, il reste possible de les déchiffrer. C'est pour cela qu'il faut utiliser un algorithme de "hash".

    Ne pas utiliser une fonction de "hash" faible (md5, sha1, ...)

    Tout d'abord, qu'est-ce qu'une fonction de "hash" ? Contrairement à ce qu'on entend, non, une fonction de "hash" n'est pas une fonction de chiffrement.

    Il s'agit d'une fonction qui prend en entrée n'importe quel binaire (chaîne de caractère, fichier, vidéo, ...) et en sortie, une chaîne de taille fixe. Elle doit répondre aux caractéristiques suivantes:

    • Pour une même entrée, elle doit toujours fournir la même sortie, quelle que soit la machine sur laquelle l'algorithme est exécuté ;
    • Quelle que soit l'entrée, la sortie doit avoir la même taille ;
    • Un légère modification de l'entrée doit engendrer une sortie complètement différente.

    De plus, pour que la fonction soit sécurisée, les caractéristiques suivantes doivent aussi être présentes :

    • Il doit être (quasiment) impossible, à partir d'une sortie trouvée, de calculer une entrée possible ;
    • Il ne faut pas que pour deux entrées différentes, la même sortie soit possible *note;
    • Il faut que l'exécution du calcul soit (relativement) longue.

    Note : On entend ici un "possible" pratique, et non théorique, car mathématiquement il est trivial de montrer que cette propriété est impossible étant donné la caractéristique de taille fixe de la sortie.

    Il existe de nombreux algorithmes répondant aux premiers critères (CRC32, md5, sha1, ...), cependant, ils ne répondent pas (ou plus) aux critères suivants, permettant de les caractériser comme algorithmes "sécurisés" :

    • Il est très facile de forger un CRC32 en modifiant uniquement 2 octets d'un binaire (https://blog.stalkr.net/2011/03/crc-32-forging.html)
    • Il est très facile, à partir d'un fichier avec un md5 donné, de modifier un autre binaire afin qu'ils aient le même md5 (http://www.mscs.dal.ca/selinger/md5collision/)
    • Des collisions de SHA1 sont réalisables en environnement pratique (https://shattered.io/)

    Ne pas utiliser (seulement) une fonction de "hash" sécurisée (sha256, sha512, ...)

    C'est maintenant que les choses se corsent. Ne pas stocker le mot de passe en clair ou hashé avec md5 / sha1, beaucoup de gens le savent. Du coup, on voit souvent des applications qui utilisent uniquement sha256 par exemple.

    Cela peut être caractérisé par le code suivant lors de l'inscription:

    request_user = request.post['user']
    request_password = request.post['password']
    protected_password = hash.sha256(request_password)
    execute("INSERT INTO users (login, password) VALUES (?, ?)", request_user, request_password);
    

    Et lors de l'authentification de l'utilisateur, il suffira, lors du formulaire d'authentification, de faire un code du style:

    request_user = request.post['user']
    request_password = request.post['password']
    protected_password = hash.sha256(request_password)
    password_sql = execute("SELECT password FROM users WHERE users=?", request_user);
    if password_sql == protected_password:
        authenticated()
    else:
        login_failed()
    

    Cependant, ce code n'est clairement pas sécurisé, et est vulnérable aux attaques suivantes :

    • Deux utilisateurs avec le même hash en base de données auront le même mot de passe. Ainsi, sur une base de données de 10.000 utilisateurs, en triant les mots de passe par nombre d'occurrences, sachant que les mots de passe les plus utilisés sont azerty / password / P@assword01, il sera très facile de savoir à qui s'attaquer en premier.
    • Bien que qualifié de sécurisé, sha256 est dorénavant "connu", et des dictionnaires existent. Par exemple, tapez fdd838b687df260a19f752d5c06e7aa0034222fefbf9dab42242e030e6d24ad5 dans google, et vous casserez votre premier sha256 les doigts dans le nez :-)

    Ce qu'il faut faire

    Une fois la liste de ce qu'il ne faut pas faire terminée, nous allons faire... ce qu'il faut faire !

    Nous allons créer deux fonctions:

    • l'une pour "générer" ce que nous allons stocker en base de données ;
    • l'autre pour "valider" que le mot de passe envoyé par un utilisateur est valide.

    Partir d'une fonction de "hash" sécurisée

    Tout d'abord, partons d'une fonction de hash réputée "solide", par exemple, sha256 (je fais le code en pseudo-python, mais cela doit être compréhensif pour tous):

    def secure_password_generate(password):
        return hash.sha256(password)
    
    def secure_password_validate(password, secure_password):
        return secure_password == hash.sha256(password)
    

    Pour l'instant simple, mais comme nous l'avons vu précédemment, cela n'est pas encore sécurisé.

    Ajouter un peu de sel

    Afin que deux personnes ayant le même mot de passe n'aient jamais le même "reliquat" stocké en base de données, nous allons générer une chaîne aléatoire pour chaque génération de secure_password. Cette chaîne sera appelé sel (ou salt en anglais), et sera stockée en clair dans la base de données.

    Afin de ne pas être trop "lourd" en stockage, nous allons simplement concaténer le sel au secure_password. Il sera alors facile de l'extraire lors de la validation :

    def secure_password_generate(password):
        salt = random.string(32)
        secure_password_salted = hash.sha256(password + salt)
        return secure_password_salted + "_" + salt
    
    def secure_password_validate(password, secure_password):
        secure_password_salted = secure_password.split("_")[0]
        salt = secure_password.split("_")[1]
        submitted_password_salted = hash.sha256(password + salt)
        return secure_password_salted == submitted_password_salted
    

    Deux choses sur les gens ont parfois un doute :

    • Le sel n'est pas une information "secrète", il est bel est bien stocké en clair dans la base de données, et ce n'est pas un problème ;
    • Le sel doit être différent pour tous les utilisateurs, sinon cela n'a plus d'intérêt.

    Ne pas oublier le poivre

    Un peu plus méconnu des moldus, le poivre (ou pepper en anglais), c'est l'inverse du sel :

    • Le poivre est une information "sécrète", il ne doit JAMAIS être stocké en base de données ;
    • Le poivre sera le même pour TOUS vos utilisateurs.

    A quoi cela sert-il ? Simple, en cas de compromission de votre base de données, et uniquement de votre base de données, sans le poivre, un attaquant sera dans l'impossibilité de démarrer une attaque sur les mots de passe de vos utilisateurs.

    Cela s'implémente ainsi :

    pepper = config["MONAPP_PEPPER"]
    
    def secure_password_generate(password):
        salt = random.string(32)
        secure_password_flavored = hash.sha256(password + salt + pepper)
        return secure_password_flavored + "_" + salt
    
    def secure_password_validate(password, secure_password):
        secure_password_flavored = secure_password.split("_")[0]
        salt = secure_password.split("_")[1]
        submitted_password_flavored = hash.sha256(password + salt + pepper)
        return secure_password_flavored == submitted_password_flavored
    

    Note: flavored signifie assaisonné en anglais, donc avec du sel et poivre :-)

    Itérer un peu tout ça

    Maintenant on commence à avoir quelque chose d'un peu plus sérieux !

    Cependant, le calcul de sha256 est un peu trop simple et rapide, du coup, au lieu d'appliquer "simplement" sha256, nous allons l'appliquer 1 000 fois (par exemple). Ainsi, lors d'une attaque, il faudra 1 000 fois plus de temps pour casser un mot de passe.

    Cela s'implémente ainsi :

    pepper = config["MONAPP_PEPPER"]
    
    def secure_password_generate(password, iter=1000):
        salt = random.string(32)
        password_flavored = password + salt + pepper
        secure_password_flavored = password_flavored
        for i in range(iter):
            secure_password_flavored = hash.sha256(secure_password_flavored)
        return secure_password_flavored + "_" + salt
    
    def secure_password_validate(password, secure_password, iter=1000):
        secure_password_flavored = secure_password.split("_")[0]
        salt = secure_password.split("_")[1]
        submitted_password_flavored = password + salt + pepper
        for i in range(iter):
            submitted_password_flavored = hash.sha256(submitted_password_flavored) 
        return secure_password_flavored == submitted_password_flavored
    

    Ne pas réinventer la roue

    Ce que l'on vient de construire là, c'est à peu de chose de près, l'état de l'art concernant le stockage de mot de passe. Cependant, comme à peu près tout en cryptographie, voir même en développement de façon générale, il est très mauvais de réinventer la roue.

    En ce qui concerne des fonctions pour stocker et valider des mots de passe, de nombreux algorithmes existent déjà (et reprennent, dans les grandes lignes, ce que nous avons fait) :

    Attention par contre, ces quatre algorithmes :

    • N'intègrent pas l'algorithme de génération d'aléa pour le sel, il faut donc fournir le sel manuellement ;
    • N'intègre pas la notion de poivre, il faut donc le fournir dans le password ;
    • Sont limités (notamment bcrypt) à un certain nombre de caractère en entrée, le poivre ne doit pas faire dépasser cette limite.

    Conclusion

    Une autre chose dont on n'a pas parlé, c'est comment choisir son algorithme de hash. Cependant, pour détailler cela, il faudrait détailler Comment casser des hashs. Cela sera peut-être le sujet d'un prochain billet.

    Cependant, pour les impatients, le choix de l'algorithme de hash peut se faire par celui qui sera le plus difficile à casser avec les méthodes actuelles. Il faut donc des algorithmes forts contre le calcul par GPU (carte graphique). Pour cela, les algorithmes Argon2d/Argon2i et bcrypt sont de premiers choix !

  • Comments