Calcolare l'entropia di una password in Python

L'entropia di una password è una misura quantitativa della sua imprevedibilità, espressa in bit. Più alto è il valore di entropia, maggiore è il numero di tentativi che un attaccante deve effettuare per indovinare la password tramite un attacco di forza bruta. Comprendere come calcolare correttamente questo valore è fondamentale per chiunque sviluppi sistemi di autenticazione o voglia valutare la robustezza delle proprie credenziali.

In questo articolo vedremo come calcolare l'entropia di una password in Python, partendo dai fondamenti teorici fino ad arrivare a un'implementazione completa che tenga conto anche dei pattern comuni e delle password presenti nei dizionari di attacco.

Fondamenti teorici dell'entropia

L'entropia di una password viene calcolata utilizzando la formula di Shannon, che nel caso più semplice assume questa forma:

E = log2(R^L)

dove R rappresenta la dimensione dell'alfabeto utilizzato (il pool di caratteri possibili) e L è la lunghezza della password. Questa formula può essere riscritta in modo più conveniente come E = L * log2(R), evitando di calcolare potenze potenzialmente molto grandi.

Per fare un esempio pratico, una password di 8 caratteri composta solo da lettere minuscole ha un alfabeto di 26 caratteri, quindi un'entropia di circa 37,6 bit. La stessa lunghezza con caratteri minuscoli, maiuscoli, cifre e simboli arriva a circa 52,4 bit, mostrando come l'aumento del pool di caratteri impatti significativamente sulla sicurezza.

Determinare il pool di caratteri

Il primo passo per calcolare l'entropia consiste nell'analizzare la password per determinare quali tipi di caratteri contiene. Questo ci permette di stabilire la dimensione effettiva dell'alfabeto da cui la password potrebbe essere stata generata.

import string
import math

def get_charset_size(password: str) -> int:
    # Determina la dimensione del pool di caratteri utilizzato
    charset_size = 0
    
    # Verifica la presenza di lettere minuscole
    if any(c in string.ascii_lowercase for c in password):
        charset_size += 26
    
    # Verifica la presenza di lettere maiuscole
    if any(c in string.ascii_uppercase for c in password):
        charset_size += 26
    
    # Verifica la presenza di cifre numeriche
    if any(c in string.digits for c in password):
        charset_size += 10
    
    # Verifica la presenza di simboli speciali
    if any(c in string.punctuation for c in password):
        charset_size += len(string.punctuation)
    
    # Verifica la presenza di spazi
    if " " in password:
        charset_size += 1
    
    return charset_size

Questa funzione restituisce la dimensione dell'alfabeto basandosi sulle classi di caratteri presenti nella password. È importante notare che stiamo facendo un'assunzione conservativa: se la password contiene anche solo una cifra, consideriamo che l'attaccante debba esplorare tutto lo spazio delle 10 cifre possibili.

Calcolo dell'entropia di base

Una volta determinata la dimensione del pool, il calcolo dell'entropia è immediato. Implementiamo una funzione che combina i due passaggi:

def calculate_basic_entropy(password: str) -> float:
    # Calcolo dell'entropia secondo la formula di Shannon
    if not password:
        return 0.0
    
    charset_size = get_charset_size(password)
    
    if charset_size == 0:
        return 0.0
    
    # Formula: E = L * log2(R)
    entropy = len(password) * math.log2(charset_size)
    
    return entropy

Possiamo testare questa funzione con alcuni esempi rappresentativi:

test_passwords = [
    "password",
    "Password1",
    "P@ssw0rd!",
    "x7K#mQ9$vL2p"
]

for pwd in test_passwords:
    entropy = calculate_basic_entropy(pwd)
    print(f"Password: {pwd:20} Entropia: {entropy:.2f} bit")

Classificazione della robustezza

Conoscere il valore numerico dell'entropia è utile, ma per un utente finale è più significativo ricevere una valutazione qualitativa. Possiamo definire delle soglie basate sulle raccomandazioni del NIST e della letteratura sulla sicurezza informatica:

from enum import Enum

class PasswordStrength(Enum):
    VERY_WEAK = "Molto debole"
    WEAK = "Debole"
    MODERATE = "Moderata"
    STRONG = "Forte"
    VERY_STRONG = "Molto forte"

def classify_strength(entropy: float) -> PasswordStrength:
    # Classifica la robustezza in base ai bit di entropia
    if entropy < 28:
        return PasswordStrength.VERY_WEAK
    elif entropy < 36:
        return PasswordStrength.WEAK
    elif entropy < 60:
        return PasswordStrength.MODERATE
    elif entropy < 128:
        return PasswordStrength.STRONG
    else:
        return PasswordStrength.VERY_STRONG

Le soglie scelte riflettono uno scenario in cui un attaccante ha accesso a hardware moderno: sotto i 28 bit la password può essere violata istantaneamente, mentre sopra i 60 bit offre una protezione ragionevole contro attacchi offline su hash ben progettati.

Il problema dei pattern e dei dizionari

Il calcolo basato esclusivamente sul pool di caratteri presenta una limitazione importante: tratta tutte le password con la stessa composizione come equivalenti. Tuttavia, una password come password e una come kj4nx9pq hanno la stessa entropia teorica ma una robustezza pratica radicalmente diversa, perché la prima è presente in qualunque dizionario di attacco.

Per ottenere una valutazione più realistica dobbiamo penalizzare le password che presentano pattern prevedibili o che corrispondono a parole comuni. Implementiamo un sistema di rilevamento dei pattern più frequenti:

import re

def detect_patterns(password: str) -> list[str]:
    # Identifica pattern comuni che riducono l'entropia effettiva
    patterns_found = []
    
    # Sequenze numeriche crescenti o decrescenti
    if re.search(r"(?:0123|1234|2345|3456|4567|5678|6789)", password):
        patterns_found.append("sequenza_numerica")
    
    # Sequenze alfabetiche
    lower_password = password.lower()
    alphabet = "abcdefghijklmnopqrstuvwxyz"
    for i in range(len(alphabet) - 3):
        if alphabet[i:i+4] in lower_password:
            patterns_found.append("sequenza_alfabetica")
            break
    
    # Caratteri ripetuti consecutivamente
    if re.search(r"(.)\1{2,}", password):
        patterns_found.append("caratteri_ripetuti")
    
    # Pattern di tastiera (qwerty, asdf)
    keyboard_patterns = ["qwerty", "asdf", "zxcv", "qaz", "wsx"]
    for kp in keyboard_patterns:
        if kp in lower_password:
            patterns_found.append("pattern_tastiera")
            break
    
    # Anno a 4 cifre (tipicamente data di nascita o evento)
    if re.search(r"(?:19|20)\d{2}", password):
        patterns_found.append("anno")
    
    return patterns_found

Verifica contro dizionari di password comuni

Oltre ai pattern, è utile verificare se la password (o una sua componente) è presente in liste di password compromesse. La lista rockyou e simili contengono milioni di password trapelate da data breach reali e rappresentano il punto di partenza di qualunque attacco serio.

def load_common_passwords(filepath: str) -> set[str]:
    # Carica un set di password comuni da un file
    common = set()
    try:
        with open(filepath, "r", encoding="utf-8", errors="ignore") as f:
            for line in f:
                cleaned = line.strip().lower()
                if cleaned:
                    common.add(cleaned)
    except FileNotFoundError:
        # Fallback con un piccolo set di esempio
        common = {
            "password", "123456", "qwerty", "letmein",
            "admin", "welcome", "monkey", "dragon",
            "master", "iloveyou", "sunshine", "princess"
        }
    return common

def is_common_password(password: str, common_set: set[str]) -> bool:
    # Verifica diretta e con varianti comuni (leet speak base)
    lower_pwd = password.lower()
    
    if lower_pwd in common_set:
        return True
    
    # Inversione delle sostituzioni leet più frequenti
    leet_map = str.maketrans("01345!@$", "oleas ia ")
    normalized = lower_pwd.translate(leet_map)
    
    return normalized in common_set

Calcolo dell'entropia effettiva

Mettiamo ora insieme tutti i pezzi per ottenere una funzione che fornisca una stima realistica dell'entropia, applicando penalità per pattern e password comuni:

def calculate_effective_entropy(
    password: str,
    common_passwords: set[str] | None = None
) -> dict:
    # Calcolo completo con penalità per pattern e password comuni
    if not password:
        return {
            "password": password,
            "length": 0,
            "base_entropy": 0.0,
            "effective_entropy": 0.0,
            "patterns": [],
            "is_common": False,
            "strength": PasswordStrength.VERY_WEAK
        }
    
    base_entropy = calculate_basic_entropy(password)
    patterns = detect_patterns(password)
    
    is_common = False
    if common_passwords is not None:
        is_common = is_common_password(password, common_passwords)
    
    # Applicazione delle penalità
    effective = base_entropy
    
    # Ogni pattern riduce l'entropia di una quantità fissa
    penalty_per_pattern = 8.0
    effective -= len(patterns) * penalty_per_pattern
    
    # Le password comuni vengono pesantemente penalizzate
    if is_common:
        effective = min(effective, 10.0)
    
    # L'entropia non può essere negativa
    effective = max(effective, 0.0)
    
    return {
        "password": password,
        "length": len(password),
        "base_entropy": round(base_entropy, 2),
        "effective_entropy": round(effective, 2),
        "patterns": patterns,
        "is_common": is_common,
        "strength": classify_strength(effective)
    }

Le penalità applicate sono volutamente conservative: ogni pattern rilevato sottrae 8 bit di entropia, valore che corrisponde indicativamente al fattore di riduzione che un attaccante esperto otterrebbe esplorando per primi gli spazi più probabili. Per le password comuni la penalità è molto più severa, perché in quel caso l'attacco si riduce a una semplice ricerca in una lista preordinata.

Stima del tempo di violazione

Un valore di entropia in bit può risultare astratto. Convertirlo in un tempo stimato di violazione lo rende immediatamente comprensibile. La conversione richiede un'assunzione sulla velocità dell'attaccante, espressa in tentativi al secondo:

def estimate_crack_time(entropy: float, attempts_per_second: float = 1e10) -> str:
    # Stima il tempo medio di violazione dato un certo throughput
    # 10^10 tentativi/secondo corrisponde a una GPU moderna su hash veloci
    
    if entropy <= 0:
        return "istantaneo"
    
    # Numero medio di tentativi necessari (metà dello spazio di ricerca)
    total_attempts = (2 ** entropy) / 2
    seconds = total_attempts / attempts_per_second
    
    # Conversione in unità leggibili
    if seconds < 1:
        return "meno di un secondo"
    if seconds < 60:
        return f"{seconds:.1f} secondi"
    if seconds < 3600:
        return f"{seconds / 60:.1f} minuti"
    if seconds < 86400:
        return f"{seconds / 3600:.1f} ore"
    if seconds < 31536000:
        return f"{seconds / 86400:.1f} giorni"
    
    years = seconds / 31536000
    if years < 1e6:
        return f"{years:.1f} anni"
    if years < 1e9:
        return f"{years / 1e6:.1f} milioni di anni"
    
    return f"{years / 1e9:.2e} miliardi di anni"

Il valore predefinito di 10 miliardi di tentativi al secondo riflette le capacità di una GPU di fascia alta su algoritmi di hashing veloci come MD5 o SHA-1. Per algoritmi progettati per essere lenti, come bcrypt o Argon2, il throughput scende a poche migliaia di tentativi al secondo, aumentando drasticamente il tempo necessario.

Esempio completo di utilizzo

Vediamo come integrare tutti i componenti in un'applicazione di esempio che analizza un insieme di password e produce un report dettagliato:

def analyze_password(password: str, common_set: set[str] | None = None) -> None:
    # Stampa un report completo per una singola password
    result = calculate_effective_entropy(password, common_set)
    crack_time = estimate_crack_time(result["effective_entropy"])
    
    print(f"\nPassword: {result['password']}")
    print(f"Lunghezza: {result['length']} caratteri")
    print(f"Entropia di base: {result['base_entropy']} bit")
    print(f"Entropia effettiva: {result['effective_entropy']} bit")
    print(f"Pattern rilevati: {result['patterns'] or 'nessuno'}")
    print(f"Password comune: {'sì' if result['is_common'] else 'no'}")
    print(f"Robustezza: {result['strength'].value}")
    print(f"Tempo stimato di violazione: {crack_time}")


if __name__ == "__main__":
    common_passwords = load_common_passwords("common_passwords.txt")
    
    samples = [
        "password",
        "P@ssw0rd2024",
        "correct horse battery staple",
        "x7K#mQ9$vL2pZ4nB",
        "qwerty123"
    ]
    
    for sample in samples:
        analyze_password(sample, common_passwords)

Eseguendo questo codice si ottiene un output che evidenzia chiaramente le differenze tra le varie strategie di composizione. La passphrase correct horse battery staple, resa celebre da una vignetta di xkcd, mostra come la lunghezza possa compensare la presenza di parole di dizionario, raggiungendo livelli di entropia eccellenti pur essendo memorizzabile.

Considerazioni sulla precisione del modello

Il sistema che abbiamo costruito fornisce una stima ragionevole, ma è importante riconoscerne i limiti. Strumenti più sofisticati come zxcvbn di Dropbox utilizzano modelli statistici basati su corpus reali di password, riconoscono date, nomi propri, sostituzioni leet complesse e calcolano l'entropia in base al pattern che minimizza il numero di tentativi necessari. Per applicazioni di produzione vale la pena considerare l'integrazione di queste librerie specializzate.

Va inoltre ricordato che l'entropia è solo una delle metriche da considerare. La vera sicurezza dipende anche dall'algoritmo di hashing utilizzato sul server, dalla presenza di sale, dall'implementazione di meccanismi anti-bruteforce e dall'autenticazione a più fattori. Una password con 80 bit di entropia su un servizio che memorizza le credenziali in chiaro offre ben poca protezione, mentre una passphrase più semplice protetta da Argon2 e 2FA risulta praticamente inviolabile.

Conclusione

Calcolare l'entropia di una password in Python è un esercizio che combina concetti di teoria dell'informazione con considerazioni pratiche di sicurezza. La formula di Shannon fornisce il punto di partenza, ma una valutazione realistica richiede di tenere conto dei pattern comuni e delle password presenti nei dizionari di attacco. Il codice presentato in questo articolo offre una base solida che può essere estesa con tecniche più avanzate o integrata in sistemi di validazione delle credenziali. Comprendere questi meccanismi non serve solo a sviluppare software più sicuro, ma anche a fare scelte informate quando si creano le proprie password.