La crittografia è l’insieme di tecniche che permettono di proteggere dati e comunicazioni contro accessi o modifiche non autorizzate. Con Python puoi implementare prototipi rapidi, automatizzare flussi di sicurezza e integrare librerie mature per cifratura, firme digitali, gestione di chiavi e protocolli. In questo articolo vedremo concetti fondamentali e buone pratiche, con esempi concreti e riutilizzabili.
Obiettivi e minacce: cosa risolve la crittografia
- Confidenzialità: solo i destinatari autorizzati possono leggere i dati (cifratura).
- Integrità: i dati non vengono alterati senza che ce ne si accorga (hash, MAC, AEAD).
- Autenticità: si verifica l’identità di chi invia o firma un messaggio (firme digitali, certificati).
- Non ripudio: chi firma non può negare di averlo fatto (firme con chiave privata, policy e audit).
Le minacce tipiche includono intercettazione (sniffing), manomissione (tampering), replay, furto di credenziali, compromissione di chiavi e errori di implementazione (generazione casuale debole, gestione non sicura delle chiavi, uso di algoritmi obsoleti o modalità non autenticata).
Primitivi crittografici: hash, cifratura, autenticazione
Hash crittografici
Un hash crittografico (es. SHA-256) produce una “impronta” a lunghezza fissa. Serve per verificare integrità e per costruire schemi più avanzati (es. firme digitali). Un hash non è reversibile e non deve essere usato per “nascondere” dati sensibili (non è cifratura).
import hashlib
data = b"messaggio importante"
digest = hashlib.sha256(data).hexdigest()
print(digest)
MAC e HMAC
Un Message Authentication Code aggiunge autenticazione e integrità tramite una chiave condivisa. In Python, HMAC è disponibile nella libreria standard e va preferito a “hash + segreto concatenato” (schema fragile).
import hmac
import hashlib
key = b"chiave_condivisa_molto_segreta"
msg = b"saldo=1000"
tag = hmac.new(key, msg, hashlib.sha256).digest()
# Verifica in modo resistente al timing (constant-time)
ok = hmac.compare_digest(tag, hmac.new(key, msg, hashlib.sha256).digest())
print(ok)
Cifratura simmetrica e asimmetrica
La cifratura simmetrica usa la stessa chiave per cifrare e decifrare (veloce, adatta a grandi volumi). La cifratura asimmetrica usa una coppia di chiavi (pubblica/privata), utile per scambio chiavi e firme, ma più lenta. Nei sistemi reali si usa quasi sempre un approccio ibrido: asimmetrica per scambiare una chiave di sessione, simmetrica per i dati.
Librerie: standard library e “cryptography”
Con la libreria standard puoi fare hash/HMAC, derivazione di chiavi di base e gestione di certificati tramite moduli specifici, ma per cifratura moderna e API sicure è consigliato usare il pacchetto cryptography (ampiamente adottato), che espone primitive robuste e schemi ad alto livello.
python -m pip install cryptography
Cifratura simmetrica moderna: AEAD con AES-GCM e ChaCha20-Poly1305
Una regola pratica: evita modalità “solo cifratura” (come AES-CBC senza MAC) perché non garantiscono integrità. Preferisci schemi AEAD (Authenticated Encryption with Associated Data), come AES-GCM o ChaCha20-Poly1305, che cifrano e autenticano in un’unica operazione.
Esempio: cifrare e decifrare con AES-GCM
AES-GCM richiede una chiave e un nonce (IV) univoco per ogni cifratura con la stessa chiave. Il nonce non deve essere segreto, ma non va riutilizzato.
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
import os
# 256 bit = 32 byte
key = AESGCM.generate_key(bit_length=256)
aead = AESGCM(key)
nonce = os.urandom(12) # 96 bit consigliati per GCM
plaintext = b"Contenuto riservato"
aad = b"metadati_non_segreti" # Associated Authenticated Data (opzionale)
ciphertext = aead.encrypt(nonce, plaintext, aad)
recovered = aead.decrypt(nonce, ciphertext, aad)
print(recovered)
Esempio: ChaCha20-Poly1305 (ottimo su device senza AES acceleration)
from cryptography.hazmat.primitives.ciphers.aead import ChaCha20Poly1305
import os
key = ChaCha20Poly1305.generate_key()
aead = ChaCha20Poly1305(key)
nonce = os.urandom(12)
pt = b"Dati"
aad = b"header"
ct = aead.encrypt(nonce, pt, aad)
pt2 = aead.decrypt(nonce, ct, aad)
print(pt2)
Derivazione di chiavi da password: PBKDF2, scrypt, Argon2
Le password hanno bassa entropia: non usarle direttamente come chiavi. Serve una KDF (Key Derivation Function) con un salt casuale e parametri costosi (iterazioni/memoria) per rallentare attacchi a forza bruta. PBKDF2 è ampiamente supportata, scrypt e Argon2 aumentano la resistenza grazie all’uso di memoria.
PBKDF2 con SHA-256
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
from cryptography.hazmat.primitives import hashes
import os
password = b"password_da_utente"
salt = os.urandom(16)
kdf = PBKDF2HMAC(
algorithm=hashes.SHA256(),
length=32, # chiave da 256 bit
salt=salt,
iterations=310000 # valore tipico moderno, da tarare in base all'hardware
)
key = kdf.derive(password)
print(len(key))
Conserva salt e parametri KDF insieme ai dati cifrati (non sono segreti). Cambia i parametri nel tempo: l’obiettivo è rendere la derivazione “costosa ma accettabile” per l’utente legittimo.
Cifratura di file: formato, metadati e versionamento
Quando cifri un file non basta ottenere “bytes cifrati”. Serve definire un formato che includa: versione, algoritmo, nonce, salt e (se applicabile) parametri KDF. Inoltre devi decidere come gestire i metadati: cosa va cifrato e cosa può rimanere in chiaro (es. un identificatore di versione).
Lo schema seguente è un esempio semplice per cifrare contenuti con AES-GCM usando una chiave derivata da password. È pensato per essere chiaro e pratico, non come standard definitivo.
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
from cryptography.hazmat.primitives import hashes
import os, struct
MAGIC = b"PYCR" # 4 byte di identificatore
VERSION = 1 # 1 byte
def derive_key(password: bytes, salt: bytes, iterations: int = 310000) -> bytes:
kdf = PBKDF2HMAC(
algorithm=hashes.SHA256(),
length=32,
salt=salt,
iterations=iterations,
)
return kdf.derive(password)
def encrypt_blob(password: bytes, plaintext: bytes) -> bytes:
salt = os.urandom(16)
iterations = 310000
key = derive_key(password, salt, iterations)
nonce = os.urandom(12)
aead = AESGCM(key)
# AAD include parte dell'header per legare i metadati all'autenticazione
aad = MAGIC + bytes([VERSION]) + salt + struct.pack(">I", iterations) + nonce
ciphertext = aead.encrypt(nonce, plaintext, aad)
# Formato: MAGIC(4) | VER(1) | SALT(16) | ITER(4) | NONCE(12) | CT(...)
return aad + ciphertext
def decrypt_blob(password: bytes, blob: bytes) -> bytes:
if len(blob) < 4 + 1 + 16 + 4 + 12:
raise ValueError("Blob troppo corto")
magic = blob[:4]
if magic != MAGIC:
raise ValueError("Magic non valido")
ver = blob[4]
if ver != VERSION:
raise ValueError("Versione non supportata")
salt = blob[5:21]
iterations = struct.unpack(">I", blob[21:25])[0]
nonce = blob[25:37]
ciphertext = blob[37:]
key = derive_key(password, salt, iterations)
aead = AESGCM(key)
aad = blob[:37]
return aead.decrypt(nonce, ciphertext, aad)
# Esempio d'uso
pw = b"passphrase_lunga_e_unica"
pt = b"contenuto del file"
blob = encrypt_blob(pw, pt)
print(decrypt_blob(pw, blob))
Crittografia asimmetrica: RSA ed ECC in pratica
Per scambio chiavi e firme, oggi si preferiscono schemi moderni:
- Scambio chiavi: X25519 o ECDH su curve moderne.
- Firme: Ed25519 o ECDSA (con attenzione alla generazione del nonce), oppure RSA-PSS.
- Cifratura asimmetrica: quando serve, usa RSA-OAEP (non RSA “raw”).
La libreria cryptography espone API di alto livello per generare chiavi, serializzarle e usarle.
Di seguito un esempio di firma digitale con Ed25519, semplice e sicuro.
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
from cryptography.hazmat.primitives import serialization
message = b"documento da firmare"
private_key = Ed25519PrivateKey.generate()
public_key = private_key.public_key()
signature = private_key.sign(message)
# Verifica (solleva eccezione se non valido)
public_key.verify(signature, message)
# Serializzazione (PEM)
pem_priv = private_key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.PKCS8,
encryption_algorithm=serialization.NoEncryption(),
)
pem_pub = public_key.public_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PublicFormat.SubjectPublicKeyInfo,
)
print(pem_pub.decode("utf-8"))
Scambio chiavi e cifratura ibrida
Per inviare un messaggio a un destinatario senza una chiave condivisa preesistente, puoi:
- Generare una chiave di sessione simmetrica.
- Cifrare i dati con AEAD (AES-GCM/ChaCha20-Poly1305).
- Cifrare o incapsulare la chiave di sessione usando la chiave pubblica del destinatario (o usare ECDH/X25519 per derivarla).
Esempio concettuale (semplificato): l’idea è separare “trasporto sicuro della chiave” e “cifratura dei dati”. In produzione, considera protocolli standard come TLS o schemi di “sealed box” e formati ben definiti.
Gestione delle chiavi: il problema più difficile
La crittografia spesso fallisce non per l’algoritmo, ma per la gestione delle chiavi. Buone pratiche:
- Evita hardcoding: mai chiavi nel codice o nel repository.
- Usa un KMS/HSM quando possibile (cloud o on-prem) per proteggere chiavi master e auditare l’uso.
- Rotazione: prevedi versionamento e rotazione delle chiavi; conserva i dati necessari per decifrare storico.
- Principio del minimo privilegio: accesso alle chiavi limitato e tracciato.
- Backup sicuri: se perdi la chiave, perdi i dati; i backup devono essere cifrati e testati.
Randomness: os.urandom e perché non usare random
Per nonces, salt e chiavi devi usare un generatore crittograficamente sicuro.
In Python usa os.urandom() o secrets.
Il modulo random è pensato per simulazioni e non è adatto alla sicurezza.
import secrets
token = secrets.token_urlsafe(32)
key = secrets.token_bytes(32)
print(token, len(key))
Confronti e verifiche: attenzione ai timing attack
Se confronti tag o hash con ==, un attaccante potrebbe sfruttare differenze di tempo per indovinare il valore.
Per confronti di segreti usa funzioni constant-time come hmac.compare_digest.
Errori comuni da evitare
- Riutilizzare nonce/IV con la stessa chiave (catastrofico in GCM e in stream cipher).
- Usare AES-CBC senza autenticazione (malleabilità e padding oracle).
- Inventare “algoritmi custom” o concatenazioni non standard.
- Gestire password senza KDF o usare hash veloce (es. SHA-256) per password storage.
- Usare RSA senza OAEP o firme RSA-PKCS#1 v1.5 senza comprenderne i rischi (preferire RSA-PSS).
- Serializzare chiavi private in chiaro su disco senza protezione o senza controlli di accesso.
Test, interoperabilità e manutenzione
Per rendere affidabile un modulo crittografico:
- Aggiungi test unitari e test di regressione con vettori di test noti quando disponibili.
- Verifica la compatibilità di formati (PEM/DER, base64, endianness nei campi numerici).
- Documenta chiaramente il formato dei dati cifrati e la strategia di versionamento.
- Tieni aggiornate le dipendenze e monitora advisory di sicurezza.
Checklist operativa
- Definisci requisiti: confidenzialità, integrità, autenticità, durata di conservazione.
- Scegli AEAD (AES-GCM o ChaCha20-Poly1305) per i dati.
- Usa KDF per password e conserva salt/parametri con il payload.
- Progetta un formato versionato e autentica metadati critici con AAD.
- Gestisci le chiavi con attenzione: niente hardcoding, rotazione, least privilege.
- Usa CSPRNG (
os.urandom/secrets). - Scrivi test e valida i casi limite (error handling, dati corrotti, versioni diverse).
Con questi principi puoi costruire soluzioni robuste in Python, evitando gli errori più comuni e sfruttando primitive moderne. Quando il contesto lo richiede (comunicazioni su rete, autenticazione tra servizi), privilegia protocolli standard (TLS, JWT con firme corrette, mTLS) e librerie ben mantenute, limitando il “fai da te” alla sola integrazione.