---
jupytext:
  text_representation:
    extension: .md
    format_name: myst
    format_version: 0.13
    jupytext_version: 1.16.0
kernelspec:
  name: python3
  display_name: Python 3
---

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

```{admonition} Bibliothèque de référence
:class: note
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.
```

```{code-cell} python
:tags: [hide-input]

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

```{code-cell} python
# 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.")
```

## 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)

```{code-cell} python
# 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é.")
```

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

```{code-cell} python
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")
```

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

```{code-cell} python
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}%")
```

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

```{code-cell} python
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é)")
```

```{admonition} Choix de l'algorithme
:class: tip
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.

```{admonition} Comparaison sécurisée
:class: warning
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.
```

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

```{admonition} Forward secrecy
:class: important
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.
