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
Exemplesbcrypt, argon2, SHA-256AES-256-GCM, RSA
Clé requiseNon (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

CostRoundsDurée ~Usage
8256~3msTests unitaires
101 024~65msMinimum acceptable
124 096~250msRecommandé production
1416 384~1sHaute 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.

AlgorithmeGPU résistanceParamètresRecommandation 2024
bcryptMoyennecost factor✅ Acceptable
scryptForteN, r, p✅ Bon choix
argon2idTrès fortem, 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)
→ Exercices 🔑 Password Analyzer 📝 QCM A01