Generare una passphrase con PHP

Generare una passphrase con PHP

Una passphrase è una sequenza di parole casuali concatenate, spesso separate da un delimitatore, utilizzata come credenziale di autenticazione in alternativa alla classica password composta da caratteri eterogenei. Rispetto a una password tradizionale, una passphrase offre vantaggi significativi: è più lunga, più difficile da indovinare mediante attacchi di forza bruta e, al tempo stesso, più facile da memorizzare per l'utente.

Il modello di sicurezza alla base delle passphrase è stato formalizzato da Diceware e successivamente adottato in contesti come XKCD 936, che ha dimostrato come una sequenza di quattro o cinque parole comuni possa offrire un'entropia superiore a quella di molte password complesse di otto caratteri. In questo articolo vedremo come implementare un generatore di passphrase in PHP, partendo da principi fondamentali fino ad arrivare a una classe riutilizzabile e testabile.

Entropia e sicurezza di una passphrase

L'entropia di una passphrase dipende dal numero di parole nel dizionario usato e dal numero di parole estratte. La formula è la seguente:

H = log2(N^L)

dove:
  H = entropia in bit
  N = dimensione del dizionario (numero di parole disponibili)
  L = lunghezza della passphrase (numero di parole)

Se il dizionario contiene 7.776 parole (la dimensione standard di Diceware, corrispondente a 6^5) e si estraggono 5 parole, l'entropia è circa 90 bit, un valore considerato sicuro per la maggior parte degli scenari attuali. Con un dizionario di 65.536 parole (2^16) e 4 parole, si ottengono 64 bit, mentre con 6 parole si raggiungono 96 bit.

Randomness crittograficamente sicura in PHP

Il punto critico nella generazione di una passphrase è la qualità della randomness. PHP offre, a partire dalla versione 7, la funzione random_int(), che si appoggia al generatore di numeri casuali del sistema operativo (CSPRNG). È fondamentale non usare rand() o mt_rand() per scopi crittografici, in quanto entrambe le funzioni producono sequenze predicibili.

<?php

// Esempio base: selezione sicura di un indice casuale
$wordList = ['alpha', 'bravo', 'charlie', 'delta', 'echo'];
$index = random_int(0, count($wordList) - 1);
$word = $wordList[$index];

echo $word;

La funzione random_int() lancia un'eccezione di tipo Error se non è in grado di reperire bytes di entropia sufficienti dal sistema, il che la rende sicura per uso in produzione senza ulteriori accorgimenti.

Struttura del progetto

Organizzeremo il codice in tre componenti principali:

  • un'interfaccia WordListProviderInterface che astrae la sorgente delle parole;
  • una classe concreta FileWordListProvider che carica le parole da un file di testo;
  • una classe PassphraseGenerator che orchestra la generazione della passphrase.

Questa separazione consente di sostituire il provider in fase di test senza toccare la logica del generatore.

L'interfaccia WordListProviderInterface

<?php

declare(strict_types=1);

namespace App\Passphrase;

interface WordListProviderInterface
{
    /**
     * Restituisce l'elenco delle parole disponibili.
     *
     * @return string[]
     */
    public function getWords(): array;
}

La classe FileWordListProvider

Questa implementazione legge un file di testo in cui ogni riga contiene una parola. Il file viene caricato una sola volta e memorizzato in una proprietà per evitare letture ripetute.

<?php

declare(strict_types=1);

namespace App\Passphrase;

use RuntimeException;

class FileWordListProvider implements WordListProviderInterface
{
    /** @var string[] */
    private array $words = [];

    public function __construct(private readonly string $filePath)
    {
        $this->load();
    }

    private function load(): void
    {
        // Verifica che il file esista e sia leggibile
        if (!is_file($this->filePath) || !is_readable($this->filePath)) {
            throw new RuntimeException(
                sprintf('Il file dizionario non è accessibile: %s', $this->filePath)
            );
        }

        // Legge ogni riga e rimuove gli spazi vuoti
        $lines = file($this->filePath, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);

        if ($lines === false || count($lines) < 2) {
            throw new RuntimeException('Il file dizionario è vuoto o non valido.');
        }

        // Normalizza le parole in minuscolo
        $this->words = array_values(array_map('strtolower', $lines));
    }

    public function getWords(): array
    {
        return $this->words;
    }
}

La classe PassphraseGenerator

Il cuore della logica risiede in questa classe. Il metodo generate() accetta la lunghezza della passphrase (numero di parole) e un delimitatore opzionale, e restituisce la stringa finale.

<?php

declare(strict_types=1);

namespace App\Passphrase;

use InvalidArgumentException;

class PassphraseGenerator
{
    // Lunghezza minima accettabile in termini di numero di parole
    private const MIN_LENGTH = 3;
    // Delimitatore predefinito
    private const DEFAULT_SEPARATOR = '-';

    public function __construct(
        private readonly WordListProviderInterface $provider
    ) {}

    /**
     * Genera una passphrase casuale.
     *
     * @throws InvalidArgumentException se la lunghezza è inferiore al minimo
     * @throws \Random\RandomException   se il CSPRNG non è disponibile
     */
    public function generate(int $length = 5, string $separator = self::DEFAULT_SEPARATOR): string
    {
        // Controlla che la lunghezza sia almeno il minimo consentito
        if ($length < self::MIN_LENGTH) {
            throw new InvalidArgumentException(
                sprintf('La lunghezza minima è %d parole.', self::MIN_LENGTH)
            );
        }

        $words = $this->provider->getWords();
        $count = count($words);

        // Seleziona le parole in modo crittograficamente sicuro
        $selected = [];
        for ($i = 0; $i < $length; $i++) {
            $index = random_int(0, $count - 1);
            $selected[] = $words[$index];
        }

        return implode($separator, $selected);
    }
}

Preparare il dizionario

Il dizionario è un semplice file di testo con una parola per riga. È possibile usare liste pubbliche come la EFF Large Wordlist (7.776 parole) o creare una lista personalizzata. Un esempio minimo (wordlist.txt):

abbey
absent
absorb
abstract
abyss
academy
account
achieve
acquire
action
active
acute
...

Per ottenere buona entropia, il dizionario dovrebbe contenere almeno 4.096 parole (2^12). La EFF Large Wordlist è disponibile pubblicamente e contiene parole in inglese selezionate per essere facilmente memorizzabili e distintive.

Utilizzo del generatore

<?php

declare(strict_types=1);

require_once __DIR__ . '/vendor/autoload.php';

use App\Passphrase\FileWordListProvider;
use App\Passphrase\PassphraseGenerator;

// Carica il dizionario dal file
$provider = new FileWordListProvider(__DIR__ . '/wordlist.txt');

$generator = new PassphraseGenerator($provider);

// Genera una passphrase di 5 parole separate da trattino
echo $generator->generate(5, '-') . PHP_EOL;

// Genera una passphrase di 6 parole separate da punto
echo $generator->generate(6, '.') . PHP_EOL;

// Genera una passphrase di 4 parole senza separatore
echo $generator->generate(4, '') . PHP_EOL;

Un esempio di output con un dizionario EFF potrebbe essere:

fabric-zombie-pillow-novel-crane
signal.parish.rocket.album.marble.token
clenchtrumpetglacierenvoy

Aggiungere un numero per incrementare l'entropia

Alcuni sistemi richiedono che la password contenga almeno un carattere numerico. È possibile estendere il generatore per inserire casualmente un numero nella passphrase:

<?php

declare(strict_types=1);

namespace App\Passphrase;

class PassphraseGeneratorWithDigit extends PassphraseGenerator
{
    /**
     * Genera una passphrase con un numero casuale inserito in posizione casuale.
     */
    public function generateWithDigit(int $length = 5, string $separator = '-'): string
    {
        $passphrase = $this->generate($length, $separator);
        $parts = explode($separator, $passphrase);

        // Genera un numero casuale a due cifre
        $digit = (string) random_int(10, 99);

        // Scegli una posizione casuale in cui inserire il numero
        $position = random_int(0, count($parts));
        array_splice($parts, $position, 0, [$digit]);

        return implode($separator, $parts);
    }
}

Esempio di output:

fabric-zombie-47-pillow-novel-crane

Implementare un provider in memoria per i test

Grazie all'interfaccia WordListProviderInterface, è semplice creare un provider fittizio da usare nei test unitari senza dipendere dal filesystem:

<?php

declare(strict_types=1);

namespace Tests\Passphrase;

use App\Passphrase\WordListProviderInterface;

class InMemoryWordListProvider implements WordListProviderInterface
{
    /** @param string[] $words */
    public function __construct(private readonly array $words) {}

    public function getWords(): array
    {
        return $this->words;
    }
}

Un test con PHPUnit risulta quindi leggibile e isolato:

<?php

declare(strict_types=1);

namespace Tests\Passphrase;

use App\Passphrase\PassphraseGenerator;
use InvalidArgumentException;
use PHPUnit\Framework\TestCase;

class PassphraseGeneratorTest extends TestCase
{
    private PassphraseGenerator $generator;

    protected function setUp(): void
    {
        // Dizionario fisso per rendere i test deterministici nel formato
        $provider = new InMemoryWordListProvider([
            'alpha', 'bravo', 'charlie', 'delta', 'echo',
            'foxtrot', 'golf', 'hotel', 'india', 'juliet',
        ]);

        $this->generator = new PassphraseGenerator($provider);
    }

    public function testGenerateReturnsCorrectNumberOfWords(): void
    {
        $passphrase = $this->generator->generate(4, '-');
        $parts = explode('-', $passphrase);

        // Verifica che il numero di parole corrisponda
        $this->assertCount(4, $parts);
    }

    public function testGenerateUsesProvidedSeparator(): void
    {
        $passphrase = $this->generator->generate(3, '.');

        // Verifica che il separatore sia corretto
        $this->assertStringContainsString('.', $passphrase);
    }

    public function testGenerateThrowsOnLengthBelowMinimum(): void
    {
        $this->expectException(InvalidArgumentException::class);

        // Deve lanciare un'eccezione per lunghezza insufficiente
        $this->generator->generate(2);
    }

    public function testAllWordsAreLowercase(): void
    {
        $passphrase = $this->generator->generate(5, '-');

        // Ogni parola deve essere in minuscolo
        foreach (explode('-', $passphrase) as $word) {
            $this->assertSame(strtolower($word), $word);
        }
    }
}

Esporre il generatore tramite un endpoint HTTP

In un contesto applicativo reale, è comune esporre il generatore come endpoint REST. Ecco un esempio minimale con routing manuale, senza framework:

<?php

declare(strict_types=1);

require_once __DIR__ . '/vendor/autoload.php';

use App\Passphrase\FileWordListProvider;
use App\Passphrase\PassphraseGenerator;

header('Content-Type: application/json');

// Recupera i parametri dalla query string con valori di default sicuri
$length    = isset($_GET['length'])    ? (int) $_GET['length']    : 5;
$separator = isset($_GET['separator']) ? (string) $_GET['separator'] : '-';

// Limite superiore per prevenire richieste eccessive
if ($length > 20) {
    http_response_code(400);
    echo json_encode(['error' => 'La lunghezza massima consentita è 20 parole.']);
    exit;
}

try {
    $provider   = new FileWordListProvider(__DIR__ . '/wordlist.txt');
    $generator  = new PassphraseGenerator($provider);
    $passphrase = $generator->generate($length, $separator);

    echo json_encode([
        'passphrase' => $passphrase,
        'length'     => $length,
        'separator'  => $separator,
    ]);
} catch (Throwable $e) {
    http_response_code(500);
    echo json_encode(['error' => $e->getMessage()]);
}

Una richiesta a /generate.php?length=5&separator=- restituirà una risposta simile a:

{
  "passphrase": "marble-signal-token-parish-rocket",
  "length": 5,
  "separator": "-"
}

Considerazioni sulla sicurezza aggiuntive

Anche con una buona implementazione, è bene tenere presenti alcune considerazioni pratiche. La trasmissione della passphrase deve avvenire sempre su canale HTTPS, poiché il valore in chiaro non deve transitare su rete non cifrata. Lato server, la passphrase non deve mai essere registrata nei log delle richieste HTTP. Se la passphrase viene memorizzata nel database, deve essere sottoposta ad hashing con password_hash() usando PASSWORD_BCRYPT o PASSWORD_ARGON2ID, esattamente come una password tradizionale.

Inoltre, è opportuno non generare passphrase sul lato client JavaScript e poi inviarle al server come testo in chiaro nel corpo della richiesta senza alcun hashing preventivo: il rischio di intercettazione o logging accidentale rimane elevato. La generazione deve avvenire sempre server-side tramite random_int().

Conclusioni

Generare una passphrase sicura in PHP è un'operazione relativamente semplice purché si rispettino due principi fondamentali: usare un CSPRNG (random_int()) e un dizionario di dimensioni adeguate. La struttura a interfaccia e provider presentata in questo articolo permette di mantenere il codice disaccoppiato, testabile e facilmente estendibile, ad esempio aggiungendo supporto per dizionari in lingue diverse, per caratteri speciali obbligatori, o per lunghezze minime di caratteri totali in aggiunta al numero di parole.

L'approccio a passphrase è raccomandato dal NIST nelle linee guida SP 800-63B e rappresenta oggi una delle strategie più efficaci per conciliare sicurezza e usabilità nell'autenticazione utente.