Cryptography in PHP: usare libsodium correttamente per cifrare dati sensibili

Cryptography in PHP: usare libsodium correttamente per cifrare dati sensibili

A ottobre 2025 ho condotto un audit di sicurezza applicativa per un'azienda del settore servizi legali - 28 dipendenti, fatturato annuo intorno ai 5 milioni di euro, piattaforma Laravel 10 che gestisce fascicoli di circa 4.000 clienti con dati sensibili di natura legale (atti notarili, contratti riservati, corrispondenza con controparti). L'audit aveva scope ampio, ma uno dei capitoli più rivelatori ha riguardato la crittografia applicata ai campi sensibili del database. La piattaforma cifrava effettivamente alcuni campi (codice fiscale, numero di conto, note confidenziali dei fascicoli), ma l'implementazione era problematica: usava openssl_encrypt con modalità AES-128-CBC senza authentication (vulnerabile a padding oracle attack), IV generati con rand() invece di random_bytes() (non crittograficamente sicuri), stessa chiave di crittografia per tutti i record (se la chiave viene compromessa, tutti i dati sono decifrabili), chiave hardcoded nel file di configurazione anziché in un vault dedicato. Tecnicamente i dati erano "cifrati", ma un penetration test serio avrebbe potuto estrarli con tecniche note.

Il dato emerso dall'audit ha sorpreso il CTO dell'azienda, che credeva in buona fede che "abbiamo openssl_encrypt, siamo a posto". Era un pattern frequente che vedo in contesti PMI: team che implementa crittografia senza specialista, seguendo tutorial generici su Stack Overflow del 2015, producendo codice tecnicamente funzionante ma vulnerabile in modo non rilevabile dai test applicativi standard. Il problema è strutturale: la crittografia applicata male è peggio di nessuna crittografia, perché produce falsa sicurezza. In quattro settimane abbiamo migrato l'intera piattaforma alla suite libsodium, disponibile nativamente in PHP dalla versione 7.2 come documenta la sezione ufficiale del manuale PHP su sodium, con authenticated encryption, chiavi separate per risorsa, gestione corretta di IV e nonces, e integrazione con un password manager per custodia delle master key. Questo articolo descrive i pattern corretti che applico e gli errori sistematici che vedo nei sistemi PHP legacy.

Perché libsodium è la scelta giusta per applicazioni PHP moderne

Il panorama crittografico di PHP è storicamente stratificato. Prima c'era mcrypt (funzioni wrapper su libmcrypt, libreria abbandonata dagli anni 2000 e ufficialmente deprecata da PHP 7.1). Poi c'è stato openssl_* (wrapper su OpenSSL, ampiamente usato ma con API di basso livello dove è facile sbagliare). Dal 2017 (PHP 7.2) è stato incluso nativamente l'intero ecosistema di libsodium progettato da Daniel J. Bernstein come "alternative al cryptography library incaricata di essere difficile da usare male", che offre primitive crittografiche moderne con API intenzionalmente ad alto livello.

La differenza pratica è che con libsodium è molto difficile sbagliare nei modi catastrofici tipici delle librerie di basso livello. Le funzioni fanno la cosa giusta di default: usano algoritmi moderni (XChaCha20-Poly1305 per cifratura simmetrica, Ed25519 per firma), richiedono nonce/IV che non possono essere dimenticati, includono autenticazione automatica che previene manomissioni, producono output con formato standardizzato che include metadata necessari. Un sviluppatore che legge solo la documentazione libsodium e applica i pattern raccomandati produce codice crittograficamente solido. Uno che legge solo la documentazione di openssl_encrypt e combina "modalità che sembrano ragionevoli" produce spesso codice vulnerabile.

Il tasso di adozione di libsodium nelle PMI italiane è ancora basso - stimo meno del 30% dei progetti PHP post-2020 lo usa. La ragione è l'inerzia: tutorial online si riferiscono ancora prevalentemente a openssl_encrypt, molti developer hanno imparato PHP con quella API, le migrazioni non sono percepite urgenti perché "funziona". Ma il debito di sicurezza accumulato è reale e verificabile tramite pentest rigorosi.

I cinque errori ricorrenti con openssl_encrypt

Prima di descrivere i pattern corretti di libsodium, elenco gli errori tipici che trovo in audit di codice PHP con openssl_encrypt. Riconoscere questi pattern permette di triagere rapidamente vulnerabilità crittografiche in codebase esistenti.

Errore 1 - modalità CBC senza HMAC. openssl_encrypt($plaintext, 'AES-128-CBC', $key, OPENSSL_RAW_DATA, $iv) produce ciphertext cifrato ma non autenticato. Un attaccante che intercetta il ciphertext può modificarlo in modi specifici (padding oracle attack) per decifrare gradualmente il contenuto, oppure può produrre ciphertext valido per plaintext scelto. La fix su openssl_encrypt sarebbe aggiungere manualmente HMAC-SHA256 del ciphertext (pattern "encrypt-then-MAC") - ma nessuno lo fa perché è facile dimenticare, e verificare correttamente l'HMAC richiede hash_equals (non ==) che molti ignorano. Con libsodium, sodium_crypto_secretbox include autenticazione automatica. Zero chance di fare questo errore.

Errore 2 - IV/nonce prevedibile o riutilizzato. Per essere sicuro, un IV deve essere random e non può essere mai riutilizzato con la stessa chiave. Implementazioni che usano md5($qualcosa) come IV, o contatori incrementali, o peggio IV fissi, rompono completamente la sicurezza del cifrario. Ho visto codice in produzione che usava openssl_encrypt($pt, ..., str_repeat('0', 16)) - IV tutto-zero. Decryption triviale.

Errore 3 - chiave derivata da password con algoritmo debole. Se la chiave di cifratura deriva da una password utente (tipico scenario in sistemi dove l'utente controlla la master key), derivarla con MD5 o SHA1 senza iterations è facilmente brute-force-able. La fix corretta è usare Argon2id (disponibile via libsodium sodium_crypto_pwhash) o almeno PBKDF2 con 100.000+ iterazioni. Molti codebase legacy usano semplicemente hash('sha256', $password) come derivation, che è crittograficamente veloce quanto bruteforceare 10 miliardi di password al secondo su GPU moderna.

Errore 4 - stessa chiave per tutti i record. Se cifri tutti i dati sensibili di tutti gli utenti con la stessa chiave master, una compromissione della chiave espone tutti i dati. Il pattern corretto è un livello di "derived key per record" - ogni record ha una sua chiave derivata dalla master + un salt specifico del record. Compromissione della master richiede ancora lavoro per decifrare ogni singolo record.

Errore 5 - chiave nel codice sorgente o in file di config versionato. Chiavi crittografiche hardcoded in config/app.php committato in Git sono un classic. Chiunque ottiene accesso al repository (fornitore esterno, ex-dipendente, breach di GitHub) ottiene le chiavi. La fix è vault dedicato (HashiCorp Vault, AWS KMS, Azure Key Vault) o almeno file .env mai committato con protezioni di filesystem strict. Ho visto repository pubblici su GitHub con chiavi AES-256 in chiaro - cercando AES_KEY= su code search GitHub produce migliaia di risultati.

Il pattern corretto con libsodium: secretbox per dati a riposo

Il pattern base per cifrare dati sensibili a riposo (campi database, file su disco) è sodium_crypto_secretbox. Questa funzione implementa XSalsa20-Poly1305 - algoritmo di cifratura simmetrica con autenticazione built-in.

<?php
// app/Services/Crypto/SecretBoxService.php
namespace App\Services\Crypto;

class SecretBoxService
{
    public function encrypt(string $plaintext, string $masterKey): string
    {
        // Genera nonce di 24 byte random per OGNI operazione
        $nonce = random_bytes(SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);

        // Cifra con authenticated encryption
        $ciphertext = sodium_crypto_secretbox($plaintext, $nonce, $masterKey);

        // Concatena nonce + ciphertext per storage
        $combined = $nonce . $ciphertext;

        // Encoding base64 per storage in DB colonna text
        return base64_encode($combined);
    }

    public function decrypt(string $encoded, string $masterKey): string
    {
        $combined = base64_decode($encoded, true);
        if ($combined === false || strlen($combined) < SODIUM_CRYPTO_SECRETBOX_NONCEBYTES) {
            throw new \RuntimeException('Invalid ciphertext');
        }

        $nonce = substr($combined, 0, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);
        $ciphertext = substr($combined, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);

        $plaintext = sodium_crypto_secretbox_open($ciphertext, $nonce, $masterKey);
        if ($plaintext === false) {
            // Autenticazione fallita: ciphertext manipolato o chiave errata
            throw new \RuntimeException('Decryption failed: ciphertext tampered or wrong key');
        }

        return $plaintext;
    }
}

Cinque dettagli tecnici che meritano attenzione. Primo: random_bytes per nonce. È l'unico modo corretto di generare nonce in PHP - crittograficamente random, non prevedibile. Secondo: nonce di 24 byte - lo spazio di nonce di XSalsa20 è così grande che la probabilità di collisioni casuali è trascurabile, non serve contatore. Terzo: autenticazione built-in - sodium_crypto_secretbox_open fallisce con false se il ciphertext è stato manomesso anche di un bit. Non serve HMAC separato. Quarto: formato serializzato - concateno nonce+ciphertext perché questo è il pattern standard per storage; il decrypt legge i primi 24 byte come nonce e il resto come ciphertext. Quinto: sodium_memzero post-decrypt sulle chiavi in memoria per mitigare attacchi che leggono la memoria del processo - ometto per brevità nell'esempio ma è buona pratica in produzione.

Integrazione in Eloquent: cifratura trasparente dei campi sensibili

In un'applicazione Laravel, il pattern operativo è di avere cifratura trasparente a livello di Model - il codice applicativo che legge/scrive il campo non sa che è cifrato. Eloquent supporta questo tramite accessors/mutators custom.

<?php
// app/Models/Cliente.php - campo codice_fiscale cifrato trasparentemente
namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Casts\Attribute;
use App\Services\Crypto\SecretBoxService;
use App\Services\Crypto\KeyManagement;

class Cliente extends Model
{
    protected $fillable = ['ragione_sociale', 'codice_fiscale', 'partita_iva', 'note_riservate'];

    protected function codiceFiscale(): Attribute
    {
        return Attribute::make(
            get: fn (string|null $value) => $value
                ? app(SecretBoxService::class)->decrypt($value, app(KeyManagement::class)->getMasterKey())
                : null,
            set: fn (string|null $value) => $value
                ? app(SecretBoxService::class)->encrypt($value, app(KeyManagement::class)->getMasterKey())
                : null,
        );
    }

    protected function noteRiservate(): Attribute
    {
        return Attribute::make(
            get: fn (string|null $value) => $value
                ? app(SecretBoxService::class)->decrypt($value, app(KeyManagement::class)->getMasterKey())
                : null,
            set: fn (string|null $value) => $value
                ? app(SecretBoxService::class)->encrypt($value, app(KeyManagement::class)->getMasterKey())
                : null,
        );
    }
}

Con questo pattern, il codice applicativo usa normalmente $cliente->codice_fiscale = 'ABCDEF80A01H501Z' e echo $cliente->codice_fiscale - il dato è cifrato/decifrato automaticamente nei mutators/accessors. Nel database, la colonna codice_fiscale contiene il ciphertext base64. Dal punto di vista del SQL, non c'è modo di decifrare senza la chiave.

Il problema tecnico di questo pattern è che rende impossibile query sul campo: WHERE codice_fiscale = 'ABCDEF80...' non funziona perché il DB ha ciphertext diverso per ogni record (nonce random). La soluzione è mantenere un campo hash non-cifrato per permettere lookup:

protected static function booted()
{
    static::saving(function ($cliente) {
        // Hash determinstic del codice fiscale per permettere lookup
        if ($cliente->codice_fiscale) {
            $cliente->setAttribute(
                'codice_fiscale_hash',
                hash_hmac('sha256', $cliente->codice_fiscale, config('app.hash_key'))
            );
        }
    });
}

// Uso: $cliente = Cliente::where('codice_fiscale_hash', hash_hmac('sha256', $cf, config('app.hash_key')))->first();

L'hash HMAC-SHA256 con chiave segreta è deterministic per lo stesso input, quindi permette lookup. Non è reversibile, quindi non espone il codice fiscale in chiaro nel DB. La chiave HMAC deve essere separata dalla master key di cifratura - se entrambe sono compromesse, la protezione aggiuntiva dell'hash svanisce.

Stai cercando un Consulente Informatico esperto per implementare o auditare la crittografia applicativa delle tue applicazioni PHP, con uso corretto di libsodium e gestione professionale delle chiavi? Nel mio profilo professionale trovi l'esperienza concreta su crittografia moderna, hardening applicativo, conformità GDPR su dati sensibili per PMI italiane del settore legale, fintech e sanitario.

Key management: dove custodire le chiavi

La crittografia è forte quanto la gestione delle chiavi. Le chiavi master di una piattaforma possono essere custodite in tre modi principali, ordinati per maturità crescente.

Livello base - file ambiente protetto. La chiave vive in .env file con permessi 600, non committata in Git. Funziona per PMI piccole dove il team di amministrazione è controllato e il rischio di leak interno è minimo. Il trade-off: chiunque ottiene accesso al server può leggere la chiave. Una compromissione del server espone tutti i dati cifrati.

Livello intermedio - vault locale cifrato. La chiave è custodita in un vault (Bitwarden self-hosted, 1Password Business, Passbolt) con accesso controllato per ruolo. L'applicazione recupera la chiave all'avvio, la mantiene in memoria, la cancella quando smette. Compromissione del server non espone immediatamente le chiavi - richiede anche compromissione del vault separato. Questo è il livello che ho implementato sul cliente legale.

Livello avanzato - KMS gestito. Per contesti finance/health con requisiti compliance stretti, la chiave vive in un Key Management Service dedicato (AWS KMS, Azure Key Vault, HashiCorp Vault enterprise). L'applicazione non ha mai la chiave - chiama il KMS via API per ogni operazione di crypto, passando ciphertext e ricevendo plaintext. La chiave non lascia mai il KMS. Audit trail completo di ogni accesso.

Sul cliente legale abbiamo scelto il livello intermedio - Bitwarden self-hosted su VPS dedicato con MFA obbligatorio, rotazione annuale delle chiavi. Il livello avanzato sarebbe stato overkill per la loro dimensione e contesto.

La rotazione delle chiavi: la procedura che nessuno pianifica

Un aspetto critico della gestione delle chiavi crittografiche è la rotazione. Una chiave che gira per 5 anni senza mai cambiare è un rischio crescente - più tempo passa, più aumenta la probabilità di compromissione silenziosa. Il pattern standard è di ruotare le chiavi almeno annualmente, e fuori programma in caso di sospetto di compromissione.

La procedura di rotazione per la tabella clienti cifrata con secretbox:

// app/Console/Commands/RotateEncryptionKey.php
public function handle(): int
{
    $oldKey = app(KeyManagement::class)->getMasterKey();
    $newKey = sodium_crypto_secretbox_keygen();

    // Salva temporaneamente il newKey nel vault come "pending"
    app(KeyManagement::class)->setPendingKey($newKey);

    // Re-encrypt tutti i record
    Cliente::chunk(500, function ($clienti) use ($oldKey, $newKey) {
        foreach ($clienti as $cliente) {
            if (!$cliente->codice_fiscale_encrypted) continue;

            $svc = app(SecretBoxService::class);
            $plaintext = $svc->decrypt($cliente->codice_fiscale_encrypted, $oldKey);
            $newCiphertext = $svc->encrypt($plaintext, $newKey);

            // Update atomic con raw SQL per evitare trigger Eloquent
            DB::table('clienti')
                ->where('id', $cliente->id)
                ->update(['codice_fiscale_encrypted' => $newCiphertext]);
        }
    });

    // Promuove newKey a master key, archivia oldKey
    app(KeyManagement::class)->promotePendingKey();
    $this->info("Rotation complete. Old key archived.");
    return 0;
}

Due aspetti operativi. Primo: la rotazione richiede downtime minimo se gestita correttamente - durante la rotazione, il sistema legge con oldKey e scrive con newKey, finché non tutti i record sono ri-cifrati. Per 100.000 record con secretbox tipica, la rotazione richiede 15-20 minuti. Secondo: conservazione delle old key archiviate. Non vanno distrutte immediatamente - se emerge necessità di decifrare record storici (backup non ancora rotato), servono ancora. La policy tipica è conservare per 7 anni in archivio cifrato separato, con distruzione formalizzata solo dopo.

Cifratura di file grandi: i limiti di secretbox

sodium_crypto_secretbox è perfetta per dati piccoli (campi database fino a qualche KB), ma ha un limite: carica tutto il plaintext in memoria. Per cifrare file grandi (PDF di fascicoli legali, allegati di documenti, archivi), serve streaming encryption.

Libsodium offre sodium_crypto_secretstream_xchacha20poly1305_* per questo scenario. Il pattern è di chunking - il file viene spezzato in blocchi di dimensione ragionevole (es: 64 KB), ogni blocco viene cifrato in sequenza mantenendo uno stato condiviso che garantisce integrità dell'intera sequenza:

<?php
// app/Services/Crypto/StreamCipherService.php
class StreamCipherService
{
    private const CHUNK_SIZE = 65536; // 64 KB

    public function encryptFile(string $inputPath, string $outputPath, string $key): void
    {
        [$state, $header] = sodium_crypto_secretstream_xchacha20poly1305_init_push($key);

        $input = fopen($inputPath, 'rb');
        $output = fopen($outputPath, 'wb');

        try {
            fwrite($output, $header);
            $isLastChunk = false;

            while (!$isLastChunk) {
                $chunk = fread($input, self::CHUNK_SIZE);
                $isLastChunk = feof($input);
                $tag = $isLastChunk
                    ? SODIUM_CRYPTO_SECRETSTREAM_XCHACHA20POLY1305_TAG_FINAL
                    : SODIUM_CRYPTO_SECRETSTREAM_XCHACHA20POLY1305_TAG_MESSAGE;

                $encryptedChunk = sodium_crypto_secretstream_xchacha20poly1305_push(
                    $state, $chunk, '', $tag
                );

                fwrite($output, $encryptedChunk);
            }
        } finally {
            fclose($input);
            fclose($output);
        }
    }

    public function decryptFile(string $inputPath, string $outputPath, string $key): void
    {
        $input = fopen($inputPath, 'rb');
        $output = fopen($outputPath, 'wb');

        try {
            $header = fread($input, SODIUM_CRYPTO_SECRETSTREAM_XCHACHA20POLY1305_HEADERBYTES);
            $state = sodium_crypto_secretstream_xchacha20poly1305_init_pull($header, $key);

            $isLastChunk = false;
            while (!$isLastChunk) {
                $encryptedChunk = fread($input, self::CHUNK_SIZE +
                    SODIUM_CRYPTO_SECRETSTREAM_XCHACHA20POLY1305_ABYTES);

                [$plaintext, $tag] = sodium_crypto_secretstream_xchacha20poly1305_pull($state, $encryptedChunk);

                fwrite($output, $plaintext);

                if ($tag === SODIUM_CRYPTO_SECRETSTREAM_XCHACHA20POLY1305_TAG_FINAL) {
                    $isLastChunk = true;
                }
            }
        } finally {
            fclose($input);
            fclose($output);
        }
    }
}

Il pattern garantisce che non è possibile troncare un file cifrato (il tag FINAL sull'ultimo chunk impedisce chiusure premature) né riordinare i chunk (lo stato condiviso rileva sequenze alterate). Usato sul cliente legale per cifrare allegati dei fascicoli (PDF di contratti, scansioni di documenti), con chiave per-cliente derivata dalla master + cliente_id come salt.

Integrazione con il backup: dati cifrati che arrivano off-site crittografati

Un pattern di sicurezza spesso dimenticato è come i dati cifrati vengono gestiti durante il backup. Se il backup del database include colonne cifrate con secretbox, il backup stesso contiene ciphertext - quindi è protetto anche se il backup viene esposto. Ma la chiave di cifratura non è nel backup del database (vive nel vault separato). Il piano di disaster recovery deve tenere conto di questo: per ripristinare il sistema, serve sia il backup che la chiave. Se perdo la chiave, i dati sono inutilizzabili anche con il backup perfetto.

Il pattern operativo che applico: backup settimanale del vault delle chiavi su location diversa dal backup DB, con cifratura separata. Test di restore che verifica che la combinazione backup DB + backup vault sia sufficiente per ripristinare dati leggibili. Questo è lo stesso pattern di rigore descritto nel mio articolo sul backup incrementale MySQL con xtrabackup per recovery point granulare senza blocchi - il backup è utile solo se testato, e quando include dati cifrati il test include anche la disponibilità delle chiavi.

Verifica e audit della crittografia in produzione

L'ultimo pezzo della pratica corretta è la verifica continua che la crittografia funzioni come previsto. Implementazioni correte possono degradarsi nel tempo - un developer che aggiunge un nuovo campo sensibile e dimentica di cifrarlo, una procedura di import batch che salta i mutators Eloquent, una configurazione che viene alterata in un deploy frettoloso.

Lo script di audit che gira weekly controlla cinque invarianti. Primo: tutti i campi dichiarati "sensitive" hanno valore cifrato nel DB. Secondo: la lunghezza dei valori cifrati è compatibile con ciphertext base64 di secretbox (minimo ~60 byte per nonce+ciphertext+tag). Terzo: un decrypt random su 100 record campione deve riuscire (verifica che le chiavi siano ancora allineate). Quarto: nessun valore che somiglia a dato plaintext sensibile nelle colonne cifrate (scan per pattern codice fiscale, IBAN, email). Quinto: la master key è accessibile dal vault con gli accessi attesi, nessun tentativo di accesso non autorizzato nelle ultime 24 ore.

Il report weekly viene inviato via email al CISO (o al ruolo equivalente per PMI piccole: il CTO o il titolare informatico). Nei 8 mesi post-migrazione del cliente legale, gli alert sono stati 2 totali - entrambi legati a import batch fatti dal team fiscale che bypassava Eloquent; fix in 24 ore, nessuna esposizione di dati.

La conversazione con il business: tradurre sicurezza in valore

Un capitolo operativo importante: convincere il business a investire in corretta crittografia. La conversazione con il CTO del cliente legale è stato illuminante - aveva inizialmente percepito la richiesta come "over-engineering" rispetto al "già abbiamo openssl_encrypt". La chiave per farsi ascoltare è stata dimostrare, non argomentare. Ho preparato un demo di attacco su un database di test con la loro implementazione originale: in 45 minuti ho mostrato come un attaccante con accesso al ciphertext (scenario realistico di breach) poteva estrarre il codice fiscale del 10% dei record tramite padding oracle attack, in un pattern che non sarebbe stato rilevato dal loro monitoring.

Dopo la demo, la conversazione è cambiata. Non era più "quanto costa questa migrazione" ma "quanto velocemente possiamo farla". L'urgenza percepita si è allineata con l'urgenza reale.

Per una PMI che deve convincere un board non tecnico del valore della crittografia corretta, l'argomento vincente è tipicamente il GDPR Articolo 32 ("il titolare del trattamento e il responsabile del trattamento mettono in atto misure tecniche e organizzative adeguate"). L'Autorità Garante ha esplicitamente incluso in sanzioni situazioni dove la crittografia era "tecnicamente presente ma implementata in modo inadeguato". Il rischio sanzione è sufficiente argomento finanziario per giustificare l'investimento.

Se la tua applicazione PHP tratta dati sensibili e usa openssl_encrypt, mcrypt legacy, o hai dubbi sulla correttezza dell'implementazione attuale, contattami per un audit crittografico: in una settimana analizzo il codice di cifratura esistente, identifico le vulnerabilità specifiche (con demo di attacco dove applicabile), pianifico la migrazione a libsodium con minimal downtime, implemento il pattern corretto di key management calibrato sul tuo budget (vault locale, KMS gestito, o HSM per casi più stringenti), e ti lascio una procedura di rotazione chiavi e audit continuo che mantiene lo stato nel tempo. L'investimento è contenuto, il ritorno è la certezza che "abbiamo crittografia" non è un'affermazione pericolosamente ottimistica ma una protezione verificabile.

Ultima modifica: