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.

Hide code cell source

import os
import secrets
import hashlib
import hmac
import timeit
import struct

import matplotlib.pyplot as plt
import matplotlib.patches as patches
import numpy as np
import seaborn as sns
from matplotlib.patches import FancyBboxPatch

# cryptography (PyCA)
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from cryptography.hazmat.primitives.asymmetric import rsa, ec, padding
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives import hmac as crypto_hmac
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey
import base64

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")
_images/a0153e5664c59c872f5d2ef83247089719a76862259338fbe5203e55560c4fb0.png
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}%")
_images/2d3cc20b21e17dd0fd895c5f24f7195cf60cdb40e60133955dff10fc337bebc8.png
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 :

  1. SHA-256 est rapide : un attaquant peut tester des milliards de mots de passe par seconde sur GPU.

  2. 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é)")
_images/9266db7bf7fce4db1116d2a025cbafa9d89bf53d7a6ee7fc805273800b06a95b.png
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.

  1. Phase asymétrique (coûteuse, une seule fois) : les deux parties utilisent ECDH pour établir un secret partagé sans jamais le transmettre en clair.

  2. Dérivation de clé : le secret ECDH est passé dans une KDF (ex. HKDF) pour produire des clés de session AES-GCM.

  3. 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é#

  1. 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.

  2. ECB ne doit jamais être utilisé : les blocs identiques produisent des chiffrés identiques, révélant des patterns dans les données.

  3. 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é.

  4. 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.

  5. 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.

  6. 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.

  7. 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.

  8. 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.

  9. 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).

  10. 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.