Crittografia con Java

Java offre un ecosistema maturo per la crittografia grazie alla Java Cryptography Architecture (JCA) e, per le funzionalità di cifratura, alla Java Cryptography Extension (JCE). In pratica, la maggior parte del lavoro avviene tramite API standard (come Cipher, MessageDigest, Signature, KeyStore) che delegano l’implementazione a un provider crittografico. Questo articolo mostra come usare le primitive più comuni in modo sicuro, con esempi pronti all’uso.

1. Prerequisiti e concetti chiave

  • Provider: moduli (SunJCE, SunRsaSign, ecc.) che implementano algoritmi e modalità. Java seleziona un provider in base alla disponibilità e alla priorità.
  • Primitive:
    • Hash (es. SHA-256): impronta non reversibile.
    • MAC/HMAC (es. HmacSHA256): integrità/autenticità con chiave simmetrica.
    • Cifratura simmetrica (es. AES-GCM): stessa chiave per cifrare/decifrare.
    • Cifratura asimmetrica (es. RSA/OAEP): coppia chiave pubblica/privata.
    • Firme digitali (es. ECDSA o RSA-PSS): autenticità e non ripudio.
  • Modalità e padding: per i cifrari a blocchi (AES) la modalità è cruciale. Evitare ECB; preferire GCM (autenticata) o, dove necessario, CBC + HMAC.
  • Non reinventare protocolli: la crittografia “a pezzi” è delicata. Usare schemi standard (AEAD come AES-GCM, OAEP, PSS, PBKDF2/Argon2 dove disponibile).

2. Generazione sicura di numeri casuali

Molte vulnerabilità derivano da casualità debole. Per chiavi, salt e IV/nonce usa SecureRandom. In Java moderno è sufficiente new SecureRandom() (o SecureRandom.getInstanceStrong() se accetti possibili blocchi).

import java.security.SecureRandom;

public class RandomExample {
  private static final SecureRandom RNG = new SecureRandom();

  public static byte[] randomBytes(int n) {
    byte[] out = new byte[n];
    RNG.nextBytes(out);
    return out;
  }
}

Salt e nonce non devono essere segreti, ma devono essere unici (per AES-GCM è fondamentale). Una pratica comune è 12 byte (96 bit) per il nonce GCM.

3. Hash: impronte e verifiche

Un hash serve per verificare integrità o indicizzare contenuti, non per proteggere password. Per gli hash usa algoritmi moderni come SHA-256 o SHA-512.

import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;

public class HashExample {
  public static byte[] sha256(byte[] data) {
    try {
      MessageDigest md = MessageDigest.getInstance("SHA-256");
      return md.digest(data);
    } catch (NoSuchAlgorithmException e) {
      throw new IllegalStateException("SHA-256 non disponibile", e);
    }
  }

  public static void main(String[] args) {
    byte[] digest = sha256("ciao".getBytes(StandardCharsets.UTF_8));
    System.out.println(bytesToHex(digest));
  }

  private static String bytesToHex(byte[] b) {
    StringBuilder sb = new StringBuilder(b.length * 2);
    for (byte v : b) sb.append(String.format("%02x", v));
    return sb.toString();
  }
}

Se devi confrontare hash o MAC, usa confronti a tempo costante per ridurre rischi di timing attack.

public static boolean constantTimeEquals(byte[] a, byte[] b) {
  if (a == null || b == null) return false;
  if (a.length != b.length) return false;
  int diff = 0;
  for (int i = 0; i < a.length; i++) diff |= (a[i] ^ b[i]);
  return diff == 0;
}

4. HMAC: integrità e autenticità con chiave simmetrica

Per garantire che un messaggio non sia stato alterato e che provenga da chi conosce una chiave condivisa, usa un HMAC (es. HmacSHA256). Non è un hash: è un MAC basato su chiave.

import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.security.GeneralSecurityException;

public class HmacExample {
  public static byte[] hmacSha256(byte[] key, byte[] message) {
    try {
      Mac mac = Mac.getInstance("HmacSHA256");
      mac.init(new SecretKeySpec(key, "HmacSHA256"));
      return mac.doFinal(message);
    } catch (GeneralSecurityException e) {
      throw new IllegalStateException("Errore HMAC", e);
    }
  }
}

Un caso d’uso tipico è proteggere token o payload (per esempio un blob JSON) quando non vuoi o non puoi usare firme asimmetriche. Se ti serve anche confidenzialità, valuta un cifrario autenticato come AES-GCM.

5. Cifratura simmetrica moderna: AES-GCM

AES-GCM è un algoritmo di cifratura autenticata (AEAD): fornisce confidenzialità e integrità in un’unica operazione, producendo un tag di autenticazione. Questo riduce gli errori rispetto a schemi “Cifra-then-MAC” implementati a mano.

5.1 Generare una chiave AES

import javax.crypto.KeyGenerator;
import javax.crypto.SecretKey;
import java.security.GeneralSecurityException;

public class AesKeyGen {
  public static SecretKey generateAesKey(int bits) {
    try {
      KeyGenerator kg = KeyGenerator.getInstance("AES");
      kg.init(bits); // 128, 192, 256 (dipende dalle policy della JVM)
      return kg.generateKey();
    } catch (GeneralSecurityException e) {
      throw new IllegalStateException("Impossibile generare chiave AES", e);
    }
  }
}

5.2 Cifrare e decifrare con GCM

Parametri importanti:

  • Nonce/IV: tipicamente 12 byte, deve essere unico per ogni cifratura con la stessa chiave.
  • Tag length: spesso 128 bit (16 byte).
  • AAD (Additional Authenticated Data): dati non cifrati ma autenticati (es. id utente, versione protocollo).
import javax.crypto.Cipher;
import javax.crypto.SecretKey;
import javax.crypto.spec.GCMParameterSpec;
import java.security.GeneralSecurityException;
import java.security.SecureRandom;
import java.util.Arrays;

public class AesGcm {
  private static final SecureRandom RNG = new SecureRandom();
  private static final int NONCE_LEN = 12;       // 96 bit
  private static final int TAG_LEN_BITS = 128;   // 16 byte

  public static byte[] encrypt(SecretKey key, byte[] plaintext, byte[] aad) {
    try {
      byte[] nonce = new byte[NONCE_LEN];
      RNG.nextBytes(nonce);

      Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
      GCMParameterSpec spec = new GCMParameterSpec(TAG_LEN_BITS, nonce);
      cipher.init(Cipher.ENCRYPT_MODE, key, spec);

      if (aad != null) cipher.updateAAD(aad);

      byte[] ct = cipher.doFinal(plaintext);

      // Output: nonce || ciphertext+tag
      byte[] out = new byte[nonce.length + ct.length];
      System.arraycopy(nonce, 0, out, 0, nonce.length);
      System.arraycopy(ct, 0, out, nonce.length, ct.length);
      return out;
    } catch (GeneralSecurityException e) {
      throw new IllegalStateException("Errore cifratura AES-GCM", e);
    }
  }

  public static byte[] decrypt(SecretKey key, byte[] nonceAndCiphertext, byte[] aad) {
    try {
      byte[] nonce = Arrays.copyOfRange(nonceAndCiphertext, 0, NONCE_LEN);
      byte[] ct = Arrays.copyOfRange(nonceAndCiphertext, NONCE_LEN, nonceAndCiphertext.length);

      Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
      GCMParameterSpec spec = new GCMParameterSpec(TAG_LEN_BITS, nonce);
      cipher.init(Cipher.DECRYPT_MODE, key, spec);

      if (aad != null) cipher.updateAAD(aad);

      // Se tag non valido: AEADBadTagException (GeneralSecurityException)
      return cipher.doFinal(ct);
    } catch (GeneralSecurityException e) {
      throw new IllegalStateException("Errore decifratura AES-GCM (tag non valido o chiave errata)", e);
    }
  }
}

Nota operativa: salva sempre insieme a ciphertext anche il nonce (e l’eventuale AAD se serve ricostruirla). Il nonce non è segreto, ma deve essere univoco; una strategia comune è generarlo random e anteporlo al ciphertext come nell’esempio.

6. Derivare chiavi da password: PBKDF2

Le password non sono chiavi crittografiche: hanno bassa entropia. Per usarle in cifratura, deriva una chiave con un KDF (Key Derivation Function) come PBKDF2 con HMAC-SHA256 (o SHA512). In JCA è disponibile tramite SecretKeyFactory.

Linee guida:

  • Salt casuale (almeno 16 byte) diverso per ogni password.
  • Iterazioni: scegli un valore alto e rivaluta nel tempo (dipende dall’hardware target). Testa e misura.
  • Lunghezza chiave: 256 bit per AES-256 (o 128 per AES-128).
import javax.crypto.SecretKey;
import javax.crypto.SecretKeyFactory;
import javax.crypto.spec.PBEKeySpec;
import javax.crypto.spec.SecretKeySpec;
import java.security.GeneralSecurityException;
import java.security.SecureRandom;

public class Pbkdf2 {
  private static final SecureRandom RNG = new SecureRandom();

  public static class DerivedKey {
    public final byte[] salt;
    public final SecretKey key;

    public DerivedKey(byte[] salt, SecretKey key) {
      this.salt = salt;
      this.key = key;
    }
  }

  public static DerivedKey deriveAesKey(char[] password, int iterations, int keyBits) {
    try {
      byte[] salt = new byte[16];
      RNG.nextBytes(salt);

      PBEKeySpec spec = new PBEKeySpec(password, salt, iterations, keyBits);
      SecretKeyFactory skf = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256");
      byte[] raw = skf.generateSecret(spec).getEncoded();

      SecretKey aesKey = new SecretKeySpec(raw, "AES");
      return new DerivedKey(salt, aesKey);
    } catch (GeneralSecurityException e) {
      throw new IllegalStateException("Errore PBKDF2", e);
    }
  }
}

In produzione, salva salt e iterations insieme al ciphertext. La password non va mai salvata. Cancella le password in memoria quando possibile (es. sovrascrivendo l’array char[]) e valuta l’uso di librerie specializzate se gestisci credenziali ad alta criticità.

7. Cifratura asimmetrica: RSA con OAEP

RSA è spesso usato per scambiare un segreto (es. una chiave AES) oppure per cifrare dati molto piccoli. Evita di cifrare direttamente grandi payload. Usa OAEP, non PKCS#1 v1.5.

7.1 Generare una coppia di chiavi RSA

import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.GeneralSecurityException;

public class RsaKeyGen {
  public static KeyPair generateRsaKeyPair(int bits) {
    try {
      KeyPairGenerator kpg = KeyPairGenerator.getInstance("RSA");
      kpg.initialize(bits); // es. 2048 o 3072
      return kpg.generateKeyPair();
    } catch (GeneralSecurityException e) {
      throw new IllegalStateException("Impossibile generare chiavi RSA", e);
    }
  }
}

7.2 Cifrare una chiave simmetrica con RSA-OAEP

import javax.crypto.Cipher;
import javax.crypto.SecretKey;
import java.security.GeneralSecurityException;
import java.security.PublicKey;
import java.security.PrivateKey;

public class RsaOaep {
  public static byte[] wrapAesKey(PublicKey rsaPublic, SecretKey aesKey) {
    try {
      Cipher c = Cipher.getInstance("RSA/ECB/OAEPWithSHA-256AndMGF1Padding");
      c.init(Cipher.WRAP_MODE, rsaPublic);
      return c.wrap(aesKey);
    } catch (GeneralSecurityException e) {
      throw new IllegalStateException("Errore wrap RSA-OAEP", e);
    }
  }

  public static SecretKey unwrapAesKey(PrivateKey rsaPrivate, byte[] wrapped) {
    try {
      Cipher c = Cipher.getInstance("RSA/ECB/OAEPWithSHA-256AndMGF1Padding");
      c.init(Cipher.UNWRAP_MODE, rsaPrivate);
      return (SecretKey) c.unwrap(wrapped, "AES", Cipher.SECRET_KEY);
    } catch (GeneralSecurityException e) {
      throw new IllegalStateException("Errore unwrap RSA-OAEP", e);
    }
  }
}

Questo schema (RSA per proteggere una chiave AES, AES-GCM per cifrare il payload) è un esempio di cifratura ibrida. È la scelta tipica quando devi cifrare dati arbitrariamente grandi per un destinatario specifico.

8. Firme digitali: ECDSA o RSA-PSS

Se devi dimostrare che un messaggio proviene da un soggetto specifico senza condividere una chiave segreta, usa una firma digitale. In generale:

  • ECDSA con curve moderne (es. P-256) è efficiente e compatto.
  • RSA-PSS è preferibile rispetto a RSA “classico” con PKCS#1 v1.5.

8.1 Esempio con ECDSA (SHA256withECDSA)

import java.security.*;

public class EcdsaSignature {
  public static KeyPair generateEcKeyPair() {
    try {
      KeyPairGenerator kpg = KeyPairGenerator.getInstance("EC");
      kpg.initialize(256); // spesso mappa a secp256r1 / P-256
      return kpg.generateKeyPair();
    } catch (GeneralSecurityException e) {
      throw new IllegalStateException("Impossibile generare chiavi EC", e);
    }
  }

  public static byte[] sign(PrivateKey privateKey, byte[] message) {
    try {
      Signature sig = Signature.getInstance("SHA256withECDSA");
      sig.initSign(privateKey);
      sig.update(message);
      return sig.sign();
    } catch (GeneralSecurityException e) {
      throw new IllegalStateException("Errore firma ECDSA", e);
    }
  }

  public static boolean verify(PublicKey publicKey, byte[] message, byte[] signature) {
    try {
      Signature sig = Signature.getInstance("SHA256withECDSA");
      sig.initVerify(publicKey);
      sig.update(message);
      return sig.verify(signature);
    } catch (GeneralSecurityException e) {
      throw new IllegalStateException("Errore verifica ECDSA", e);
    }
  }
}

Quando distribuisci chiavi pubbliche, quasi sempre lo fai tramite certificati X.509 e una catena di fiducia (PKI). In applicazioni interne, puoi anche distribuire direttamente una chiave pubblica “pinnata” (key pinning) se il modello di minaccia lo consente.

9. KeyStore: gestire chiavi e certificati

Java fornisce KeyStore per archiviare chiavi private e certificati. I formati comuni includono:

  • PKCS12 (.p12/.pfx): oggi spesso preferito per interoperabilità.
  • JKS: storico, specifico Java; ancora diffuso ma in genere PKCS12 è consigliato.

Esempio: caricare un keystore e leggere una chiave privata e il certificato associato.

import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.KeyStore;
import java.security.PrivateKey;
import java.security.cert.Certificate;

public class KeyStoreRead {
  public static class KeyMaterial {
    public final PrivateKey privateKey;
    public final Certificate certificate;

    public KeyMaterial(PrivateKey privateKey, Certificate certificate) {
      this.privateKey = privateKey;
      this.certificate = certificate;
    }
  }

  public static KeyMaterial loadPkcs12(Path p12Path, char[] storePassword, String alias, char[] keyPassword) {
    try (InputStream in = Files.newInputStream(p12Path)) {
      KeyStore ks = KeyStore.getInstance("PKCS12");
      ks.load(in, storePassword);

      PrivateKey pk = (PrivateKey) ks.getKey(alias, keyPassword);
      Certificate cert = ks.getCertificate(alias);

      if (pk == null || cert == null) {
        throw new IllegalArgumentException("Alias non trovato o materiale incompleto: " + alias);
      }
      return new KeyMaterial(pk, cert);
    } catch (Exception e) {
      throw new IllegalStateException("Errore lettura KeyStore", e);
    }
  }
}

Per creare keystore e certificati in sviluppo puoi usare keytool. In ambienti produttivi, la generazione e protezione delle chiavi private dovrebbe seguire procedure rigorose (HSM, segregazione dei ruoli, backup cifrati, rotazione).

# Genera un keystore PKCS12 con una coppia di chiavi e un certificato self-signed (dev/test)
keytool -genkeypair -alias app -keyalg EC -keysize 256 -sigalg SHA256withECDSA \
  -keystore app.p12 -storetype PKCS12 -storepass changeit -keypass changeit \
  -dname "CN=App Dev, OU=Dev, O=Example, L=Roma, ST=RM, C=IT"

10. Errori comuni e best practice

  • Usare AES/ECB: non fornisce sicurezza semantica, rivela pattern. Evitarlo.
  • Riutilizzare nonce in GCM: può compromettere confidenzialità e integrità. Nonce unico per chiave.
  • Gestire password con hash veloce: per password usa funzioni lente (PBKDF2/bcrypt/scrypt/Argon2). In Java standard PBKDF2 è disponibile; per Argon2/bcrypt serve libreria esterna.
  • Parametri non versionati: salva insieme ai dati cifrati anche algoritmo, nonce, salt, iterazioni, versione schema.
  • Confronti non costanti: per MAC/firme/token usare confronti costanti dove applicabile.
  • Eccezioni ignorate: in particolare in decifratura AEAD, un errore di tag deve interrompere il flusso (dati non autentici).
  • Chiavi hardcoded: mai nel codice. Usa KeyStore, vault, variabili d’ambiente protette, HSM/KMS.
  • Rotazione e scadenza: pianifica rotazione delle chiavi e supporto a più chiavi attive (key id).

11. Un formato di “pacchetto” pratico per dati cifrati

Per interoperabilità interna è utile definire un payload autodescrittivo. Un esempio semplice:

  1. 1 byte: versione
  2. 1 byte: id algoritmo (o stringa)
  3. 12 byte: nonce
  4. N byte: ciphertext+tag
  5. Opzionale: salt, iterazioni, key id, ecc.

Anche se puoi serializzare in JSON/Base64, un formato binario compatto riduce overhead ed errori. L’importante è versionare e testare la compatibilità.

12. Checklist finale

  • Preferisci AEAD (AES-GCM) per cifratura di dati.
  • Nonce GCM: 12 byte, univoco per chiave.
  • Chiavi: SecureRandom, mai hardcoded.
  • Password: PBKDF2 con salt e molte iterazioni (o KDF più moderno tramite librerie).
  • Asimmetrica: RSA-OAEP per cifrare chiavi; firme con ECDSA o RSA-PSS.
  • KeyStore/PKCS12 per gestire chiavi e certificati.
  • Salva sempre parametri e versione dello schema insieme ai dati.

Con queste basi puoi costruire componenti crittografici affidabili in Java. Prima di andare in produzione, aggiungi test (inclusi test di regressione e compatibilità), analisi del modello di minaccia e, se possibile, una revisione di sicurezza indipendente.

Torna su