MODULE A01
Mots de passe & Hashing
Les mots de passe sont la cible numéro un des attaques. Stocker ou hacher un mot de passe incorrectement expose des millions d'utilisateurs en cas de fuite de base de données.
1. Les grandes fuites — pourquoi ça arrive
RockYou (2009)
32 millions de mots de passe stockés en clair. Révèle que "123456" et "password" sont les plus utilisés.
LinkedIn (2012)
117 millions de hashes SHA-1 sans sel. 90% crackés en quelques jours via rainbow tables.
Adobe (2013)
153 millions de comptes avec mots de passe chiffrés (pas hashés) avec 3DES en mode ECB — facilement réversible.
Have I Been Pwned
Plus de 12 milliards de credentials compromis recensés. API publique pour vérifier sans exposer le mot de passe.
🎯 La règle absolue : Un mot de passe ne doit jamais être stocké en clair, ni chiffré de manière réversible. Il doit être haché avec un algorithme conçu pour ça.
2. Hashing vs Chiffrement — la différence fondamentale
| Propriété | Hachage (hash) | Chiffrement |
|---|---|---|
| Réversibilité | ❌ À sens unique | ✅ Réversible avec la clé |
| Usage mots de passe | ✅ Recommandé | ❌ Déconseillé |
| Usage données PII | ❌ Impossible à relire | ✅ Si besoin de relire |
| Exemples | bcrypt, argon2, SHA-256 | AES-256-GCM, RSA |
| Clé requise | Non (ou pepper optionnel) | Oui |
Propriétés d'un bon hash cryptographique
// 1. Déterministe — même entrée → même sortie
hash("password123") → "abc123..." // toujours identique
// 2. Effet avalanche — 1 bit différent → hash totalement différent
hash("password123") → "abc123..."
hash("Password123") → "zxy987..." // totalement différent
// 3. Non réversible — impossible de retrouver l'entrée depuis le hash
hash("password123") → "abc123..." // OK
"abc123..." → ??? // Impossible mathématiquement
// 4. Sans collision (idéalement) — deux entrées ≠ → deux hashes ≠
⚠️ SHA-256 seul n'est PAS adapté aux mots de passe : il est conçu pour être rapide. Sur une RTX 4090 : ~10 milliards de SHA-256/seconde. Un mot de passe de 8 caractères se crack en secondes.
3. bcrypt — l'algorithme recommandé
Fonctionnement
bcrypt est basé sur le chiffrement Blowfish. Il intègre nativement un sel aléatoire et un cost factor (nombre de rounds) qui rend le hachage délibérément lent.
// Format d'un hash bcrypt
$2b$12$8Hqn.../jM7K4l7s2fPu // 60 caractères toujours
│ │ │
│ │ └─ sel (22 chars) + hash (31 chars)
│ └──── cost factor (12 = 2^12 = 4096 rounds)
└──────── version bcrypt (2b = recommandée)
Usage Node.js
const bcrypt = require('bcrypt');
// ✅ Hachage — cost factor 12 recommandé (≥ 10 en production)
const SALT_ROUNDS = 12;
const hash = await bcrypt.hash(password, SALT_ROUNDS);
// Durée ~ 250ms sur serveur moderne → 4 hash/s max → brute force impossible
// ✅ Vérification — comparaison en temps constant (pas de timing attack)
const isValid = await bcrypt.compare(inputPassword, storedHash);
// Le sel est extrait automatiquement du hash stocké
// ✅ Même mot de passe → hashes DIFFÉRENTS (sel aléatoire à chaque fois)
bcrypt.hash("password", 12) → "$2b$12$ABC..."
bcrypt.hash("password", 12) → "$2b$12$XYZ..." // différent !
// ❌ Ne JAMAIS faire :
const hash = md5(password); // rapide, sans sel
const hash = sha256(password); // rapide, vulnérable GPU
const hash = sha256(password + 'fixedSalt'); // sel statique = mauvais
Impact du cost factor
| Cost | Rounds | Durée ~ | Usage |
|---|---|---|---|
| 8 | 256 | ~3ms | Tests unitaires |
| 10 | 1 024 | ~65ms | Minimum acceptable |
| 12 | 4 096 | ~250ms | Recommandé production |
| 14 | 16 384 | ~1s | Haute sécurité |
Pepper — couche de sécurité supplémentaire
// Pepper = secret serveur concaténé AVANT le hash
// Stocké en variable d'environnement (pas en base de données)
const PEPPER = process.env.PASSWORD_PEPPER; // secret aléatoire 32 chars
const hash = await bcrypt.hash(password + PEPPER, 12);
// Avantage : même si la BDD fuite, sans le pepper les hashes sont inutilisables
4. scrypt & Argon2 — algorithmes memory-hard
Pourquoi "memory-hard" ?
bcrypt peut être accéléré par des GPUs car il n'utilise que peu de mémoire. scrypt et Argon2 nécessitent beaucoup de RAM — les rendre difficiles à paralléliser sur GPU/ASIC.
| Algorithme | GPU résistance | Paramètres | Recommandation 2024 |
|---|---|---|---|
bcrypt | Moyenne | cost factor | ✅ Acceptable |
scrypt | Forte | N, r, p | ✅ Bon choix |
argon2id | Très forte | m, t, p | ⭐ Meilleur choix |
Argon2id en Node.js
const argon2 = require('argon2');
// Hachage avec paramètres recommandés OWASP 2024
const hash = await argon2.hash(password, {
type: argon2.argon2id, // résistant aux side-channel ET GPU
memoryCost: 65536, // 64 MB RAM requis
timeCost: 3, // 3 passes
parallelism: 4, // 4 threads max
});
// Format : $argon2id$v=19$m=65536,t=3,p=4$SALT$HASH
// Vérification
const isValid = await argon2.verify(storedHash, inputPassword);
// scrypt natif Node.js (crypto module)
const { scrypt, randomBytes } = require('crypto');
const salt = randomBytes(32);
// N=2^17, r=8, p=1 = paramètres recommandés OWASP
scrypt(password, salt, 64, { N: 131072, r: 8, p: 1 }, (err, derivedKey) => {
const hash = salt.toString('hex') + ':' + derivedKey.toString('hex');
});
5. Politique de mots de passe
NIST SP 800-63B (2017, mis à jour 2024)
- Longueur minimale : 8 caractères (recommandé : permettre jusqu'à 64+)
- Autoriser tous les caractères Unicode (espaces inclus)
- Ne PAS forcer la rotation périodique sans raison (augmente les mauvaises pratiques)
- Vérifier contre les listes de mots de passe compromis (HIBP)
- Ne PAS imposer des règles de composition rigides (complexité artificielle = post-it)
- Bloquer les mots de passe contextuels (nom du site, prénom…)
HIBP — Have I Been Pwned API (k-anonymity)
// Vérification sans exposer le mot de passe complet
async function checkHIBP(password) {
const hash = await crypto.subtle.digest('SHA-1', new TextEncoder().encode(password));
const hex = [...new Uint8Array(hash)].map(b => b.toString(16).padStart(2, '0')).join('').toUpperCase();
const prefix = hex.slice(0, 5); // Envoyer seulement les 5 premiers chars
const suffix = hex.slice(5);
const res = await fetch(`https://api.pwnedpasswords.com/range/${prefix}`);
const text = await res.text();
const found = text.split('\n').find(line => line.startsWith(suffix));
return found ? parseInt(found.split(':')[1]) : 0; // nb de fois compromise
}
// Le serveur HIBP ne voit jamais le mot de passe ni son hash complet
zxcvbn — estimation de force réaliste
const zxcvbn = require('zxcvbn');
const result = zxcvbn('P@ssw0rd');
// result.score : 0-4 (0=très faible, 4=fort)
// result.feedback.suggestions : ["Évitez les substitutions prévisibles"]
// result.crack_times_display.online_no_throttling_10_per_second: "3 heures"
// ⚠️ P@ssw0rd score=2 malgré complexité apparente (dictionnaire commun)