Calcolare l'entropia di una password in Go

L'entropia di una password è una misura quantitativa della sua imprevedibilità ed è uno dei parametri più importanti per valutarne la robustezza. Espressa in bit, indica il numero di tentativi che un attaccante dovrebbe effettuare, in media, per indovinare la password tramite un attacco a forza bruta. In questo articolo vedremo come calcolare l'entropia di una password in Go, partendo dalle basi teoriche fino ad arrivare a un'implementazione completa e riutilizzabile.

Cos'è l'entropia di una password

L'entropia, in teoria dell'informazione, misura il livello di incertezza associato a una variabile casuale. Applicata alle password, rappresenta la quantità di informazione necessaria per descrivere una password scelta da un determinato insieme di caratteri. La formula classica per calcolare l'entropia di una password generata casualmente è:

E = log2(N^L) = L * log2(N)

dove E è l'entropia in bit, N è la dimensione dell'alfabeto (cioè il numero di caratteri possibili) e L è la lunghezza della password. Più alto è il valore di E, più la password è resistente agli attacchi a forza bruta.

Per esempio, una password di 8 caratteri composta solo da lettere minuscole ha un alfabeto di 26 caratteri e quindi un'entropia pari a 8 * log2(26) ≈ 37,6 bit. La stessa lunghezza, ma con un alfabeto che include maiuscole, minuscole, cifre e simboli (circa 94 caratteri stampabili), porta l'entropia a circa 52,4 bit.

Determinare la dimensione dell'alfabeto

Il primo passo per calcolare l'entropia è determinare la dimensione effettiva dell'alfabeto utilizzato nella password. Questa operazione consiste nell'analizzare i caratteri presenti e nel sommare le dimensioni dei pool a cui appartengono. I pool tipici sono:

  • Lettere minuscole: 26 caratteri
  • Lettere maiuscole: 26 caratteri
  • Cifre: 10 caratteri
  • Simboli stampabili ASCII: 32 caratteri
  • Caratteri Unicode estesi: variabile

In Go possiamo utilizzare il package unicode per classificare i caratteri in modo affidabile. Vediamo una prima implementazione:

package entropy

import (
    "unicode"
)

// CharsetSize calcola la dimensione del pool di caratteri
// utilizzato in una password analizzando i tipi di rune presenti.
func CharsetSize(password string) int {
    var hasLower, hasUpper, hasDigit, hasSymbol, hasOther bool

    for _, r := range password {
        switch {
        case unicode.IsLower(r):
            hasLower = true
        case unicode.IsUpper(r):
            hasUpper = true
        case unicode.IsDigit(r):
            hasDigit = true
        case unicode.IsPunct(r) || unicode.IsSymbol(r):
            hasSymbol = true
        default:
            // Caratteri non ASCII o spazi
            hasOther = true
        }
    }

    size := 0
    if hasLower {
        size += 26
    }
    if hasUpper {
        size += 26
    }
    if hasDigit {
        size += 10
    }
    if hasSymbol {
        size += 32
    }
    if hasOther {
        // Approssimazione conservativa per altri caratteri
        size += 100
    }

    return size
}

La funzione CharsetSize esamina ogni rune della password e attiva un flag per ciascuna categoria rilevata. Alla fine, somma le dimensioni dei pool corrispondenti ai flag attivi. È importante iterare sulle rune e non sui byte, perché in Go le stringhe sono sequenze di byte UTF-8 e una semplice iterazione tramite indice potrebbe spezzare i caratteri multibyte.

Implementare il calcolo dell'entropia

Una volta nota la dimensione dell'alfabeto, il calcolo dell'entropia è immediato. Utilizziamo il package math per la funzione logaritmica:

package entropy

import (
    "math"
    "unicode/utf8"
)

// Calculate restituisce l'entropia in bit di una password
// considerando la lunghezza in rune e la dimensione dell'alfabeto.
func Calculate(password string) float64 {
    if password == "" {
        return 0
    }

    length := utf8.RuneCountInString(password)
    charset := CharsetSize(password)

    if charset == 0 {
        return 0
    }

    return float64(length) * math.Log2(float64(charset))
}

La funzione Calculate applica direttamente la formula L * log2(N). Si noti l'uso di utf8.RuneCountInString per ottenere la lunghezza corretta in caratteri Unicode anziché in byte: questa distinzione è fondamentale per password che includono caratteri accentati o simboli speciali.

Classificare la robustezza di una password

Il valore numerico dell'entropia, da solo, non è sempre intuitivo. Per offrire un riscontro più immediato all'utente, è utile mappare l'entropia su categorie di robustezza. Una classificazione comunemente accettata è la seguente:

  • Sotto i 28 bit: molto debole
  • Tra 28 e 36 bit: debole
  • Tra 36 e 60 bit: ragionevole
  • Tra 60 e 128 bit: forte
  • Oltre i 128 bit: molto forte

Possiamo definire un tipo dedicato e una funzione che restituisca la categoria corrispondente:

package entropy

// Strength rappresenta il livello di robustezza di una password.
type Strength int

const (
    VeryWeak Strength = iota
    Weak
    Reasonable
    Strong
    VeryStrong
)

// String restituisce una rappresentazione testuale del livello.
func (s Strength) String() string {
    switch s {
    case VeryWeak:
        return "very weak"
    case Weak:
        return "weak"
    case Reasonable:
        return "reasonable"
    case Strong:
        return "strong"
    case VeryStrong:
        return "very strong"
    default:
        return "unknown"
    }
}

// Classify converte un valore di entropia in una categoria.
func Classify(bits float64) Strength {
    switch {
    case bits < 28:
        return VeryWeak
    case bits < 36:
        return Weak
    case bits < 60:
        return Reasonable
    case bits < 128:
        return Strong
    default:
        return VeryStrong
    }
}

L'uso di iota per definire le costanti rende il codice più conciso e meno soggetto a errori, mentre il metodo String consente di stampare direttamente il valore tramite l'interfaccia fmt.Stringer.

Stimare il tempo di crack

Oltre all'entropia, è spesso utile fornire una stima del tempo necessario per craccare la password tramite un attacco a forza bruta. Assumendo un attaccante in grado di effettuare un certo numero di tentativi al secondo, il numero medio di tentativi necessari è pari alla metà dello spazio totale, cioè 2^(E-1):

package entropy

import (
    "math"
    "time"
)

// CrackTime stima il tempo medio necessario per craccare una password
// dato un numero di tentativi al secondo.
func CrackTime(bits float64, guessesPerSecond float64) time.Duration {
    if guessesPerSecond <= 0 || bits <= 0 {
        return 0
    }

    // Numero medio di tentativi: 2^(bits - 1)
    averageGuesses := math.Pow(2, bits-1)
    seconds := averageGuesses / guessesPerSecond

    // Limita il valore massimo per evitare overflow
    maxSeconds := float64(math.MaxInt64) / float64(time.Second)
    if seconds > maxSeconds {
        return time.Duration(math.MaxInt64)
    }

    return time.Duration(seconds * float64(time.Second))
}

Il controllo sul valore massimo è necessario perché time.Duration è un int64 e una password con entropia molto alta produrrebbe valori che superano la rappresentabilità del tipo. In quel caso restituiamo math.MaxInt64 come valore sentinella.

Mettere insieme i pezzi

A questo punto possiamo combinare le funzioni precedenti in un'API coerente che restituisca un risultato strutturato. Definiamo un tipo Report con tutti i dati rilevanti:

package entropy

import (
    "fmt"
    "time"
)

// Report contiene il risultato completo dell'analisi di una password.
type Report struct {
    Password    string
    Length      int
    CharsetSize int
    Bits        float64
    Strength    Strength
    CrackTime   time.Duration
}

// Analyze esegue l'analisi completa di una password e restituisce
// un report con entropia, robustezza e tempo di crack stimato.
func Analyze(password string, guessesPerSecond float64) Report {
    bits := Calculate(password)
    return Report{
        Password:    password,
        Length:      len([]rune(password)),
        CharsetSize: CharsetSize(password),
        Bits:        bits,
        Strength:    Classify(bits),
        CrackTime:   CrackTime(bits, guessesPerSecond),
    }
}

// String restituisce una rappresentazione leggibile del report.
func (r Report) String() string {
    return fmt.Sprintf(
        "length=%d charset=%d entropy=%.2f bits strength=%s crack_time=%s",
        r.Length, r.CharsetSize, r.Bits, r.Strength, r.CrackTime,
    )
}

Questa struttura espone tutte le informazioni utili in un unico oggetto, semplificando l'integrazione con altri sistemi (per esempio, un endpoint REST o una CLI). Si noti che il campo Password è incluso solo a scopo dimostrativo: in un contesto di produzione è preferibile evitare di propagare la password in chiaro nei log o nelle risposte API.

Esempio di utilizzo

Vediamo ora come utilizzare il package in un programma completo. Assumiamo un attaccante moderno in grado di effettuare circa 10 miliardi di tentativi al secondo, valore plausibile per un cluster GPU che attacca un hash veloce come MD5 o SHA-1:

package main

import (
    "fmt"

    "example.com/entropy"
)

func main() {
    // Velocità ipotetica di un attaccante con cluster GPU
    const guessesPerSecond = 1e10

    passwords := []string{
        "password",
        "Password1",
        "P@ssw0rd!",
        "correct horse battery staple",
        "x9!Kp2#mZ7qB$nL4",
    }

    for _, p := range passwords {
        report := entropy.Analyze(p, guessesPerSecond)
        fmt.Printf("password=%q -> %s\n", p, report)
    }
}

L'output mostrerà come password apparentemente complesse ma corte siano spesso meno robuste di passphrase più lunghe ma composte da parole comuni. Questa è una conseguenza diretta della formula: la lunghezza pesa linearmente, mentre la dimensione dell'alfabeto pesa solo logaritmicamente.

Testare il codice

Un package del genere richiede test unitari accurati per garantirne il corretto funzionamento. Go offre un sistema di testing integrato molto efficace:

package entropy

import (
    "math"
    "testing"
)

func TestCharsetSize(t *testing.T) {
    tests := []struct {
        name     string
        input    string
        expected int
    }{
        {"only lowercase", "abcdef", 26},
        {"only digits", "123456", 10},
        {"lower and digits", "abc123", 36},
        {"all categories", "Abc123!@", 94},
        {"empty string", "", 0},
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            got := CharsetSize(tt.input)
            if got != tt.expected {
                t.Errorf("CharsetSize(%q) = %d, want %d",
                    tt.input, got, tt.expected)
            }
        })
    }
}

func TestCalculate(t *testing.T) {
    // Una password di 8 caratteri minuscoli ha entropia pari a 8 * log2(26)
    expected := 8 * math.Log2(26)
    got := Calculate("abcdefgh")

    // Tolleranza per il confronto tra float
    if math.Abs(got-expected) > 1e-9 {
        t.Errorf("Calculate = %f, want %f", got, expected)
    }
}

func TestClassify(t *testing.T) {
    tests := []struct {
        bits     float64
        expected Strength
    }{
        {20, VeryWeak},
        {30, Weak},
        {50, Reasonable},
        {80, Strong},
        {150, VeryStrong},
    }

    for _, tt := range tests {
        got := Classify(tt.bits)
        if got != tt.expected {
            t.Errorf("Classify(%f) = %v, want %v",
                tt.bits, got, tt.expected)
        }
    }
}

L'uso dei table-driven tests è un pattern idiomatico in Go che consente di testare molti casi con poco codice. Il sotto-test creato con t.Run permette di identificare facilmente quale caso ha fallito quando i test vengono eseguiti.

Limiti e considerazioni

L'approccio descritto presenta alcuni limiti che è importante conoscere. Il calcolo basato sulla formula L * log2(N) assume che la password sia stata generata casualmente con distribuzione uniforme sull'alfabeto. In realtà, le password scelte dagli utenti seguono pattern prevedibili: parole del dizionario, sostituzioni comuni come a → @, sequenze di tastiera, date, nomi propri. Una password come P@ssw0rd123 ha un'entropia teorica elevata, ma è banale da indovinare per qualsiasi attaccante che usi una wordlist con regole di mutazione.

Per stime più realistiche è opportuno integrare strumenti come la libreria zxcvbn (disponibile anche in port Go), che valuta la password confrontandola con dizionari di parole comuni, pattern di tastiera e leak noti. L'entropia di Shannon classica resta tuttavia un buon punto di partenza per stabilire un limite superiore alla robustezza e per imporre policy minime di lunghezza e composizione.

Un'ulteriore considerazione riguarda la sicurezza dell'analisi stessa: se il calcolo dell'entropia viene eseguito lato client e usato per validare la password prima dell'invio, occorre comunque ripetere la verifica lato server, perché qualsiasi controllo client-side può essere bypassato. Inoltre, è buona norma evitare di registrare la password in plaintext in qualsiasi log, anche temporaneo, durante la fase di analisi.

Conclusioni

Calcolare l'entropia di una password in Go è un'operazione relativamente semplice, ma richiede attenzione ai dettagli: gestione corretta delle rune Unicode, uso accurato dei tipi numerici, considerazione dei limiti di rappresentazione dei tipi temporali. Il package mostrato in questo articolo offre un punto di partenza solido che può essere esteso con classificazioni più sofisticate, integrazione con dizionari di parole comuni o esposizione tramite API HTTP. Combinando questi strumenti con policy di sicurezza adeguate (lunghezza minima, hashing robusto come bcrypt o Argon2, autenticazione a due fattori) è possibile costruire sistemi di autenticazione resilienti agli attacchi più comuni.