Calcolare l'entropia di una password in Node.js
L'entropia di una password è una misura matematica che quantifica il grado di imprevedibilità di una stringa segreta. In termini pratici, rappresenta la quantità di informazione, espressa in bit, che un attaccante dovrebbe indovinare per ricostruire la password attraverso un attacco a forza bruta. Maggiore è il valore dell'entropia, maggiore è la resistenza della password contro tentativi di compromissione automatizzati. In questo articolo vedremo come implementare in Node.js diverse strategie per il calcolo dell'entropia, partendo dalla formula classica basata sulla dimensione dell'alfabeto fino ad arrivare a metodi più sofisticati che tengono conto della distribuzione dei caratteri e della presenza di pattern ricorrenti.
La formula matematica di base
La formula più diffusa per il calcolo dell'entropia di una password è quella proposta dal NIST e si basa sul logaritmo in base due della dimensione dell'alfabeto utilizzato, moltiplicato per la lunghezza della password. Se indichiamo con L la lunghezza della password e con R la dimensione del pool di caratteri possibili, l'entropia E in bit si calcola come E = L * log2(R). Il pool di caratteri varia in funzione delle classi presenti: lettere minuscole, lettere maiuscole, cifre numeriche e simboli speciali. Una password composta esclusivamente da lettere minuscole avrà un pool di 26 caratteri, mentre una password che combina tutte le classi raggiunge un pool di circa 94 caratteri stampabili ASCII.
Implementazione iniziale in Node.js
Iniziamo con un'implementazione semplice che analizza la password per determinare quali classi di caratteri sono presenti e calcola l'entropia di conseguenza. Creiamo un modulo dedicato che esporrà una funzione di analisi.
// Modulo per il calcolo dell'entropia di base
const CHARACTER_POOLS = {
lowercase: { regex: /[a-z]/, size: 26 },
uppercase: { regex: /[A-Z]/, size: 26 },
digits: { regex: /[0-9]/, size: 10 },
symbols: { regex: /[^a-zA-Z0-9]/, size: 32 }
};
function calculatePoolSize(password) {
// Calcola la dimensione del pool in base alle classi rilevate
let poolSize = 0;
for (const key of Object.keys(CHARACTER_POOLS)) {
const pool = CHARACTER_POOLS[key];
if (pool.regex.test(password)) {
poolSize += pool.size;
}
}
return poolSize;
}
function calculateBasicEntropy(password) {
if (!password || password.length === 0) {
return 0;
}
const poolSize = calculatePoolSize(password);
// Formula classica: E = L * log2(R)
return password.length * Math.log2(poolSize);
}
module.exports = {
calculatePoolSize,
calculateBasicEntropy
};
La funzione calculatePoolSize attraversa le classi di caratteri definite e somma le dimensioni dei pool corrispondenti alle classi effettivamente presenti nella password. Questo approccio penalizza correttamente le password che utilizzano una sola classe di caratteri, riconoscendo loro un'entropia inferiore rispetto a password della stessa lunghezza ma con maggiore varietà.
Entropia di Shannon basata sulla distribuzione
La formula classica presenta un limite significativo: assume che ogni carattere sia scelto in modo uniforme dal pool, condizione raramente verificata nelle password create dagli utenti. Una password come aaaaaaaa ha la stessa entropia teorica di abcdefgh secondo la formula di base, pur essendo evidentemente molto meno sicura. L'entropia di Shannon affronta questo problema analizzando la distribuzione effettiva dei caratteri.
function calculateShannonEntropy(password) {
if (!password || password.length === 0) {
return 0;
}
const frequencies = new Map();
// Conta le occorrenze di ciascun carattere
for (const char of password) {
frequencies.set(char, (frequencies.get(char) || 0) + 1);
}
let entropy = 0;
const length = password.length;
// Applica la formula di Shannon: H = -sum(p_i * log2(p_i))
for (const count of frequencies.values()) {
const probability = count / length;
entropy -= probability * Math.log2(probability);
}
// L'entropia totale è il prodotto per la lunghezza
return entropy * length;
}
module.exports.calculateShannonEntropy = calculateShannonEntropy;
Questa funzione costruisce una mappa delle frequenze, calcola la probabilità di ciascun carattere come rapporto tra la sua occorrenza e la lunghezza totale della password, e applica la formula di Shannon. Il risultato per carattere viene poi moltiplicato per la lunghezza per ottenere l'entropia complessiva della stringa. Una password con caratteri ripetuti otterrà un valore inferiore rispetto a una con caratteri distinti.
Rilevamento di pattern comuni
Un aspetto cruciale spesso trascurato è la presenza di pattern prevedibili come sequenze di caratteri consecutivi sulla tastiera, ripetizioni o parole presenti in dizionari noti. Implementiamo una funzione che riduce l'entropia stimata in presenza di tali pattern.
const COMMON_PATTERNS = [
/(.)\1{2,}/, // Sequenze di caratteri ripetuti
/(?:0123|1234|2345|3456|4567|5678|6789)/, // Sequenze numeriche
/(?:abcd|bcde|cdef|defg|qwer|wert|asdf|zxcv)/i, // Sequenze tastiera
/(?:password|admin|login|welcome|qwerty)/i // Termini comuni
];
function detectPatterns(password) {
const detected = [];
for (const pattern of COMMON_PATTERNS) {
if (pattern.test(password)) {
detected.push(pattern.source);
}
}
return detected;
}
function applyPatternPenalty(entropy, patterns) {
// Ogni pattern rilevato riduce l'entropia di una percentuale fissa
const penaltyFactor = 0.85;
let adjustedEntropy = entropy;
for (let i = 0; i < patterns.length; i++) {
adjustedEntropy *= penaltyFactor;
}
return adjustedEntropy;
}
module.exports.detectPatterns = detectPatterns;
module.exports.applyPatternPenalty = applyPatternPenalty;
L'array COMMON_PATTERNS contiene espressioni regolari che intercettano i pattern più frequenti nelle password deboli. La funzione applyPatternPenalty applica una riduzione moltiplicativa dell'entropia per ciascun pattern rilevato, simulando il fatto che un attaccante con accesso a dizionari e regole di trasformazione può ridurre drasticamente lo spazio di ricerca.
Classificazione della robustezza
Una volta calcolata l'entropia, è utile fornire all'utente una valutazione qualitativa della robustezza. Le linee guida del NIST suggeriscono soglie di riferimento che possiamo tradurre in categorie comprensibili.
function classifyStrength(entropy) {
// Soglie ispirate alle raccomandazioni del NIST
if (entropy < 28) {
return { level: 'very_weak', description: 'Estremamente vulnerabile' };
}
if (entropy < 36) {
return { level: 'weak', description: 'Debole' };
}
if (entropy < 60) {
return { level: 'reasonable', description: 'Ragionevole' };
}
if (entropy < 128) {
return { level: 'strong', description: 'Forte' };
}
return { level: 'very_strong', description: 'Molto forte' };
}
module.exports.classifyStrength = classifyStrength;
Funzione di analisi completa
Componiamo ora tutte le funzioni precedenti in un'unica interfaccia che restituisce un report dettagliato. Questa rappresenta l'API pubblica del nostro modulo di analisi.
function analyzePassword(password) {
if (typeof password !== 'string') {
throw new TypeError('La password deve essere una stringa');
}
// Calcola le metriche fondamentali
const basicEntropy = calculateBasicEntropy(password);
const shannonEntropy = calculateShannonEntropy(password);
const patterns = detectPatterns(password);
// L'entropia effettiva considera il minimo tra le due metriche
// e applica la penalità per i pattern rilevati
const baseEntropy = Math.min(basicEntropy, shannonEntropy);
const effectiveEntropy = applyPatternPenalty(baseEntropy, patterns);
const strength = classifyStrength(effectiveEntropy);
return {
length: password.length,
poolSize: calculatePoolSize(password),
basicEntropy: Number(basicEntropy.toFixed(2)),
shannonEntropy: Number(shannonEntropy.toFixed(2)),
effectiveEntropy: Number(effectiveEntropy.toFixed(2)),
detectedPatterns: patterns,
strength: strength
};
}
module.exports.analyzePassword = analyzePassword;
La funzione analyzePassword rappresenta il punto di ingresso principale del modulo. Verifica innanzitutto che l'input sia una stringa, poi calcola separatamente l'entropia di base e quella di Shannon, sceglie il valore più conservativo tra i due e applica le penalità per i pattern rilevati. Il risultato è un oggetto strutturato che espone tutte le metriche intermedie, utile sia per l'integrazione in interfacce utente sia per il logging in sistemi di audit.
Stima del tempo di attacco
Convertire l'entropia in una stima del tempo necessario per un attacco a forza bruta rende il dato più tangibile per l'utente finale. Assumendo una velocità di calcolo di un attaccante moderno, possiamo stimare quanti secondi sarebbero necessari per esaurire lo spazio di ricerca.
function estimateCrackTime(entropy, guessesPerSecond = 1e10) {
// Numero totale di tentativi nello spazio di ricerca
const totalGuesses = Math.pow(2, entropy);
// In media, un attacco riesce a metà dello spazio
const averageGuesses = totalGuesses / 2;
const seconds = averageGuesses / guessesPerSecond;
return formatDuration(seconds);
}
function formatDuration(seconds) {
if (seconds < 1) return 'meno di un secondo';
if (seconds < 60) return `${Math.round(seconds)} secondi`;
if (seconds < 3600) return `${Math.round(seconds / 60)} minuti`;
if (seconds < 86400) return `${Math.round(seconds / 3600)} ore`;
if (seconds < 31536000) return `${Math.round(seconds / 86400)} giorni`;
const years = seconds / 31536000;
if (years < 1e6) return `${Math.round(years)} anni`;
// Notazione scientifica per durate enormi
return `${years.toExponential(2)} anni`;
}
module.exports.estimateCrackTime = estimateCrackTime;
Il parametro guessesPerSecond è impostato di default a dieci miliardi al secondo, valore plausibile per un attaccante con hardware GPU dedicato. La funzione formatDuration converte i secondi grezzi in una rappresentazione testuale leggibile, ricorrendo alla notazione esponenziale solo quando la durata supera il milione di anni.
Esempio di utilizzo del modulo
Vediamo un esempio concreto che impiega il modulo appena costruito per analizzare diverse password rappresentative.
const { analyzePassword, estimateCrackTime } = require('./password-entropy');
const samples = [
'password',
'Password1',
'Tr0ub4dor&3',
'correct horse battery staple',
'X9!kLm@2pQr#7vN'
];
for (const sample of samples) {
const report = analyzePassword(sample);
const crackTime = estimateCrackTime(report.effectiveEntropy);
console.log(`Password: ${sample}`);
console.log(` Lunghezza: ${report.length}`);
console.log(` Pool: ${report.poolSize}`);
console.log(` Entropia effettiva: ${report.effectiveEntropy} bit`);
console.log(` Robustezza: ${report.strength.description}`);
console.log(` Tempo stimato: ${crackTime}`);
console.log('---');
}
L'esecuzione di questo script mostra come password apparentemente complesse possano risultare deboli quando contengono pattern rilevabili, mentre passphrase lunghe ma semplici possono raggiungere livelli di entropia molto elevati grazie alla loro estensione.
Integrazione con Express
Per esporre la funzionalità tramite un endpoint HTTP, possiamo integrare il modulo in un'applicazione Express che riceve una password e restituisce il report di analisi in formato JSON.
const express = require('express');
const { analyzePassword, estimateCrackTime } = require('./password-entropy');
const app = express();
app.use(express.json());
app.post('/api/analyze', (request, response) => {
const { password } = request.body;
if (!password) {
return response.status(400).json({
error: 'Il campo password è obbligatorio'
});
}
try {
const report = analyzePassword(password);
const crackTime = estimateCrackTime(report.effectiveEntropy);
// Non restituiamo mai la password originale nella risposta
return response.json({
...report,
estimatedCrackTime: crackTime
});
} catch (error) {
return response.status(500).json({ error: error.message });
}
});
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`Server in ascolto sulla porta ${PORT}`);
});
L'endpoint riceve la password tramite il body della richiesta POST e restituisce il report completo. È fondamentale che la password non venga mai inclusa nella risposta né registrata nei log applicativi, per evitare esposizioni accidentali in caso di compromissione del sistema di logging. In un contesto di produzione si consiglia inoltre di applicare il rate limiting all'endpoint per prevenire abusi e di servire l'API esclusivamente su connessioni TLS.
Considerazioni finali
Il calcolo dell'entropia rappresenta uno strumento prezioso per fornire feedback agli utenti durante la creazione di credenziali, ma non sostituisce le buone pratiche di sicurezza complementari come l'hashing delle password con algoritmi resistenti come bcrypt o Argon2, l'autenticazione a più fattori e la verifica contro liste di password compromesse pubblicamente note. L'implementazione presentata in questo articolo offre una base solida che può essere estesa con dizionari più completi, riconoscimento di trasformazioni leetspeak avanzate e integrazione con servizi esterni come l'API di Have I Been Pwned per controllare se la password è già stata esposta in violazioni di dati. Combinando l'analisi dell'entropia con questi controlli aggiuntivi, è possibile costruire un sistema di validazione delle credenziali realmente efficace e in linea con gli standard di sicurezza moderni.