Crittografia con Python

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:

  1. Generare una chiave di sessione simmetrica.
  2. Cifrare i dati con AEAD (AES-GCM/ChaCha20-Poly1305).
  3. 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

  1. Definisci requisiti: confidenzialità, integrità, autenticità, durata di conservazione.
  2. Scegli AEAD (AES-GCM o ChaCha20-Poly1305) per i dati.
  3. Usa KDF per password e conserva salt/parametri con il payload.
  4. Progetta un formato versionato e autentica metadati critici con AAD.
  5. Gestisci le chiavi con attenzione: niente hardcoding, rotazione, least privilege.
  6. Usa CSPRNG (os.urandom/secrets).
  7. 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.

Torna su