Generare una passphrase con JavaScript

Generare una passphrase con JavaScript

Una passphrase è una sequenza di parole comuni concatenate, utilizzata come alternativa a una password tradizionale. A differenza di una password composta da caratteri casuali e difficili da ricordare, una passphrase è leggibile dall'essere umano ma sufficientemente lunga e imprevedibile da resistere ad attacchi di tipo brute force o dizionario. Il concetto è stato reso popolare dal famoso fumetto xkcd di Randall Munroe, che mostrava come "correct horse battery staple" fosse più sicura di "Tr0ub4dor&3" pur essendo molto più facile da memorizzare.

In questo articolo vedremo come generare passphrases sicure in JavaScript, sia in ambiente browser sia in Node.js, sfruttando la Web Crypto API per ottenere numeri casuali crittograficamente sicuri. Implementeremo un generatore completo, con una wordlist, la gestione dell'entropia e un'interfaccia a riga di comando per Node.js.

Principi di sicurezza: entropia e casualità

Prima di scrivere il codice, è necessario comprendere due concetti fondamentali: l'entropia e la casualità crittografica. L'entropia misura il grado di imprevedibilità di una password o passphrase, espressa in bit. Più alta è l'entropia, più difficile è indovinarla. Una passphrase con entropia di 60 bit richiederebbe, in media, 2^59 tentativi per essere indovinata con un attacco brute force.

La formula per calcolare l'entropia di una passphrase è la seguente:

H = log2(N^W)

// dove:
// H = entropia in bit
// N = dimensione del vocabolario (numero di parole disponibili)
// W = numero di parole nella passphrase

Con una wordlist di 7776 parole (la dimensione standard del metodo Diceware) e una passphrase di 4 parole, otteniamo circa 51 bit di entropia. Con 6 parole si raggiungono circa 77 bit, considerati oggi sufficientemente sicuri per la maggior parte degli usi.

Il secondo principio riguarda la qualità della casualità. La funzione Math.random() di JavaScript non è crittograficamente sicura perché il suo output può essere prevedibile. Per generare passphrases sicure dobbiamo usare crypto.getRandomValues(), disponibile sia nei browser moderni sia in Node.js (dalla versione 15 come parte del modulo globale crypto).

Struttura del progetto

Organizziamo il progetto in più moduli separati per mantenere il codice pulito e riutilizzabile.

passphrase-generator/
├── wordlist.js
├── entropy.js
├── generator.js
└── cli.js

La wordlist

Una wordlist per passphrases deve contenere parole comuni, brevi e facilmente pronunciabili in una singola lingua. Per semplicità didattica utilizzeremo una wordlist ridotta di parole inglesi. In produzione è consigliabile adottare la lista EFF (Electronic Frontier Foundation), disponibile pubblicamente, che contiene 7776 parole selezionate con criteri rigorosi.

Il file wordlist.js esporta un array di stringhe:

// Lista ridotta di parole per la generazione della passphrase
const wordlist = [
  "apple", "brave", "cloud", "dance", "earth",
  "flame", "grace", "heart", "ivory", "joker",
  "knack", "lemon", "maple", "north", "ocean",
  "piano", "quest", "river", "stone", "tiger",
  "umbra", "vivid", "water", "xenon", "yield",
  "zonal", "amber", "blaze", "coral", "delta",
  "eagle", "faith", "globe", "honey", "inlet",
  "jewel", "karma", "light", "month", "nerve",
  "orbit", "panel", "quota", "radar", "solar",
  "thorn", "ultra", "vapor", "wheat", "extra",
  "young", "zebra", "arose", "brisk", "crisp",
  "drive", "enjoy", "frost", "grand", "happy",
  "index", "joint", "kneel", "lever", "minor",
  "noble", "optic", "pride", "query", "robin",
  "shelf", "thick", "unity", "vault", "whole",
  "xerox", "yarns", "zones", "altar", "bench",
  "chess", "dress", "elite", "flute", "giant",
  "house", "image", "judge", "kings", "lance",
  "magic", "night", "onset", "place", "quiet",
  "raise", "speak", "touch", "urban", "visit",
  "winds", "exact", "yelps", "zones", "agile",
  "beach", "carry", "depth", "every", "fixed",
  "given", "holds", "ideal", "jumps", "known",
  "loves", "moral", "noted", "often", "phase"
];

// Esportazione compatibile con Node.js (CommonJS) e browser
if (typeof module !== "undefined" && module.exports) {
  module.exports = { wordlist };
}

Nel caso si utilizzi la lista EFF completa, il file avrà 7776 voci e ogni parola sarà associata a un codice Diceware. Per l'uso puramente programmatico è sufficiente l'array di stringhe.

Il modulo per il calcolo dell'entropia

Il file entropy.js fornisce una funzione che calcola quanti bit di entropia ha una passphrase generata con una data wordlist e un dato numero di parole:

/**
 * Calcola l'entropia in bit di una passphrase.
 * @param {number} vocabularySize - Numero di parole nella wordlist
 * @param {number} wordCount - Numero di parole nella passphrase
 * @returns {number} Entropia espressa in bit
 */
function calculateEntropy(vocabularySize, wordCount) {
  // log2(N^W) = W * log2(N)
  return wordCount * Math.log2(vocabularySize);
}

/**
 * Restituisce una valutazione qualitativa dell'entropia.
 * @param {number} bits - Entropia in bit
 * @returns {string} Livello di sicurezza
 */
function evaluateSecurity(bits) {
  // Soglie convenzionali per la classificazione
  if (bits < 40) return "molto debole";
  if (bits < 56) return "debole";
  if (bits < 72) return "accettabile";
  if (bits < 100) return "forte";
  return "molto forte";
}

if (typeof module !== "undefined" && module.exports) {
  module.exports = { calculateEntropy, evaluateSecurity };
}

Il generatore crittograficamente sicuro

Il cuore del progetto è il file generator.js. La funzione principale usa crypto.getRandomValues() per selezionare indici casuali nella wordlist. Occorre prestare attenzione al problema del modulo bias: quando il numero di parole nella wordlist non è una potenza di due, il semplice resto della divisione introduce una leggera distorsione statistica. Per evitarla utilizziamo la tecnica del rejection sampling.

/**
 * Genera un intero casuale crittograficamente sicuro nell'intervallo [0, max).
 * Usa il rejection sampling per evitare il modulo bias.
 * @param {number} max - Limite superiore esclusivo
 * @returns {number} Intero casuale
 */
function getSecureRandomIndex(max) {
  // Calcola la soglia per il rejection sampling
  const threshold = (2 ** 32) - ((2 ** 32) % max);
  const buffer = new Uint32Array(1);

  let value;
  do {
    // Richiede un nuovo valore casuale finché non rientra nella soglia
    crypto.getRandomValues(buffer);
    value = buffer[0];
  } while (value >= threshold);

  return value % max;
}

/**
 * Seleziona una parola casuale dalla wordlist.
 * @param {string[]} words - Array di parole disponibili
 * @returns {string} Parola selezionata casualmente
 */
function pickRandomWord(words) {
  const index = getSecureRandomIndex(words.length);
  return words[index];
}

/**
 * Genera una passphrase completa.
 * @param {string[]} words - Wordlist da cui attingere
 * @param {number} wordCount - Numero di parole desiderate
 * @param {string} separator - Separatore tra le parole (default: spazio)
 * @returns {string} Passphrase generata
 */
function generatePassphrase(words, wordCount, separator = " ") {
  if (!Array.isArray(words) || words.length === 0) {
    throw new Error("La wordlist non può essere vuota.");
  }
  if (!Number.isInteger(wordCount) || wordCount < 1) {
    throw new Error("Il numero di parole deve essere un intero positivo.");
  }

  // Costruisce la passphrase selezionando le parole una per volta
  const selectedWords = [];
  for (let i = 0; i < wordCount; i++) {
    selectedWords.push(pickRandomWord(words));
  }

  return selectedWords.join(separator);
}

if (typeof module !== "undefined" && module.exports) {
  module.exports = { generatePassphrase, getSecureRandomIndex, pickRandomWord };
}

Il problema del modulo bias

Vale la pena approfondire brevemente il modulo bias. Supponiamo di avere una wordlist di 100 parole e di campionare da un intero a 32 bit (range 0–4294967295). Dividendo per 100, i valori da 0 a 94 compaiono 42949673 volte, mentre i valori da 95 a 99 compaiono solo 42949672 volte. Questa differenza, seppur minuscola, rompe l'uniformità della distribuzione. La soglia calcolata nel codice elimina i valori che causerebbero questa distorsione, garantendo che ogni parola abbia la stessa probabilità di essere scelta.

L'interfaccia a riga di comando

Il file cli.js mette insieme tutti i moduli e permette di usare il generatore direttamente dal terminale con Node.js:

const { wordlist } = require("./wordlist");
const { generatePassphrase } = require("./generator");
const { calculateEntropy, evaluateSecurity } = require("./entropy");

// Legge i parametri dalla riga di comando
const args = process.argv.slice(2);
const wordCount = parseInt(args[0], 10) || 4;
const separator = args[1] !== undefined ? args[1] : "-";

try {
  const passphrase = generatePassphrase(wordlist, wordCount, separator);
  const bits = calculateEntropy(wordlist.length, wordCount);
  const securityLevel = evaluateSecurity(bits);

  console.log("\nPassphrase generata:");
  console.log("  " + passphrase);
  console.log("\nStatistiche:");
  console.log("  Parole:    " + wordCount);
  console.log("  Entropia:  " + bits.toFixed(2) + " bit");
  console.log("  Sicurezza: " + securityLevel);
  console.log();
} catch (error) {
  // Mostra il messaggio di errore e termina con codice di uscita non zero
  console.error("Errore: " + error.message);
  process.exit(1);
}

Per eseguire il generatore dal terminale:

# Genera una passphrase di 4 parole separate da trattino (default)
node cli.js

# Genera una passphrase di 6 parole separate da punto
node cli.js 6 .

# Genera una passphrase di 5 parole senza separatore
node cli.js 5 ""

Un esempio di output:

Passphrase generata:
  brave-river-stone-eagle

Statistiche:
  Parole:    4
  Entropia:  27.86 bit
  Sicurezza: molto debole

Con la wordlist ridotta usata a scopo didattico l'entropia è naturalmente bassa. Con la lista EFF da 7776 parole, 4 parole produrrebbero circa 51.7 bit e 6 parole circa 77.5 bit.

Versione per il browser

Per usare il generatore in una pagina web, basta rendere disponibili le funzioni nello scope globale o in un modulo ES. La Web Crypto API è disponibile in tutti i browser moderni senza installare librerie aggiuntive. Ecco un esempio di widget HTML autonomo:

<!DOCTYPE html>
<html lang="it">
<head>
  <meta charset="UTF-8">
  <title>Generatore di passphrase</title>
</head>
<body>
  <h1>Generatore di passphrase</h1>
  <label for="wordCountInput">Numero di parole:</label>
  <input type="number" id="wordCountInput" value="4" min="3" max="10">
  <button id="generateButton">Genera</button>
  <p id="passphraseOutput"></p>
  <p id="entropyOutput"></p>

  <script>
    // Wordlist incorporata nella pagina (versione ridotta)
    const wordlist = [
      "apple", "brave", "cloud", "dance", "earth",
      "flame", "grace", "heart", "ivory", "joker"
      // ... aggiungere tutte le parole necessarie
    ];

    // Seleziona un indice casuale evitando il modulo bias
    function getSecureRandomIndex(max) {
      const threshold = (2 ** 32) - ((2 ** 32) % max);
      const buffer = new Uint32Array(1);
      let value;
      do {
        crypto.getRandomValues(buffer);
        value = buffer[0];
      } while (value >= threshold);
      return value % max;
    }

    // Genera la passphrase e aggiorna l'interfaccia
    function handleGenerate() {
      const wordCount = parseInt(
        document.getElementById("wordCountInput").value, 10
      );
      const selectedWords = [];

      for (let i = 0; i < wordCount; i++) {
        const index = getSecureRandomIndex(wordlist.length);
        selectedWords.push(wordlist[index]);
      }

      const passphrase = selectedWords.join("-");
      const bits = wordCount * Math.log2(wordlist.length);

      // Aggiorna il DOM con il risultato
      document.getElementById("passphraseOutput").textContent = passphrase;
      document.getElementById("entropyOutput").textContent =
        "Entropia: " + bits.toFixed(2) + " bit";
    }

    document.getElementById("generateButton")
      .addEventListener("click", handleGenerate);
  </script>
</body>
</html>

Generazione di più varianti

In alcune situazioni può essere utile generare un elenco di passphrases tra cui l'utente può scegliere la più facile da ricordare. Estendiamo il generatore con una funzione dedicata:

/**
 * Genera un insieme di passphrases alternative.
 * @param {string[]} words - Wordlist da cui attingere
 * @param {number} wordCount - Parole per ciascuna passphrase
 * @param {number} count - Numero di passphrases da generare
 * @param {string} separator - Separatore tra le parole
 * @returns {string[]} Array di passphrases
 */
function generateMultiple(words, wordCount, count, separator = "-") {
  // Valida il numero di varianti richieste
  if (!Number.isInteger(count) || count < 1 || count > 50) {
    throw new Error("Il numero di varianti deve essere compreso tra 1 e 50.");
  }

  const results = [];
  for (let i = 0; i < count; i++) {
    results.push(generatePassphrase(words, wordCount, separator));
  }
  return results;
}

// Utilizzo
const variants = generateMultiple(wordlist, 4, 5);
variants.forEach((phrase, index) => {
  console.log((index + 1) + ". " + phrase);
});

Aggiungere un numero e un carattere speciale

Alcuni sistemi richiedono che le password contengano almeno un numero e un carattere speciale. È possibile soddisfare questo requisito appendendo un suffisso generato casualmente alla passphrase, senza compromettere la memorabilità:

// Caratteri speciali consentiti dalla maggior parte dei sistemi
const SPECIAL_CHARS = "!@#$%^&*";
const DIGITS = "0123456789";

/**
 * Aggiunge un suffisso con un numero e un carattere speciale.
 * @param {string} passphrase - Passphrase di base
 * @returns {string} Passphrase con suffisso
 */
function appendSpecialSuffix(passphrase) {
  const digitIndex = getSecureRandomIndex(DIGITS.length);
  const specialIndex = getSecureRandomIndex(SPECIAL_CHARS.length);

  // Concatena il numero e il carattere speciale alla fine
  return passphrase + DIGITS[digitIndex] + SPECIAL_CHARS[specialIndex];
}

// Esempio di utilizzo
const base = generatePassphrase(wordlist, 4, "-");
const enhanced = appendSpecialSuffix(base);
console.log(enhanced); // es. "brave-river-stone-eagle7!"

Test unitari con Node.js

È buona pratica verificare il comportamento delle funzioni con test automatici. Usiamo il modulo nativo assert di Node.js per scrivere un piccolo suite di test senza dipendenze esterne:

const assert = require("assert");
const { generatePassphrase, getSecureRandomIndex } = require("./generator");
const { calculateEntropy, evaluateSecurity } = require("./entropy");

// Wordlist minima per i test
const testWordlist = Array.from({ length: 100 }, (_, i) => "word" + i);

// Verifica che la passphrase abbia il numero corretto di parole
function testWordCount() {
  const passphrase = generatePassphrase(testWordlist, 5, "-");
  const parts = passphrase.split("-");
  assert.strictEqual(parts.length, 5, "La passphrase deve avere 5 parole.");
  console.log("PASS: numero di parole corretto.");
}

// Verifica che le parole appartengano alla wordlist
function testWordsInList() {
  const passphrase = generatePassphrase(testWordlist, 4, " ");
  const parts = passphrase.split(" ");
  parts.forEach(word => {
    assert.ok(
      testWordlist.includes(word),
      "La parola " + word + " non è nella wordlist."
    );
  });
  console.log("PASS: tutte le parole appartengono alla wordlist.");
}

// Verifica che il separatore personalizzato venga applicato
function testSeparator() {
  const passphrase = generatePassphrase(testWordlist, 3, ".");
  assert.ok(passphrase.includes("."), "Il separatore deve essere un punto.");
  console.log("PASS: separatore personalizzato applicato.");
}

// Verifica il calcolo dell'entropia
function testEntropy() {
  const bits = calculateEntropy(7776, 6);
  assert.ok(bits > 77 && bits < 78, "L'entropia attesa è circa 77.5 bit.");
  console.log("PASS: calcolo entropia corretto.");
}

// Verifica che le eccezioni vengano lanciate per input non validi
function testInvalidInput() {
  assert.throws(
    () => generatePassphrase([], 4),
    /wordlist/,
    "Deve lanciare un errore per wordlist vuota."
  );
  assert.throws(
    () => generatePassphrase(testWordlist, 0),
    /intero positivo/,
    "Deve lanciare un errore per wordCount zero."
  );
  console.log("PASS: eccezioni per input non validi.");
}

// Esecuzione di tutti i test
[testWordCount, testWordsInList, testSeparator, testEntropy, testInvalidInput]
  .forEach(test => {
    try {
      test();
    } catch (error) {
      // Mostra il messaggio di fallimento e continua
      console.error("FAIL: " + error.message);
    }
  });

Considerazioni finali sulla sicurezza

Un generatore di passphrases sicuro non dipende soltanto dalla qualità del codice, ma anche dall'ambiente in cui viene eseguito. Alcune linee guida da tenere a mente:

Non memorizzare le passphrases generate in chiaro su disco o in variabili globali a lungo termine. Se l'applicazione deve salvare passphrases, utilizzare un gestore di password o un keychain di sistema. La passphrase deve essere mostrata all'utente una sola volta e poi, eventualmente, hashata con una funzione adeguata come bcrypt o Argon2 prima di essere archiviata.

Non trasmettere la passphrase su canali non cifrati. Nel browser, assicurarsi che la pagina sia servita in HTTPS, perché anche crypto.getRandomValues() non può proteggere da un attacco man-in-the-middle sulla pagina stessa.

Scegliere una wordlist ampia e ben curata. Una lista di sole 100 parole, come quella usata negli esempi didattici di questo articolo, produce passphrases con entropia insufficiente per uso reale. Con una lista da 7776 parole e 6 parole si raggiunge un livello di sicurezza paragonabile a una password casuale di 12 caratteri alfanumerici.

Valutare l'uso di un separatore non ovvio. Il trattino è comune e facilmente intuibile; usare un carattere meno prevedibile, o nessun separatore con lettere maiuscole iniziali, può aumentare leggermente l'entropia percepita senza complicare la memorizzazione.

JavaScript, grazie alla Web Crypto API, offre tutti gli strumenti necessari per costruire un generatore di passphrases robusto e portabile, utilizzabile sia in Node.js sia direttamente nel browser senza dipendenze esterne.