Encryption con libsodium: XSalsa20-Poly1305 nel framework — articolo

> Encryption con libsodium: XSalsa20-Poly1305 nel framework

Deep dive sul sistema di crittografia autenticata: key derivation, nonce management e memory safety.

Luigi Iadicola
~6 min lettura
#PHP #Security #Encryption
Encryption con libsodium: XSalsa20-Poly1305 nel framework
Encryption con libsodium: XSalsa20-Poly1305 nel framework

Perche libsodium e non OpenSSL

Quando si parla di crittografia in PHP, la scelta tradizionale e openssl_encrypt con AES-256-CBC. Funziona, ma richiede di gestire manualmente il vettore di inizializzazione, il padding, e soprattutto l'autenticazione del ciphertext — se dimentichi di aggiungere un HMAC separato, il sistema e vulnerabile ad attacchi di tipo padding oracle. In Soft PHP MVC ho scelto una strada diversa: libsodium, l'estensione crittografica inclusa in PHP dal 7.2, con l'algoritmo XSalsa20-Poly1305.

XSalsa20-Poly1305 e un cipher di tipo AEAD (Authenticated Encryption with Associated Data). Questo significa che crittografia e autenticazione sono un'operazione unica: non puoi dimenticarti l'HMAC perche l'integrità e gia garantita dal MAC Poly1305 integrato nel ciphertext. Se qualcuno modifica anche un solo byte del payload, la decrittazione fallisce. Non serve combinare manualmente encrypt + HMAC come con OpenSSL.

Un altro vantaggio pratico: libsodium ha un'API molto piu difficile da usare male. Non ci sono modalità di cifratura da scegliere, non c'e padding da configurare, non ci sono costanti da ricordare. Una funzione per cifrare, una per decifrare, e il resto lo gestisce la libreria.

L'architettura di EncryptionService

EncryptionService e un singleton che viene inizializzato durante il bootstrap dell'applicazione in Mvc::run(). La prima cosa che fa e leggere APP_KEY dall'ambiente. La chiave deve avere il prefisso base64: e, una volta decodificata, deve essere esattamente di 32 byte — la dimensione richiesta da SODIUM_CRYPTO_KDF_KEYBYTES per la key derivation function.

La scelta del singleton non e casuale: la chiave di cifratura deve essere caricata una sola volta per richiesta, e avere piu istanze con chiavi potenzialmente diverse sarebbe un rischio di incoerenza. Il pattern make() garantisce che l'inizializzazione avvenga una volta sola e che il resto del codice acceda sempre alla stessa istanza.

Se APP_KEY manca o ha un formato errato, il servizio lancia una RuntimeException immediatamente — fail-fast. Non c'e modo di procedere con una chiave invalida, il che previene errori silenziosi che potrebbero manifestarsi molto dopo, quando i dati sono già stati cifrati con parametri sbagliati.

Key Derivation: una chiave master, due chiavi derivate

Un errore comune nella crittografia applicativa e usare la stessa chiave per scopi diversi. Se uso la stessa chiave per cifrare i dati e per firmare i token CSRF, una vulnerabilita in uno dei due sistemi potrebbe compromettere l'altro. Per questo EncryptionService non usa direttamente la APP_KEY: la tratta come una chiave master da cui derivare chiavi specializzate.

La derivazione avviene tramite sodium_crypto_kdf_derive_from_key(), la KDF (Key Derivation Function) di libsodium. Dal master key vengono generate due chiavi:

  • Chiave di cifratura (subkey ID 1, contesto EncryptK): usata per sodium_crypto_secretbox
  • Chiave HMAC (subkey ID 2, contesto HmacSign): usata dal CsrfService per firmare i token

I contesti (EncryptK e HmacSign) sono stringhe di 8 byte che rendono le chiavi derivate crittograficamente indipendenti anche se derivano dalla stessa master key. Dopo la derivazione, la master key viene azzerata dalla memoria con sodium_memzero() — solo le chiavi derivate vengono conservate per la durata della richiesta.

Cifratura: nonce random e formato del payload

Il metodo encrypt() genera un nonce casuale di 24 byte con random_bytes() per ogni operazione di cifratura. Il nonce non e un segreto — e un valore che deve essere unico per ogni messaggio cifrato con la stessa chiave. Usare random_bytes() con 24 byte da una probabilita di collisione trascurabile anche dopo miliardi di operazioni.

Il ciphertext prodotto da sodium_crypto_secretbox() contiene sia i dati cifrati che il MAC Poly1305. Il payload finale viene assemblato come base64(nonce + ciphertext): il nonce e preposto al ciphertext e il tutto viene codificato in base64 per essere trasportabile come stringa. Chi decifra estrae i primi 24 byte come nonce, e il resto come ciphertext.

Questo formato ha un vantaggio pratico: il payload e completamente autocontenuto. Non servono colonne aggiuntive nel database per salvare il nonce, non servono metadati separati. Una singola stringa contiene tutto il necessario per la decrittazione.

Decrittazione e validazione

Il metodo decrypt() e speculare ma con controlli aggiuntivi. Prima verifica che il payload sia base64 valido con base64_decode(strict: true). Poi controlla che la lunghezza del payload decodificato sia almeno SODIUM_CRYPTO_SECRETBOX_NONCEBYTES + SODIUM_CRYPTO_SECRETBOX_MACBYTES — altrimenti il payload e troppo corto per contenere dati cifrati validi.

Se sodium_crypto_secretbox_open() restituisce false, significa che il payload e stato manomesso, corrotto, o cifrato con una chiave diversa. In quel caso viene lanciata una DecryptionException con un messaggio chiaro. Non c'e modo di distinguere tra manomissione e chiave sbagliata — by design, perche rivelare quale dei due casi si e verificato sarebbe un information leak.

Memory safety: azzeramento delle chiavi

Le chiavi crittografiche in memoria sono un bersaglio per attacchi di tipo memory disclosure (Heartbleed insegna). EncryptionService adotta un approccio di defense-in-depth: nel distruttore della classe, le chiavi derivate vengono azzerate con sodium_memzero(). Questa funzione sovrascrive i byte in memoria con zeri, impedendo che vengano letti da un eventuale dump della memoria.

Il try/catch nel distruttore gestisce il caso in cui la chiave sia gia stata liberata — cosa che puo succedere se PHP garbage-collects l'oggetto in un ordine inatteso. Non e un errore, e un'operazione idempotente.

La master key originale viene azzerata subito dopo la derivazione delle subkey, nel costruttore stesso. Questo minimizza la finestra temporale in cui la chiave piu sensibile e presente in memoria.

Integrazione con il CSRF

Il CsrfService non genera la propria chiave: usa la chiave HMAC derivata da EncryptionService. Quando viene generato un token CSRF, il servizio crea 32 byte casuali come token grezzo, poi calcola un HMAC-SHA256 usando la chiave derivata. Il formato salvato in sessione e raw_token|hmac_signature.

Nei form HTML viene inserito solo il token grezzo (la parte prima del |). Il middleware di validazione richiede invece il token completo dalla sessione, ricalcola l'HMAC sul token ricevuto dal form e verifica che corrisponda alla firma salvata. Questo schema a doppia verifica impedisce che un attaccante possa forgiare un token valido anche se riesce a leggere il token grezzo dal DOM.

Generazione della chiave: il comando key:generate

La APP_KEY viene generata dal comando php soft key:generate. Il comando produce 32 byte casuali con random_bytes(), li codifica in base64 con il prefisso base64:, e aggiorna il file .env. Il flag --show mostra la chiave senza salvarla, utile per ambienti dove il .env non e scrivibile. Il flag --force sovrascrive una chiave esistente — operazione che invalida tutti i dati precedentemente cifrati, quindi viene richiesta con cautela.

La scelta di 32 byte non e arbitraria: e la dimensione richiesta dalla KDF di libsodium per generare subkey sicure. Chiavi piu corte non sarebbero accettate, chiavi piu lunghe non aggiungerebbero sicurezza.

altri articoli
progetti correlati