Cryptographie appliquée#
Prérequis : linux/15_cryptographie.md couvre les commandes GPG, OpenSSL, et les concepts AES/RSA/TLS en ligne de commande. Ce chapitre approfondit ces sujets au niveau implémentation Python et au niveau des décisions de design : quel algorithme choisir, pourquoi, avec quels paramètres, et quelles erreurs éviter.
Bibliothèque de référence
Toutes les implémentations utilisent la bibliothèque cryptography (PyCA), qui expose une API de haut niveau (Fernet, AESGCM) et une API bas niveau (hazmat) pour les algorithmes primitifs. Ne jamais implémenter soi-même des primitives cryptographiques.
Chiffrement symétrique : AES-256-GCM#
Modes d’opération AES#
AES est un chiffrement par blocs de 128 bits. Son comportement dépend du mode d’opération :
Mode |
Confidentialité |
Intégrité/Auth |
IV/Nonce |
Remarques |
|---|---|---|---|---|
ECB |
Oui (faible) |
Non |
Non |
À ne jamais utiliser : les blocs identiques produisent des blocs chiffrés identiques |
CBC |
Oui |
Non |
IV aléatoire |
Sensible aux attaques de padding (BEAST, POODLE) |
CTR |
Oui |
Non |
Nonce + compteur |
Transforme un chiffrement par blocs en flux ; pas d’authentification |
GCM |
Oui |
Oui (AEAD) |
Nonce 96 bits |
Standard actuel : chiffrement + authentification en un seul pass |
GCM (Galois/Counter Mode) est un mode AEAD (Authenticated Encryption with Associated Data) : il produit un tag d’authentification de 128 bits qui garantit l’intégrité du chiffré et éventuellement de données associées non chiffrées (ex. en-têtes).
Pourquoi le nonce ne doit jamais être réutilisé#
En GCM, réutiliser le même nonce avec la même clé expose la clé d’authentification et permet à un attaquant de déchiffrer les messages. La règle absolue : un nonce unique par message, généré aléatoirement via secrets.token_bytes(12).
# AES-256-GCM : chiffrement et déchiffrement complets
def aes_gcm_encrypt(plaintext: bytes, aad: bytes = b"") -> dict:
"""Chiffre avec AES-256-GCM. Retourne clé, nonce, chiffré+tag."""
key = AESGCM.generate_key(bit_length=256) # 32 octets
nonce = secrets.token_bytes(12) # 96 bits — recommandé pour GCM
aesgcm = AESGCM(key)
ciphertext = aesgcm.encrypt(nonce, plaintext, aad) # tag 16 octets concaténés
return {"key": key, "nonce": nonce, "ciphertext": ciphertext, "aad": aad}
def aes_gcm_decrypt(params: dict) -> bytes:
"""Déchiffre et vérifie l'authenticité. Lève InvalidTag si altéré."""
aesgcm = AESGCM(params["key"])
return aesgcm.decrypt(params["nonce"], params["ciphertext"], params["aad"])
message = b"Donnees sensibles : numero de carte 4111-xxxx-xxxx-xxxx"
aad_data = b"user_id=42;session=abc123" # données associées, non chiffrées mais authentifiées
ctx = aes_gcm_encrypt(message, aad_data)
recovered = aes_gcm_decrypt(ctx)
print(f"Clé (hex) : {ctx['key'].hex()}")
print(f"Nonce (hex) : {ctx['nonce'].hex()}")
print(f"Chiffré+tag : {ctx['ciphertext'].hex()[:64]}… ({len(ctx['ciphertext'])} octets)")
print(f"Message récup : {recovered.decode()}")
# Simulation d'altération : modifier un octet du chiffré
from cryptography.exceptions import InvalidTag
tampered = bytearray(ctx["ciphertext"])
tampered[5] ^= 0xFF
try:
aes_gcm_decrypt({**ctx, "ciphertext": bytes(tampered)})
print("ERREUR : altération non détectée !")
except InvalidTag:
print("OK : altération détectée par le tag d'authentification GCM.")
Clé (hex) : ea4e3add5d67ecf6647c337640ad9328528e90d60b88cb008f3a3048f2b00180
Nonce (hex) : 571fba9b8c39ef075e106fe5
Chiffré+tag : 08f603e587abab28f23fcf65466ed47107dd714fc5610f5087f5900937615c53… (71 octets)
Message récup : Donnees sensibles : numero de carte 4111-xxxx-xxxx-xxxx
OK : altération détectée par le tag d'authentification GCM.
Chiffrement asymétrique#
RSA-OAEP vs PKCS#1 v1.5#
RSA peut être utilisé pour chiffrer ou signer. Pour le chiffrement, le padding est critique :
PKCS#1 v1.5 : padding historique, vulnérable à l’attaque de Bleichenbacher (1998) et à ses variantes modernes. À ne plus utiliser pour le chiffrement.
OAEP (Optimal Asymmetric Encryption Padding) : padding randomisé, résistant aux attaques de chiffré choisi. Standard actuel.
Pour la signature :
PKCS#1 v1.5 est encore acceptable (moins d’attaques actives connues) mais PSS lui est préféré.
PSS (Probabilistic Signature Scheme) : signature randomisée, sécurité prouvée.
ECDH — échange de clé sur courbes elliptiques#
ECDH (Elliptic Curve Diffie-Hellman) permet à deux parties d’établir un secret partagé sans jamais le transmettre sur le réseau. Les deux parties génèrent une paire de clés éphémères ; chacune envoie sa clé publique ; le secret partagé est calculé localement.
Les courbes les plus utilisées :
P-256 (secp256r1) : standard NIST, bien audité, supporté partout
X25519 : courbe de Bernstein, performances supérieures, résistance accrue aux implémentations défaillantes
P-384 : pour les contextes haute sécurité (données classifiées)
Ed25519 — signature rapide et sûre#
Ed25519 (Edwards-curve DSA sur Curve25519) est l’algorithme de signature recommandé pour les nouveaux systèmes :
Signatures de 64 octets
Clés de 32 octets
Résistant aux attaques par timing
Pas de générateur aléatoire requis lors de la signature (contrairement à ECDSA)
# Ed25519 : génération de paire de clés, signature, vérification
private_key = Ed25519PrivateKey.generate()
public_key = private_key.public_key()
message_à_signer = b"Transfert de 1000 EUR vers IBAN FR76..."
signature = private_key.sign(message_à_signer)
print(f"Clé privée (raw hex) : {private_key.private_bytes_raw().hex()}")
print(f"Clé publique (raw) : {public_key.public_bytes_raw().hex()}")
print(f"Signature (64 oct.) : {signature.hex()}")
# Vérification (lève InvalidSignature si altérée)
from cryptography.exceptions import InvalidSignature
try:
public_key.verify(signature, message_à_signer)
print("Signature valide.")
except InvalidSignature:
print("Signature invalide !")
# Tentative avec un message altéré
try:
public_key.verify(signature, b"Transfert de 9999 EUR vers IBAN FR76...")
print("ERREUR : signature acceptée sur message altéré.")
except InvalidSignature:
print("OK : signature rejetée sur message altéré.")
Clé privée (raw hex) : e954af5a180bb4de97f7483cca2c17fa2f30af296a0d421b83870e6a9d1ff2fa
Clé publique (raw) : 7dd238e656a39ee7fb942e0241810db505e3c42adf695a196326f67e55346419
Signature (64 oct.) : 766437cd8ac9754606f2fe7bfba589f94f0875bb71d0737f7d58549d8ec7cad32fc85acca561cb0fa0d82693a4cac960731c863cbab580daa42f07e0736a0503
Signature valide.
OK : signature rejetée sur message altéré.
RSA vs courbes elliptiques#
Propriété |
RSA-2048 |
RSA-3072 |
ECDSA/ECDH P-256 |
Ed25519 |
|---|---|---|---|---|
Taille clé publique |
256 oct. |
384 oct. |
64 oct. |
32 oct. |
Taille signature |
256 oct. |
384 oct. |
~72 oct. |
64 oct. |
Niveau sécurité |
~112 bits |
~128 bits |
~128 bits |
~128 bits |
Performance (sig/s) |
~1 000 |
~350 |
~8 000 |
~70 000 |
Problème difficile |
Factorisation |
Factorisation |
Log discret EC |
Log discret EC |
Fonctions de hachage#
Une fonction de hachage cryptographique mappe une entrée de taille arbitraire vers un digest de taille fixe, avec les propriétés suivantes :
Résistance à la préimage : impossible de retrouver l’entrée depuis le digest
Résistance à la seconde préimage : impossible de trouver un autre message avec le même digest
Résistance aux collisions : impossible de trouver deux messages distincts avec le même digest
Birthday attack#
Pour un digest de \(n\) bits, une attaque par anniversaire peut trouver une collision en \(O(2^{n/2})\) opérations. Conséquence :
SHA-1 (160 bits) : collisions trouvées en pratique (SHAttered, 2017). Obsolète.
MD5 (128 bits) : collisions triviales. Interdit.
SHA-256 (256 bits) : \(2^{128}\) opérations pour une collision. Sûr.
SHA-3-256 (256 bits) : famille Keccak, construction sponge, résistant même si SHA-2 était cassé.
BLAKE2b (jusqu’à 512 bits) : plus rapide que SHA-2 en logiciel, aussi sûr.
sns.set_theme(style="whitegrid", palette="muted", font_scale=1.1)
# Comparaison des temps de hachage : MD5 vs SHA-256 vs SHA-3-256 vs BLAKE2b
data = os.urandom(1024 * 1024) # 1 Mo de données aléatoires
repetitions = 50
algos = {
"MD5": lambda: hashlib.md5(data).digest(),
"SHA-256": lambda: hashlib.sha256(data).digest(),
"SHA-3-256":lambda: hashlib.sha3_256(data).digest(),
"BLAKE2b": lambda: hashlib.blake2b(data).digest(),
}
timings = {}
for name, fn in algos.items():
t = timeit.timeit(fn, number=repetitions)
timings[name] = (t / repetitions) * 1000 # ms par appel
fig, ax = plt.subplots(figsize=(8, 4))
bars = ax.bar(list(timings.keys()), list(timings.values()),
color=["#e74c3c", "#3498db", "#2ecc71", "#9b59b6"],
edgecolor="#2c3e50", linewidth=1.2)
ax.set_ylabel("Temps moyen (ms) pour 1 Mo", fontsize=10)
ax.set_title("Comparaison des algorithmes de hachage — 1 Mo de données", fontsize=12, fontweight="bold")
for bar, val in zip(bars, timings.values()):
ax.text(bar.get_x() + bar.get_width() / 2, bar.get_height() + 0.01,
f"{val:.2f} ms", ha="center", va="bottom", fontsize=9)
ax.set_ylim(0, max(timings.values()) * 1.35)
plt.savefig("hash_timing.png", dpi=120, bbox_inches="tight")
plt.show()
for name, t in timings.items():
print(f"{name:<12} {t:.3f} ms/Mo — digest size: {len(algos[name]())} octets")
MD5 1.399 ms/Mo — digest size: 16 octets
SHA-256 0.592 ms/Mo — digest size: 32 octets
SHA-3-256 2.587 ms/Mo — digest size: 32 octets
BLAKE2b 1.669 ms/Mo — digest size: 64 octets
Effet avalanche#
Une bonne fonction de hachage est non-linéaire : changer 1 bit dans l’entrée doit modifier environ 50 % des bits du digest. C’est la propriété d’avalanche.
sns.set_theme(style="whitegrid", palette="muted", font_scale=1.1)
def count_differing_bits(h1: bytes, h2: bytes) -> int:
"""Compte le nombre de bits différents entre deux digests."""
diff = 0
for b1, b2 in zip(h1, h2):
xor = b1 ^ b2
diff += bin(xor).count("1")
return diff
message_original = b"La cryptographie est l'art de garder des secrets."
n_experiments = 200
total_bits = 256 # SHA-256
bit_diffs = []
for _ in range(n_experiments):
# Choisir un bit aléatoire à inverser dans le message
byte_idx = secrets.randbelow(len(message_original))
bit_idx = secrets.randbelow(8)
mut = bytearray(message_original)
mut[byte_idx] ^= (1 << bit_idx)
h_orig = hashlib.sha256(message_original).digest()
h_mut = hashlib.sha256(bytes(mut)).digest()
bit_diffs.append(count_differing_bits(h_orig, h_mut))
fig, ax = plt.subplots(figsize=(9, 4))
ax.hist(bit_diffs, bins=30, color="#3498db", edgecolor="#2c3e50", alpha=0.85)
ax.axvline(total_bits / 2, color="#e74c3c", linewidth=2, linestyle="--", label=f"50% = {total_bits//2} bits")
ax.axvline(np.mean(bit_diffs), color="#2ecc71", linewidth=2, linestyle="-",
label=f"Moyenne = {np.mean(bit_diffs):.1f} bits")
ax.set_xlabel("Nombre de bits différents dans le digest SHA-256", fontsize=10)
ax.set_ylabel("Fréquence (sur 200 expériences)", fontsize=10)
ax.set_title("Effet avalanche SHA-256 — 1 bit modifié dans l'entrée", fontsize=12, fontweight="bold")
ax.legend(fontsize=9)
plt.savefig("avalanche.png", dpi=120, bbox_inches="tight")
plt.show()
print(f"Bits différents — moyenne: {np.mean(bit_diffs):.1f}, min: {min(bit_diffs)}, max: {max(bit_diffs)}")
print(f"Pourcentage moyen : {np.mean(bit_diffs)/total_bits*100:.1f}%")
Bits différents — moyenne: 127.1, min: 106, max: 149
Pourcentage moyen : 49.7%
Hachage de mots de passe#
Hacher un mot de passe avec SHA-256 est une erreur grave. Les raisons :
SHA-256 est rapide : un attaquant peut tester des milliards de mots de passe par seconde sur GPU.
Pas de sel intégré : les tables arc-en-ciel permettent de casser tous les mots de passe identiques en une seule passe.
Les algorithmes dédiés au hachage de mots de passe introduisent intentionnellement une lenteur contrôlée et un sel aléatoire :
bcrypt#
bcrypt utilise le chiffrement Blowfish avec un facteur de coût (work factor) qui détermine le nombre d’itérations (\(2^{work\_factor}\)). Facteur recommandé : 12 (env. 250 ms). Digest de 60 caractères incluant le sel.
PBKDF2-HMAC-SHA256#
Standard NIST/FIPS. Paramètre : nombre d’itérations. OWASP recommande 600 000 itérations avec SHA-256 en 2024. Disponible nativement dans Python via hashlib.pbkdf2_hmac.
Argon2id#
Gagnant de la Password Hashing Competition (2015). Paramètres : temps (itérations), mémoire (MiB), parallélisme. Argon2id combine la résistance aux attaques side-channel (Argon2i) et aux attaques GPU (Argon2d). Recommandé par OWASP comme premier choix.
sns.set_theme(style="whitegrid", palette="muted", font_scale=1.1)
# Simulation des temps de hachage de mots de passe
# bcrypt et argon2 peuvent ne pas être installés — on simule avec des valeurs réalistes
import time
password = b"MotDePasse$ecret2026!"
salt = secrets.token_bytes(16)
# PBKDF2-HMAC-SHA256 — disponible nativement
def bench_pbkdf2(iterations: int) -> float:
start = time.perf_counter()
hashlib.pbkdf2_hmac("sha256", password, salt, iterations)
return time.perf_counter() - start
# SHA-256 brut — pour comparaison (à ne pas utiliser pour les mots de passe)
def bench_sha256_raw() -> float:
start = time.perf_counter()
hashlib.sha256(password + salt).digest()
return time.perf_counter() - start
t_sha256 = bench_sha256_raw() * 1000
t_pbkdf2_100k = bench_pbkdf2(100_000) * 1000
t_pbkdf2_600k = bench_pbkdf2(600_000) * 1000
# Valeurs simulées réalistes pour bcrypt (work_factor=12) et Argon2id
# (basées sur des benchmarks documentés sur matériel standard, ~2025)
t_bcrypt_12 = 250.0 # ms — work factor 12
t_argon2id = 300.0 # ms — m=65536 KiB, t=3, p=4
labels = ["SHA-256 brut\n(NE PAS utiliser)", "PBKDF2 100k", "PBKDF2 600k\n(OWASP 2024)", "bcrypt WF=12\n(simulé)", "Argon2id\n(simulé)"]
timings = [t_sha256, t_pbkdf2_100k, t_pbkdf2_600k, t_bcrypt_12, t_argon2id]
colors = ["#e74c3c", "#e67e22", "#f1c40f", "#2ecc71", "#3498db"]
fig, ax = plt.subplots(figsize=(10, 5))
bars = ax.bar(labels, timings, color=colors, edgecolor="#2c3e50", linewidth=1.2)
ax.set_ylabel("Temps de hachage (ms)", fontsize=10)
ax.set_title("Temps de hachage de mots de passe selon l'algorithme", fontsize=12, fontweight="bold")
ax.set_yscale("log")
for bar, val in zip(bars, timings):
ax.text(bar.get_x() + bar.get_width() / 2, bar.get_height() * 1.3,
f"{val:.1f} ms", ha="center", va="bottom", fontsize=8.5)
plt.savefig("password_hashing.png", dpi=120, bbox_inches="tight")
plt.show()
print(f"SHA-256 brut : {t_sha256:.4f} ms — ~{int(1000/t_sha256):,} hachages/s")
print(f"PBKDF2 100k : {t_pbkdf2_100k:.1f} ms")
print(f"PBKDF2 600k : {t_pbkdf2_600k:.1f} ms")
print(f"bcrypt WF=12 : {t_bcrypt_12:.0f} ms (simulé)")
print(f"Argon2id : {t_argon2id:.0f} ms (simulé)")
SHA-256 brut : 0.0341 ms — ~29,365 hachages/s
PBKDF2 100k : 24.9 ms
PBKDF2 600k : 146.1 ms
bcrypt WF=12 : 250 ms (simulé)
Argon2id : 300 ms (simulé)
Choix de l’algorithme
Pour un nouveau système : Argon2id avec m=65536 (64 MiB), t=3, p=4. Si Argon2 n’est pas disponible (environnements contraints) : bcrypt avec work factor 12. PBKDF2 est un recours acceptable si FIPS est requis.
HMAC — Intégrité et authenticité de messages#
Un hachage simple (SHA-256 d’un message) ne prouve pas l’authenticité : n’importe qui peut calculer SHA-256. HMAC (Hash-based Message Authentication Code) combine le message avec une clé secrète partagée :
HMAC(K, m) = H((K ⊕ opad) ‖ H((K ⊕ ipad) ‖ m))
HMAC garantit que seule une partie connaissant la clé K peut produire ou vérifier le code. Il résiste aux attaques par extension de longueur (length extension attacks) qui affectent SHA-256 brut.
Comparaison sécurisée
Comparer des HMACs avec l’opérateur == Python expose aux attaques par timing (la comparaison s’arrête au premier octet différent). Toujours utiliser hmac.compare_digest() qui compare en temps constant.
import hmac, hashlib, secrets
clé = secrets.token_bytes(32)
message = b"Virement 500 EUR vers compte 123"
mac = hmac.new(clé, message, hashlib.sha256).digest()
print(f"HMAC-SHA256 : {mac.hex()}")
# Vérification en temps constant
mac_à_vérifier = hmac.new(clé, message, hashlib.sha256).digest()
print(f"Valide : {hmac.compare_digest(mac, mac_à_vérifier)}")
Protocole hybride : pourquoi TLS combine asymétrique et symétrique#
Le chiffrement asymétrique (RSA, ECDH) est lent : chiffrer 1 Mo avec RSA-2048 est des milliers de fois plus lent qu’avec AES-256-GCM. Le chiffrement symétrique est rapide mais nécessite que les deux parties partagent la même clé secrète — comment la distribuer en sécurité ?
La solution : le protocole hybride.
Phase asymétrique (coûteuse, une seule fois) : les deux parties utilisent ECDH pour établir un secret partagé sans jamais le transmettre en clair.
Dérivation de clé : le secret ECDH est passé dans une KDF (ex. HKDF) pour produire des clés de session AES-GCM.
Phase symétrique (rapide, pour tous les messages) : les données sont chiffrées avec AES-256-GCM.
C’est exactement le modèle de TLS 1.3 : ECDHE pour l’échange de clé, AES-256-GCM (ou ChaCha20-Poly1305) pour les données applicatives.
Forward secrecy
En utilisant des clés ECDH éphémères (une nouvelle paire par session), on garantit la forward secrecy : si la clé privée long terme du serveur est compromise plus tard, les sessions passées restent chiffrées. Le chapitre 04 approfondit ce point dans le contexte TLS 1.3.
Résumé#
AES-256-GCM est le standard pour le chiffrement symétrique : il combine confidentialité et authenticité (AEAD) en un seul algorithme. Le nonce de 96 bits doit être unique par message.
ECB ne doit jamais être utilisé : les blocs identiques produisent des chiffrés identiques, révélant des patterns dans les données.
RSA-OAEP remplace PKCS#1 v1.5 pour le chiffrement ; PSS est préféré pour la signature. RSA-2048 équivaut à ~112 bits de sécurité.
Ed25519 est l’algorithme de signature recommandé pour les nouveaux systèmes : clés compactes (32 oct.), signatures rapides, résistant aux attaques par timing.
ECDH sur X25519 ou P-256 permet l’établissement de clé sans transmission du secret ; combiné à des clés éphémères, il garantit la forward secrecy.
SHA-256 est acceptable pour l’intégrité des données mais interdit pour les mots de passe. MD5 et SHA-1 sont obsolètes.
L’effet avalanche garantit que modifier 1 bit dans l’entrée change environ 50 % des bits du digest — propriété fondamentale des fonctions de hachage cryptographiques.
Argon2id est le meilleur choix pour le hachage de mots de passe : lenteur contrôlée, résistance GPU et side-channel, paramètres ajustables. bcrypt reste acceptable ; PBKDF2 si FIPS requis.
HMAC prouve l’authenticité d’un message via une clé partagée ; la comparaison doit toujours être effectuée en temps constant (
hmac.compare_digest).Les protocoles hybrides (ECDH + AES-GCM) combinent les avantages : le chiffrement asymétrique distribue la clé de session ; le chiffrement symétrique assure la performance pour les données applicatives.