Crittografia con PHP

La crittografia è uno strumento pratico per proteggere dati sensibili (password, token, informazioni personali, segreti applicativi) e per garantire proprietà come riservatezza, integrità e autenticità. In PHP oggi hai due “famiglie” principali di API a disposizione: libsodium (raccomandata, moderna e più difficile da usare male) e OpenSSL (molto diffusa, potente, ma più “tagliente” e soggetta a errori se non si rispettano le regole su IV, padding, modalità e autenticazione).

In questo articolo vediamo cosa fare e cosa evitare, con esempi completi in PHP. L’obiettivo è uscire con una “cassetta degli attrezzi” sicura per i casi più comuni: password, cifratura simmetrica, token firmati, file cifrati, scambio di chiavi e gestione dei segreti.

Concetti essenziali

Hash vs cifratura

  • Hash: funzione a senso unico. Dato un input produce un digest; non si può “decifrare” l’hash. Utile per password e integrità.
  • Cifratura: trasformazione reversibile; serve una chiave per decifrare. Utile per proteggere dati che poi devi recuperare.

Integrità e autenticazione (non solo segretezza)

Cifrare senza autenticare è una trappola classica: se un attaccante modifica il ciphertext, potresti decifrare dati corrotti senza accorgertene. Per questo si usa AEAD (Authenticated Encryption with Associated Data) come ChaCha20-Poly1305 o AES-256-GCM. In breve: cifra + autentica nello stesso algoritmo.

Casualità crittografica

Non usare rand() o mt_rand() per chiavi, nonce, token o sale. In PHP usa random_bytes() e random_int().

<?php
$token = bin2hex(random_bytes(32)); // 64 caratteri esadecimali, ~256 bit
echo $token;

Password: hashing corretto

Le password non vanno mai cifrate “per poterle recuperare”: vanno hashate con un algoritmo adattivo resistente a brute-force (con salt automatico e parametri configurabili). In PHP lo standard pratico è password_hash() + password_verify().

<?php
$password = $_POST['password'] ?? '';

$hash = password_hash($password, PASSWORD_DEFAULT); // oggi spesso bcrypt/argon2, dipende dalla build
// Salva $hash nel DB

// Login: verifica
$ok = password_verify($password, $hash);

// Aggiorna l'hash se cambiano i parametri/algoritmi consigliati
if ($ok && password_needs_rehash($hash, PASSWORD_DEFAULT)) {
    $hash = password_hash($password, PASSWORD_DEFAULT);
    // aggiorna nel DB
}

Scelta algoritmo e parametri

Se hai Argon2 disponibile, puoi impostarlo esplicitamente (per esempio PASSWORD_ARGON2ID) e regolare costi in base alle risorse del server.

<?php
$options = [
    'memory_cost' => 1<<17, // 128 MiB
    'time_cost'   => 3,
    'threads'     => 2,
];

$hash = password_hash($password, PASSWORD_ARGON2ID, $options);

Evita sha1, md5 e anche hash('sha256', ...) per password: non sono algoritmi progettati per resistere a GPU/ASIC, e senza costi elevati diventano vulnerabili a attacchi offline.

Token e integrità: HMAC

Se devi garantire che un messaggio non sia stato alterato (ma non ti interessa nasconderlo), usa un HMAC con una chiave segreta. È tipico per link firmati, webhook, API keys “con firma”, ecc.

<?php
$secretKey = hex2bin('b1f0...'); // 32 byte, esempio

$data = "user_id=123&exp=1700000000";
$mac  = hash_hmac('sha256', $data, $secretKey, true);
$token = rtrim(strtr(base64_encode($data . "." . $mac), '+/', '-_'), '=');

// Verifica
$decoded = base64_decode(strtr($token, '-_', '+/'), true);
[$data2, $mac2] = explode('.', $decoded, 2);

$expected = hash_hmac('sha256', $data2, $secretKey, true);
if (!hash_equals($expected, $mac2)) {
    throw new RuntimeException("Token non valido");
}

Nota l’uso di hash_equals() per confronti in tempo costante: evita leak temporali che possono aiutare a indovinare la firma.

Cifratura moderna con libsodium

Se puoi scegliere, libsodium è spesso la scelta migliore: API ad alto livello, nonce e tag gestiti correttamente, primitive robuste e default ragionevoli. In PHP è disponibile tramite l’estensione Sodium (molte distribuzioni la includono di default).

Secretbox: cifratura simmetrica autenticata

sodium_crypto_secretbox() implementa una cifratura autenticata (basata su XSalsa20-Poly1305). Richiede:

  • una chiave di 32 byte
  • un nonce di 24 byte, unico per ogni messaggio con la stessa chiave
<?php
// Genera e conserva la chiave in modo sicuro (una volta sola)
$key = sodium_crypto_secretbox_keygen(); // 32 byte

$message = "Dati super sensibili";
$nonce   = random_bytes(SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);

$ciphertext = sodium_crypto_secretbox($message, $nonce, $key);

// Trasporto/storage: salva nonce + ciphertext insieme (nonce non è segreto)
$payload = base64_encode($nonce . $ciphertext);

// Decifrazione
$raw = base64_decode($payload, true);
$nonce2 = substr($raw, 0, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);
$cipher2 = substr($raw, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);

$plaintext = sodium_crypto_secretbox_open($cipher2, $nonce2, $key);
if ($plaintext === false) {
    throw new RuntimeException("Autenticazione fallita o dati corrotti");
}
echo $plaintext;

AEAD con ChaCha20-Poly1305 e dati associati (AAD)

Con AEAD puoi autenticare anche metadati non cifrati (per esempio un ID record o un contesto). Quei metadati vengono verificati durante la decifrazione: se cambiano, la decifrazione fallisce.

<?php
$key = random_bytes(SODIUM_CRYPTO_AEAD_CHACHA20POLY1305_IETF_KEYBYTES);
$nonce = random_bytes(SODIUM_CRYPTO_AEAD_CHACHA20POLY1305_IETF_NPUBBYTES);

$aad = "record_id=42";
$plaintext = json_encode(["email" => "mario@example.com", "role" => "admin"], JSON_THROW_ON_ERROR);

$cipher = sodium_crypto_aead_chacha20poly1305_ietf_encrypt(
    $plaintext,
    $aad,
    $nonce,
    $key
);

$payload = base64_encode($nonce . $cipher);

// Open
$raw = base64_decode($payload, true);
$nonce2 = substr($raw, 0, SODIUM_CRYPTO_AEAD_CHACHA20POLY1305_IETF_NPUBBYTES);
$cipher2 = substr($raw, SODIUM_CRYPTO_AEAD_CHACHA20POLY1305_IETF_NPUBBYTES);

$plain2 = sodium_crypto_aead_chacha20poly1305_ietf_decrypt(
    $cipher2,
    $aad,   // deve combaciare
    $nonce2,
    $key
);

if ($plain2 === false) {
    throw new RuntimeException("Decifrazione fallita (chiave/nonce/AAD errati o manomissione)");
}

Derivazione chiavi da password

Per cifrare dati con una password utente è rischioso usare direttamente la password come chiave. Serve una funzione di derivazione (KDF) con parametri “costosi” e un salt. Con sodium puoi usare sodium_crypto_pwhash() per ottenere una chiave robusta da una password.

<?php
$password = $_POST['passphrase'] ?? '';
$salt = random_bytes(SODIUM_CRYPTO_PWHASH_SALTBYTES);

// Deriva 32 byte di chiave
$key = sodium_crypto_pwhash(
    32,
    $password,
    $salt,
    SODIUM_CRYPTO_PWHASH_OPSLIMIT_INTERACTIVE,
    SODIUM_CRYPTO_PWHASH_MEMLIMIT_INTERACTIVE
);

// Usa $key con secretbox/AEAD.
// Salva anche $salt (non è segreto) insieme ai dati cifrati.

Se il caso d’uso è “archiviazione cifrata con password”, considera anche la gestione del cambio password: tipicamente si cifra una chiave dati casuale con una chiave derivata dalla password, così puoi ruotare la password senza ricifrare tutti i dati.

OpenSSL: AES-GCM fatto bene

OpenSSL è utile quando devi interoperare con altri sistemi o usare standard specifici. Se puoi, scegli AES-256-GCM (AEAD). Richiede:

  • chiave da 32 byte (per AES-256)
  • IV/nonce unico (tipicamente 12 byte per GCM)
  • tag di autenticazione (di solito 16 byte)
<?php
$key = random_bytes(32); // conserva in modo sicuro
$iv  = random_bytes(12); // 96 bit consigliati per GCM

$plaintext = "Contenuto riservato";
$aad = "context=user:123"; // opzionale

$tag = '';
$ciphertext = openssl_encrypt(
    $plaintext,
    'aes-256-gcm',
    $key,
    OPENSSL_RAW_DATA,
    $iv,
    $tag,
    $aad,
    16
);

if ($ciphertext === false) {
    throw new RuntimeException("Cifratura fallita");
}

// Salva/trasporta iv + tag + ciphertext
$payload = base64_encode($iv . $tag . $ciphertext);

// Decifrazione
$raw = base64_decode($payload, true);
$iv2  = substr($raw, 0, 12);
$tag2 = substr($raw, 12, 16);
$c2   = substr($raw, 28);

$plain2 = openssl_decrypt(
    $c2,
    'aes-256-gcm',
    $key,
    OPENSSL_RAW_DATA,
    $iv2,
    $tag2,
    $aad
);

if ($plain2 === false) {
    throw new RuntimeException("Decifrazione fallita (manomissione o parametri errati)");
}

Errori comuni con OpenSSL

  • Riutilizzare l’IV con la stessa chiave: per GCM è pericoloso. L’IV deve essere unico.
  • Usare CBC senza MAC: AES-CBC senza autenticazione è vulnerabile (padding oracle e manomissione).
  • Gestire male encoding: conserva i dati binari con base64 (o come BLOB) senza “tagliare” byte.
  • Inventare formati senza versioning: inserisci un prefisso versione per aggiornare algoritmi/parametri.

Crittografia asimmetrica: firme e scambio chiavi

La crittografia asimmetrica usa una coppia di chiavi: una pubblica (distribuibile) e una privata (segreta). Serve tipicamente per:

  • Firme digitali: garantire autenticità e integrità (chiunque verifica con la chiave pubblica).
  • Scambio di chiavi: stabilire un segreto condiviso su canale insicuro.

Firme con libsodium (Ed25519)

Sodium rende le firme molto dirette. Puoi firmare un messaggio e verificarlo con la chiave pubblica.

<?php
$keypair = sodium_crypto_sign_keypair();
$publicKey = sodium_crypto_sign_publickey($keypair);
$secretKey = sodium_crypto_sign_secretkey($keypair);

$message = "Ordine: #123, importo=49.90";

$signature = sodium_crypto_sign_detached($message, $secretKey);

// Verifica
$ok = sodium_crypto_sign_verify_detached($signature, $message, $publicKey);
if (!$ok) {
    throw new RuntimeException("Firma non valida");
}

Cifra con chiave pubblica (sealed boxes)

Se devi inviare un messaggio a un destinatario conoscendo solo la sua chiave pubblica, puoi usare sodium_crypto_box_seal(). Il destinatario lo apre con la sua chiave privata.

<?php
$recipientKeypair = sodium_crypto_box_keypair();
$recipientPublic  = sodium_crypto_box_publickey($recipientKeypair);
$recipientSecret  = sodium_crypto_box_secretkey($recipientKeypair);

// Mittente: sigilla con la pubblica del destinatario
$sealed = sodium_crypto_box_seal("Messaggio per te", $recipientPublic);

// Destinatario: apre con la coppia di chiavi
$opened = sodium_crypto_box_seal_open($sealed, $recipientKeypair);
if ($opened === false) {
    throw new RuntimeException("Impossibile aprire il messaggio");
}

Gestione delle chiavi: il vero punto critico

Anche l’algoritmo migliore fallisce se la chiave viene esposta o gestita male. Alcune regole pratiche:

  • Non committare segreti: chiavi e password non devono finire in repository o immagini docker pubbliche.
  • Usa variabili d’ambiente o secret manager: Vault, AWS Secrets Manager, GCP Secret Manager, ecc.
  • Principio del minimo privilegio: l’app deve poter leggere solo i segreti necessari.
  • Rotazione e versioning: conserva un identificatore di chiave (kid) per supportare più chiavi attive.
  • Separazione: una chiave per scopo (password hashing ≠ cifratura dati ≠ HMAC).

Formato “versionato” per payload cifrati

Un trucco semplice per rendere il sistema aggiornabile è includere un prefisso versione e (se serve) un key id: ad esempio v1:kid=2026-01:<base64>. In futuro potrai introdurre v2 con parametri diversi senza rompere la compatibilità.

<?php
function packPayload(string $version, string $kid, string $binary): string {
    return $version . ":kid=" . $kid . ":" . rtrim(strtr(base64_encode($binary), '+/', '-_'), '=');
}

function unpackPayload(string $payload): array {
    $parts = explode(':', $payload, 3);
    if (count($parts) !== 3) {
        throw new InvalidArgumentException("Formato payload non valido");
    }
    [$version, $kidPart, $b64] = $parts;
    if (!str_starts_with($kidPart, 'kid=')) {
        throw new InvalidArgumentException("Kid mancante");
    }
    $kid = substr($kidPart, 4);
    $bin = base64_decode(strtr($b64, '-_', '+/'), true);
    if ($bin === false) {
        throw new InvalidArgumentException("Base64 non valido");
    }
    return [$version, $kid, $bin];
}

Checklist rapida di sicurezza

  1. Per password: password_hash / password_verify, mai SHA/MD5.
  2. Per cifrare dati: usa AEAD (libsodium o AES-GCM), mai “solo cifratura” senza autenticazione.
  3. Nonce/IV: sempre unico per chiave; generato con random_bytes.
  4. Confronti: usa hash_equals per MAC e token.
  5. Chiavi: non nel codice; usa secret manager; ruota e versiona.
  6. Encoding: conserva binario come base64 o BLOB; evita conversioni implicite.
  7. Test: crea test che verificano decifrazione, fallimenti su dati manomessi e compatibilità tra versioni.

Conclusione

In PHP puoi fare crittografia in modo robusto senza reinventare nulla: per le password usa l’API di hashing dedicata, per la cifratura preferisci libsodium e primitive AEAD, e con OpenSSL rimani su modalità autenticata come AES-GCM. Il passo decisivo, però, è trattare la gestione delle chiavi come parte del design: dove vengono conservate, come vengono ruotate, e come evolvono i formati dei dati nel tempo.

Torna su