Protéger les mots de passe en base pour les nuls (FR)
lun. 02 octobre 2017 Dan LousquiSur 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
ousha1
? - 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 :
- Exposition de backup sur Internet (Capgemini, novembre 2016) ;
- Injections SQL (OWASP) ;
- Administrateur réseau malveillant ...
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êmemd5
(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, tapezfdd838b687df260a19f752d5c06e7aa0034222fefbf9dab42242e030e6d24ad5
dans google, et vous casserez votre premiersha256
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 lesel
manuellement ; - N'intègre pas la notion de
poivre
, il faut donc le fournir dans lepassword
; - 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 !