Generare una passphrase con Bash

Generare una passphrase con Bash

Una passphrase è una sequenza di parole casuali concatenate tra loro, usata come credenziale di accesso al posto di una tradizionale password composta da caratteri pseudo-casuali. Rispetto a una password classica, una passphrase è più lunga, più facile da memorizzare e, se generata correttamente, altrettanto o più resistente agli attacchi a forza bruta. Strumenti come diceware ne hanno standardizzato il metodo di generazione, ma è possibile costruire un generatore funzionale direttamente in Bash, sfruttando gli strumenti standard di un sistema Linux.

In questo articolo si vedrà come leggere un dizionario di parole, selezionarne un sottoinsieme in modo casuale e sicuro, assemblarle in una passphrase e infine racchiudere il tutto in uno script riutilizzabile. Si analizzeranno anche le sorgenti di entropia disponibili su Linux e alcune varianti utili, come l'aggiunta di un separatore personalizzato o di un numero finale.

Entropia e sorgenti di casualità su Linux

Il kernel Linux espone due interfacce per la generazione di numeri casuali: /dev/random e /dev/urandom. La prima blocca il processo chiamante finché non ha raccolto abbastanza entropia dall'hardware; la seconda non blocca mai e utilizza un generatore pseudo-casuale crittograficamente sicuro (CSPRNG) alimentato dal pool di entropia del kernel. Su sistemi moderni (kernel 5.6 e successivi), la distinzione pratica tra i due è minima, perché il CSPRNG viene inizializzato correttamente all'avvio. Per la generazione di passphrase si usa tipicamente /dev/urandom.

Il comando shuf, parte di GNU coreutils, accetta internamente un seme derivato da /dev/urandom, rendendolo adatto a selezioni casuali non prevedibili. In alternativa, è possibile usare sort -R, ma shuf è più diretto e semanticamente più chiaro per il caso d'uso della selezione casuale di righe.

Il dizionario di parole

La qualità di una passphrase dipende dalla dimensione del dizionario da cui vengono estratte le parole. Su quasi tutte le distribuzioni Linux è disponibile il file /usr/share/dict/words, che contiene decine di migliaia di parole in lingua inglese (o nella lingua di sistema). Alcune distribuzioni lo separano in più file; su Debian e Ubuntu il pacchetto wamerican installa un dizionario americano da oltre 100.000 voci.

Per verificare la presenza e la dimensione del dizionario:

# Controlla se il dizionario esiste e conta le voci
wc -l /usr/share/dict/words

Un dizionario da 100.000 parole offre circa 17 bit di entropia per parola (log2 di 100.000). Con sei parole si ottengono oltre 100 bit di entropia, ben al di sopra della soglia considerata sicura per la maggior parte degli usi.

Se si vuole un dizionario più controllato, è possibile filtrare le parole per lunghezza, escludere quelle con apostrofi o caratteri non ASCII, e salvare il risultato in un file locale:

# Filtra le parole: solo minuscole, tra 4 e 8 caratteri, nessun apostrofo
grep -E '^[a-z]{4,8}$' /usr/share/dict/words > /tmp/filtered_words.txt
wc -l /tmp/filtered_words.txt

Selezionare parole casuali con shuf

Il modo più semplice per estrarre n parole casuali da un file è usare shuf -n:

# Estrae 6 parole casuali dal dizionario di sistema
shuf -n 6 /usr/share/dict/words

L'output sarà una parola per riga. Per assemblarle su una singola riga separate da un trattino si può usare paste con l'opzione -sd:

# Estrae 6 parole e le unisce con un trattino
shuf -n 6 /usr/share/dict/words | paste -sd '-'

In alternativa, con tr si sostituisce il carattere di nuova riga con il separatore scelto:

# Sostituisce i newline con uno spazio
shuf -n 6 /usr/share/dict/words | tr '\n' ' ' | sed 's/ $/\n/'

Primo script: generatore di base

Raccogliamo quanto visto finora in uno script parametrizzabile. Lo script accetta il numero di parole desiderato e il separatore come argomenti opzionali, con valori predefiniti sensati.

#!/usr/bin/env bash

# Percorso del dizionario di sistema
WORD_LIST="/usr/share/dict/words"

# Numero di parole predefinito
DEFAULT_WORD_COUNT=6

# Separatore predefinito
DEFAULT_SEPARATOR="-"

# Funzione principale di generazione
generate_passphrase() {
    local word_count="${1:-$DEFAULT_WORD_COUNT}"
    local separator="${2:-$DEFAULT_SEPARATOR}"

    # Verifica che il dizionario esista
    if [[ ! -f "$WORD_LIST" ]]; then
        echo "Errore: dizionario non trovato in $WORD_LIST" >&2
        exit 1
    fi

    # Verifica che il numero di parole sia un intero positivo
    if ! [[ "$word_count" =~ ^[1-9][0-9]*$ ]]; then
        echo "Errore: il numero di parole deve essere un intero positivo" >&2
        exit 1
    fi

    # Estrae le parole casuali e le unisce con il separatore
    shuf -n "$word_count" "$WORD_LIST" | paste -sd "$separator"
}

generate_passphrase "$@"

Salvato come passphrase.sh e reso eseguibile con chmod +x passphrase.sh, si usa così:

# Genera una passphrase con i valori predefiniti (6 parole, trattino)
./passphrase.sh

# Genera una passphrase con 8 parole separate da un punto
./passphrase.sh 8 '.'

# Genera una passphrase con 4 parole senza separatore
./passphrase.sh 4 ''

Filtrare le parole durante la generazione

Il dizionario di sistema può contenere parole con apostrofi, maiuscole, abbreviazioni o caratteri non ASCII. Per produrre passphrase più leggibili è utile filtrare al volo con grep:

#!/usr/bin/env bash

WORD_LIST="/usr/share/dict/words"
DEFAULT_WORD_COUNT=6
DEFAULT_SEPARATOR="-"
MIN_LENGTH=4
MAX_LENGTH=8

generate_passphrase() {
    local word_count="${1:-$DEFAULT_WORD_COUNT}"
    local separator="${2:-$DEFAULT_SEPARATOR}"

    if [[ ! -f "$WORD_LIST" ]]; then
        echo "Errore: dizionario non trovato" >&2
        exit 1
    fi

    # Filtra le parole accettabili prima di estrarre
    grep -E "^[a-z]{${MIN_LENGTH},${MAX_LENGTH}}$" "$WORD_LIST" \
        | shuf -n "$word_count" \
        | paste -sd "$separator"
}

generate_passphrase "$@"

La pipeline filtra prima tutte le voci che non rispettano i criteri (solo lettere minuscole ASCII, lunghezza tra MIN_LENGTH e MAX_LENGTH), poi applica shuf sul sottoinsieme risultante. In questo modo shuf lavora su un insieme già pulito.

Aggiungere un numero casuale in coda

Alcuni sistemi richiedono che la password contenga almeno un cifra. È possibile accodare un numero casuale a due cifre generato con $RANDOM o, in modo più sicuro, con /dev/urandom:

# Numero casuale tra 10 e 99 tramite /dev/urandom
get_random_number() {
    local max="${1:-99}"
    local min="${2:-10}"
    local range=$(( max - min + 1 ))
    # Legge un intero senza segno a 32 bit e lo riduce nell'intervallo desiderato
    local raw
    raw=$(od -An -N4 -tu4 /dev/urandom | tr -d ' ')
    echo $(( min + raw % range ))
}

# Aggiunge il numero in coda alla passphrase con il separatore
generate_passphrase_with_number() {
    local word_count="${1:-6}"
    local separator="${2:--}"
    local words
    words=$(grep -E "^[a-z]{4,8}$" /usr/share/dict/words \
        | shuf -n "$word_count" \
        | paste -sd "$separator")
    local number
    number=$(get_random_number 99 10)
    echo "${words}${separator}${number}"
}

generate_passphrase_with_number "$@"

Usare /dev/urandom direttamente per la selezione

Un approccio alternativo, che evita del tutto shuf, consiste nel leggere il dizionario in un array Bash e selezionare gli indici tramite byte letti da /dev/urandom. Questo metodo è più verboso ma rende esplicita la sorgente di entropia.

#!/usr/bin/env bash

WORD_LIST="/usr/share/dict/words"
DEFAULT_WORD_COUNT=6
DEFAULT_SEPARATOR="-"

# Legge un indice casuale nell'intervallo [0, max)
random_index() {
    local max="$1"
    # Legge 4 byte da urandom come intero senza segno
    local raw
    raw=$(od -An -N4 -tu4 /dev/urandom | tr -d ' ')
    echo $(( raw % max ))
}

generate_passphrase() {
    local word_count="${1:-$DEFAULT_WORD_COUNT}"
    local separator="${2:-$DEFAULT_SEPARATOR}"

    # Carica il dizionario filtrato in un array
    mapfile -t word_array < <(grep -E '^[a-z]{4,8}$' "$WORD_LIST")
    local dict_size="${#word_array[@]}"

    if (( dict_size == 0 )); then
        echo "Errore: dizionario vuoto dopo il filtraggio" >&2
        exit 1
    fi

    local selected_words=()
    for (( i = 0; i < word_count; i++ )); do
        local idx
        idx=$(random_index "$dict_size")
        selected_words+=("${word_array[$idx]}")
    done

    # Unisce le parole con il separatore usando printf
    local IFS="$separator"
    printf '%s\n' "${selected_words[*]}"
}

generate_passphrase "$@"

L'uso di mapfile (disponibile da Bash 4) carica l'intero dizionario filtrato in memoria come array indicizzato. La funzione random_index evita il bias di modulo classico per dizionari la cui dimensione non è una potenza di due, ma per dimensioni elevate (superiori a qualche decina di migliaia) l'errore introdotto è trascurabile in pratica. Per un'implementazione rigorosa senza bias, si ricorre al metodo del rifiuto (rejection sampling).

Rejection sampling per eliminare il bias di modulo

Il bias di modulo si verifica quando il numero totale di valori casuali possibili non è divisibile esattamente per la dimensione del dizionario. Alcune parole hanno una probabilità leggermente più alta di essere scelte. Per gli usi pratici il bias è irrilevante, ma uno script corretto dal punto di vista crittografico dovrebbe gestirlo:

# Campionamento per rifiuto: elimina il bias di modulo
unbiased_random_index() {
    local max="$1"
    # Calcola il limite superiore privo di bias
    local limit=$(( (2**32 / max) * max ))
    local raw
    while true; do
        raw=$(od -An -N4 -tu4 /dev/urandom | tr -d ' ')
        # Accetta solo valori sotto il limite calcolato
        if (( raw < limit )); then
            echo $(( raw % max ))
            return
        fi
    done
}

Il ciclo scarta i valori che cadrebbero nella fascia distorta e ne legge uno nuovo, garantendo una distribuzione uniforme. Il numero atteso di iterazioni è inferiore a 2, quindi il costo aggiuntivo è trascurabile.

Script completo e riutilizzabile

Lo script seguente consolida tutte le funzionalità viste: filtraggio del dizionario, selezione senza bias, separatore configurabile, numero opzionale in coda, e un piccolo menu di aiuto.

#!/usr/bin/env bash
set -euo pipefail

# Costanti predefinite
WORD_LIST="/usr/share/dict/words"
DEFAULT_WORD_COUNT=6
DEFAULT_SEPARATOR="-"
DEFAULT_MIN_LEN=4
DEFAULT_MAX_LEN=8

# Mostra l'aiuto
show_help() {
    cat <&2
        exit 1
    fi

    # Carica le parole filtrate in un array
    mapfile -t words < <(grep -E "^[a-z]{${min_len},${max_len}}$" "$dict_file")
    local dict_size="${#words[@]}"

    if (( dict_size < word_count )); then
        echo "Errore: dizionario troppo piccolo dopo il filtraggio ($dict_size parole disponibili)" >&2
        exit 1
    fi

    local selected=()
    for (( i = 0; i < word_count; i++ )); do
        local idx
        idx=$(unbiased_random_index "$dict_size")
        selected+=("${words[$idx]}")
    done

    # Aggiunge un numero casuale a due cifre se richiesto
    if [[ "$add_number" == "true" ]]; then
        local num
        num=$(( 10 + $(unbiased_random_index 90) ))
        selected+=("$num")
    fi

    local IFS="$separator"
    printf '%s\n' "${selected[*]}"
}

# Parsing degli argomenti
word_count="$DEFAULT_WORD_COUNT"
separator="$DEFAULT_SEPARATOR"
min_len="$DEFAULT_MIN_LEN"
max_len="$DEFAULT_MAX_LEN"
dict_file="$WORD_LIST"
add_number="false"

while getopts "n:s:l:L:d:Nh" opt; do
    case "$opt" in
        n) word_count="$OPTARG" ;;
        s) separator="$OPTARG" ;;
        l) min_len="$OPTARG" ;;
        L) max_len="$OPTARG" ;;
        d) dict_file="$OPTARG" ;;
        N) add_number="true" ;;
        h) show_help; exit 0 ;;
        *) show_help; exit 1 ;;
    esac
done

generate_passphrase \
    "$word_count" \
    "$separator" \
    "$min_len" \
    "$max_len" \
    "$dict_file" \
    "$add_number"

Esempi di utilizzo:

# Passphrase predefinita
./passphrase.sh

# Otto parole separate da un punto, con numero finale
./passphrase.sh -n 8 -s '.' -N

# Dizionario personalizzato, parole tra 5 e 10 caratteri
./passphrase.sh -d /tmp/custom_words.txt -l 5 -L 10

# Separatore vuoto (parole concatenate)
./passphrase.sh -s ''

Stima della forza della passphrase

L'entropia di una passphrase generata da un dizionario si calcola come:

# Calcola l'entropia in bit di una passphrase
calculate_entropy() {
    local dict_size="$1"
    local word_count="$2"
    # Usa bc per il logaritmo in base 2
    echo "scale=2; l($dict_size) / l(2) * $word_count" | bc -l
}

# Esempio: dizionario da 50000 parole, 6 parole
calculate_entropy 50000 6

Con 50.000 parole nel dizionario filtrato e sei parole estratte si ottengono circa 94 bit di entropia. Con otto parole si supera abbondantemente i 125 bit, considerato un obiettivo ragionevole per uso crittografico a lungo termine.

Considerazioni sulla sicurezza

Alcuni aspetti meritano attenzione. La variabile Bash $RANDOM non deve essere usata per scopi crittografici: produce solo 15 bit di casualità e non è basata su /dev/urandom. Il comando shuf di GNU coreutils usa internamente /dev/urandom e per scopi pratici è più che sufficiente, ma per un controllo esplicito è preferibile leggere direttamente dal device. Quando si usa paste o printf con variabili contenenti parole provenienti da un file esterno, occorre assicurarsi che le parole non contengano caratteri di controllo; la regex di filtraggio ^[a-z]{4,8}$ lo garantisce. Infine, la passphrase generata non dovrebbe essere salvata in un file di log o visualizzata in un ambiente condiviso.

Conclusioni

Bash offre tutti gli strumenti necessari per costruire un generatore di passphrase affidabile: /dev/urandom come sorgente di entropia crittograficamente sicura, shuf per la selezione casuale di righe, grep per il filtraggio del dizionario, e array indicizzati per implementare il campionamento senza bias. Lo script finale presentato è parametrizzabile, gestisce i casi d'errore e può essere integrato facilmente in pipeline di automazione o in script di provisioning. La semplicità dell'approccio non toglie nulla alla solidità del risultato: una passphrase di sei o più parole estratte da un dizionario ampio è una scelta difendibile sia per la sicurezza che per la memorabilità.