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
WordListProviderInterfaceche astrae la sorgente delle parole; - una classe concreta
FileWordListProviderche carica le parole da un file di testo; - una classe
PassphraseGeneratorche 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.