Generare una passphrase con Java

Generare una passphrase con Java

Una passphrase è una sequenza di parole casuali, separate da un carattere delimitatore, utilizzata come alternativa sicura a una password tradizionale. A differenza di una password composta da caratteri pseudo-casuali difficili da memorizzare, una passphrase è leggibile e facile da ricordare, mantenendo al tempo stesso un elevato livello di entropia. In questo articolo vedremo come implementare un generatore di passphrase in Java, partendo dalla gestione del dizionario di parole fino alla costruzione della stringa finale.

Concetti fondamentali

La sicurezza di una passphrase dipende da due fattori principali: la dimensione del dizionario da cui vengono estratte le parole e il numero di parole che compongono la passphrase. Se il dizionario contiene N parole e la passphrase è composta da k parole, l'entropia in bit si calcola come:

entropia = k * log2(N)

Ad esempio, con un dizionario di 2048 parole e una passphrase di 6 parole si ottengono circa 66 bit di entropia, considerata sufficiente per la maggior parte degli scenari di sicurezza odierni.

Un aspetto critico è la fonte di casualità. Java mette a disposizione due classi principali: java.util.Random, che utilizza un generatore pseudo-casuale non adatto a contesti crittografici, e java.security.SecureRandom, che si appoggia a sorgenti di entropia del sistema operativo. Per un generatore di passphrase destinato a uso reale è obbligatorio utilizzare SecureRandom.

Struttura del progetto

Organizzeremo il codice in tre classi principali:

  • WordlistLoader: carica il dizionario da file o da risorsa embedded.
  • PassphraseGenerator: seleziona le parole casuali e assembla la passphrase.
  • Main: punto di ingresso dell'applicazione con configurazione via riga di comando.

Per semplicità utilizzeremo un progetto Maven standard senza dipendenze esterne, in modo che il codice sia eseguibile su qualsiasi JDK 17 o superiore.

Il dizionario di parole

Un buon dizionario per passphrase deve contenere parole comuni, riconoscibili e preferibilmente senza ambiguità ortografiche. Lo standard de facto è la wordlist EFF (Electronic Frontier Foundation), che contiene 7776 parole in inglese progettate specificamente per questo scopo. Per i nostri esempi useremo un file di testo semplice, una parola per riga, collocato in src/main/resources/wordlist.txt.

La classe WordlistLoader legge il file dalla classpath e restituisce una lista immutabile di stringhe:

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;

public class WordlistLoader {

    // Percorso della wordlist nella classpath
    private static final String DEFAULT_RESOURCE = "/wordlist.txt";

    /**
     * Carica la wordlist dalla classpath e restituisce
     * una lista immutabile di parole.
     */
    public static List<String> load() throws IOException {
        return load(DEFAULT_RESOURCE);
    }

    /**
     * Carica la wordlist dal percorso di risorsa indicato.
     */
    public static List<String> load(String resourcePath) throws IOException {
        List<String> words = new ArrayList<>();

        // Apriamo la risorsa dal classpath
        try (InputStream inputStream = WordlistLoader.class.getResourceAsStream(resourcePath)) {
            if (inputStream == null) {
                throw new IOException("Risorsa non trovata: " + resourcePath);
            }
            BufferedReader reader = new BufferedReader(
                new InputStreamReader(inputStream, StandardCharsets.UTF_8)
            );

            String line;
            while ((line = reader.readLine()) != null) {
                // Ignoriamo righe vuote e commenti
                String trimmed = line.strip();
                if (!trimmed.isEmpty() && !trimmed.startsWith("#")) {
                    words.add(trimmed.toLowerCase());
                }
            }
        }

        if (words.isEmpty()) {
            throw new IllegalStateException("La wordlist è vuota.");
        }

        // Rendiamo la lista immutabile per sicurezza
        return Collections.unmodifiableList(words);
    }
}

La scelta di Collections.unmodifiableList impedisce modifiche accidentali alla wordlist una volta caricata. Il metodo strip(), disponibile da Java 11, gestisce correttamente gli spazi Unicode oltre agli spazi ASCII tradizionali.

Il generatore di passphrase

La classe centrale è PassphraseGenerator. Riceve in costruzione la lista di parole e un'istanza di SecureRandom, e offre un metodo generate che accetta il numero di parole desiderate e il separatore:

import java.security.SecureRandom;
import java.util.List;
import java.util.StringJoiner;

public class PassphraseGenerator {

    private final List<String> wordlist;
    private final SecureRandom secureRandom;

    /**
     * Costruisce il generatore con la wordlist fornita.
     * Crea internamente un'istanza di SecureRandom.
     */
    public PassphraseGenerator(List<String> wordlist) {
        this(wordlist, new SecureRandom());
    }

    /**
     * Costruisce il generatore con wordlist e sorgente
     * di casualità esplicita (utile nei test).
     */
    public PassphraseGenerator(List<String> wordlist, SecureRandom secureRandom) {
        if (wordlist == null || wordlist.isEmpty()) {
            throw new IllegalArgumentException("La wordlist non può essere nulla o vuota.");
        }
        this.wordlist = wordlist;
        this.secureRandom = secureRandom;
    }

    /**
     * Genera una passphrase composta dal numero di parole indicato,
     * separate dal separatore specificato.
     */
    public String generate(int wordCount, String separator) {
        if (wordCount < 1) {
            throw new IllegalArgumentException("Il numero di parole deve essere almeno 1.");
        }
        if (separator == null) {
            throw new IllegalArgumentException("Il separatore non può essere null.");
        }

        StringJoiner joiner = new StringJoiner(separator);

        for (int i = 0; i < wordCount; i++) {
            // Selezioniamo un indice casuale crittograficamente sicuro
            int index = secureRandom.nextInt(wordlist.size());
            joiner.add(wordlist.get(index));
        }

        return joiner.toString();
    }

    /**
     * Genera una passphrase con separatore predefinito (trattino).
     */
    public String generate(int wordCount) {
        return generate(wordCount, "-");
    }

    /**
     * Calcola l'entropia in bit della configurazione corrente.
     */
    public double calculateEntropy(int wordCount) {
        // log2(N) * k dove N è la dimensione della wordlist
        return wordCount * (Math.log(wordlist.size()) / Math.log(2));
    }
}

L'utilizzo di StringJoiner è preferibile alla concatenazione manuale con StringBuilder perché gestisce automaticamente il separatore senza aggiungerne uno in coda, producendo codice più leggibile e meno soggetto a errori di off-by-one.

Validazione dell'entropia

Prima di restituire la passphrase all'utente è buona pratica verificare che l'entropia minima sia rispettata. Definiamo una soglia di 60 bit come minimo accettabile e aggiungiamo un metodo di validazione:

import java.security.SecureRandom;
import java.util.List;
import java.util.StringJoiner;

public class PassphraseGenerator {

    // Soglia minima di entropia considerata sicura
    private static final double MINIMUM_ENTROPY_BITS = 60.0;

    private final List<String> wordlist;
    private final SecureRandom secureRandom;

    public PassphraseGenerator(List<String> wordlist) {
        this(wordlist, new SecureRandom());
    }

    public PassphraseGenerator(List<String> wordlist, SecureRandom secureRandom) {
        if (wordlist == null || wordlist.isEmpty()) {
            throw new IllegalArgumentException("La wordlist non può essere nulla o vuota.");
        }
        this.wordlist = wordlist;
        this.secureRandom = secureRandom;
    }

    public String generate(int wordCount, String separator) {
        if (wordCount < 1) {
            throw new IllegalArgumentException("Il numero di parole deve essere almeno 1.");
        }

        // Avvisiamo se l'entropia è inferiore alla soglia minima
        double entropy = calculateEntropy(wordCount);
        if (entropy < MINIMUM_ENTROPY_BITS) {
            throw new IllegalArgumentException(
                String.format(
                    "Entropia insufficiente: %.1f bit (minimo %.1f bit). " +
                    "Aumentare il numero di parole o la dimensione della wordlist.",
                    entropy, MINIMUM_ENTROPY_BITS
                )
            );
        }

        StringJoiner joiner = new StringJoiner(separator);
        for (int i = 0; i < wordCount; i++) {
            int index = secureRandom.nextInt(wordlist.size());
            joiner.add(wordlist.get(index));
        }

        return joiner.toString();
    }

    public String generate(int wordCount) {
        return generate(wordCount, "-");
    }

    public double calculateEntropy(int wordCount) {
        return wordCount * (Math.log(wordlist.size()) / Math.log(2));
    }
}

Il punto di ingresso dell'applicazione

La classe Main gestisce gli argomenti da riga di comando e coordina il flusso di esecuzione. Accetta due parametri opzionali: il numero di parole (default 6) e il separatore (default -):

import java.io.IOException;
import java.util.List;

public class Main {

    // Numero di parole predefinito se non specificato
    private static final int DEFAULT_WORD_COUNT = 6;

    // Separatore predefinito tra le parole
    private static final String DEFAULT_SEPARATOR = "-";

    public static void main(String[] args) {
        int wordCount = DEFAULT_WORD_COUNT;
        String separator = DEFAULT_SEPARATOR;

        // Leggiamo il numero di parole dal primo argomento
        if (args.length >= 1) {
            try {
                wordCount = Integer.parseInt(args[0]);
            } catch (NumberFormatException e) {
                System.err.println("Errore: il primo argomento deve essere un intero positivo.");
                System.exit(1);
            }
        }

        // Leggiamo il separatore dal secondo argomento
        if (args.length >= 2) {
            separator = args[1];
        }

        try {
            // Carichiamo il dizionario dalla classpath
            List<String> wordlist = WordlistLoader.load();

            PassphraseGenerator generator = new PassphraseGenerator(wordlist);
            String passphrase = generator.generate(wordCount, separator);
            double entropy = generator.calculateEntropy(wordCount);

            System.out.println("Passphrase: " + passphrase);
            System.out.printf("Entropia:   %.1f bit%n", entropy);
            System.out.printf("Parole:     %d / Dizionario: %d parole%n",
                wordCount, wordlist.size());

        } catch (IOException e) {
            System.err.println("Errore nel caricamento della wordlist: " + e.getMessage());
            System.exit(1);
        } catch (IllegalArgumentException e) {
            System.err.println("Parametri non validi: " + e.getMessage());
            System.exit(1);
        }
    }
}

Generare un dizionario di parole italiane

Se si desidera una passphrase in italiano, è possibile costruire una wordlist personalizzata a partire da un corpus lessicale oppure da un file di testo esistente. Il seguente metodo legge un file di testo, estrae i token alfabetici e seleziona le parole con lunghezza compresa tra 4 e 8 caratteri, scrivendo il risultato in un nuovo file:

import java.io.BufferedWriter;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Arrays;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;

public class WordlistBuilder {

    // Lunghezza minima e massima delle parole selezionate
    private static final int MIN_WORD_LENGTH = 4;
    private static final int MAX_WORD_LENGTH = 8;

    /**
     * Costruisce una wordlist leggendo un corpus testuale.
     * Estrae parole uniche di lunghezza compresa tra MIN e MAX.
     */
    public static void buildFromCorpus(Path inputPath, Path outputPath) throws IOException {
        String content = Files.readString(inputPath, StandardCharsets.UTF_8);

        // Suddividiamo il testo in token non alfabetici
        String[] tokens = content.split("[^a-zA-Zàèéìîïòôùûüäöüçñ]+");

        // Filtriamo per lunghezza e normalizziamo in minuscolo
        Set<String> uniqueWords = Arrays.stream(tokens)
            .map(String::toLowerCase)
            .filter(w -> w.length() >= MIN_WORD_LENGTH && w.length() <= MAX_WORD_LENGTH)
            .collect(Collectors.toCollection(LinkedHashSet::new));

        // Scriviamo le parole nel file di output, una per riga
        try (BufferedWriter writer = Files.newBufferedWriter(outputPath, StandardCharsets.UTF_8)) {
            for (String word : uniqueWords) {
                writer.write(word);
                writer.newLine();
            }
        }

        System.out.printf("Wordlist costruita: %d parole uniche salvate in %s%n",
            uniqueWords.size(), outputPath);
    }
}

L'utilizzo di LinkedHashSet garantisce l'unicità delle parole preservando l'ordine di inserimento, il che facilita la verifica manuale del contenuto del file generato.

Test con JUnit 5

Una suite di test essenziale verifica il comportamento del generatore nei casi normali e nei casi limite. Utilizziamo un SecureRandom con seed fisso per rendere i test deterministici:

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;

import java.security.SecureRandom;
import java.util.List;

import static org.junit.jupiter.api.Assertions.*;

class PassphraseGeneratorTest {

    // Wordlist minimale per i test
    private static final List<String> TEST_WORDLIST = List.of(
        "apple", "banana", "cherry", "date", "elder",
        "fig", "grape", "honeydew", "ivy", "jasmine",
        "kiwi", "lemon", "mango", "nectarine", "orange",
        "papaya", "quince", "raspberry", "strawberry", "tangerine",
        "ugli", "vanilla", "watermelon", "ximenia", "yuzu", "zucchini"
    );

    private PassphraseGenerator generator;

    @BeforeEach
    void setUp() {
        // Seed fisso per rendere i test riproducibili
        SecureRandom deterministicRandom = new SecureRandom(new byte[]{42});
        generator = new PassphraseGenerator(TEST_WORDLIST, deterministicRandom);
    }

    @Test
    void generateReturnsCorrectWordCount() {
        // Verifichiamo che la passphrase contenga esattamente 4 parole
        String passphrase = generator.generate(4, "-");
        String[] parts = passphrase.split("-");
        assertEquals(4, parts.length);
    }

    @Test
    void generateUsesSpecifiedSeparator() {
        // Verifichiamo che il separatore sia quello indicato
        String passphrase = generator.generate(3, ".");
        assertTrue(passphrase.contains("."));
        assertFalse(passphrase.contains("-"));
    }

    @Test
    void generateContainsOnlyWordlistWords() {
        // Ogni parola della passphrase deve appartenere alla wordlist
        String passphrase = generator.generate(4, " ");
        String[] parts = passphrase.split(" ");
        for (String part : parts) {
            assertTrue(TEST_WORDLIST.contains(part),
                "La parola '" + part + "' non appartiene alla wordlist.");
        }
    }

    @Test
    void calculateEntropyIsCorrect() {
        // Con 26 parole e 4 token: entropia = 4 * log2(26) ≈ 18.8 bit
        double entropy = generator.calculateEntropy(4);
        assertEquals(4 * (Math.log(26) / Math.log(2)), entropy, 0.001);
    }

    @Test
    void generateThrowsOnZeroWordCount() {
        // Deve sollevare eccezione con 0 parole
        assertThrows(IllegalArgumentException.class, () -> generator.generate(0));
    }

    @Test
    void constructorThrowsOnEmptyWordlist() {
        // Deve sollevare eccezione con wordlist vuota
        assertThrows(IllegalArgumentException.class,
            () -> new PassphraseGenerator(List.of()));
    }
}

Esempio di output

Con una wordlist EFF da 7776 parole e 6 parole richieste, un'esecuzione tipica produce:

Passphrase: clutter-uproot-unmasked-dwindling-afoot-granite
Entropia:   77.5 bit
Parole:     6 / Dizionario: 7776 parole

Con 5 parole e separatore spazio:

Passphrase: mossy upbeat revival slander jukebox
Entropia:   64.6 bit
Parole:     5 / Dizionario: 7776 parole

Considerazioni sulla sicurezza

Alcuni aspetti meritano attenzione quando si distribuisce un generatore di passphrase in un contesto reale. In primo luogo, la passphrase non deve essere mai memorizzata in chiaro: se l'applicazione gestisce credenziali, il risultato deve essere passato immediatamente alla funzione di hashing (Argon2, bcrypt o scrypt) senza lasciare tracce in log o variabili di istanza a lunga vita.

In secondo luogo, String in Java è immutabile e interned, il che significa che il suo contenuto rimane in memoria fino al prossimo ciclo del garbage collector senza possibilità di azzeramento esplicito. Per applicazioni ad alta sensibilità è preferibile lavorare con array di char o byte, che possono essere azzerati con Arrays.fill non appena la passphrase non è più necessaria.

In terzo luogo, la scelta del dizionario influisce sulla resistenza agli attacchi a dizionario stessi. Una wordlist pubblica e nota come quella EFF è paradossalmente più sicura di una lista oscura e privata, perché l'entropia è calcolabile con precisione e non dipende dall'ipotesi che l'attaccante non conosca la lista.

Conclusioni

Abbiamo costruito un generatore di passphrase completo in Java puro, senza dipendenze esterne, articolato in tre classi con responsabilità ben separate. Il cuore del sistema è l'utilizzo di SecureRandom per la selezione delle parole, che garantisce una distribuzione crittograficamente uniforme. La validazione dell'entropia protegge da configurazioni deboli, mentre la struttura modulare facilita l'estensione: è sufficiente sostituire la wordlist o il separatore per adattare il generatore a qualsiasi requisito di localizzazione o policy aziendale.

Un ulteriore sviluppo potrebbe prevedere l'integrazione con una API REST, la gestione di dizionari multipli in lingue diverse o l'aggiunta di opzioni per inserire cifre e simboli tra le parole, aumentando l'entropia senza sacrificare eccessivamente la memorizzabilità della passphrase.