Node.js include una libreria crittografica completa tramite il modulo integrato crypto. Questo articolo mostra i concetti essenziali e i pattern corretti per usare hashing, HMAC, cifratura simmetrica e crittografia asimmetrica, con esempi pratici e note di sicurezza orientate alla produzione.
Premesse e obiettivi
Nella pratica, “crittografia” copre problemi diversi: riservatezza (cifratura), integrità (hash), autenticità (HMAC o firme), e gestione delle chiavi. Node.js espone sia primitive a basso livello sia API ad alto livello per casi d’uso comuni.
Gli esempi qui sotto usano ECMAScript Modules (ESM) e richiedono una versione moderna
di Node.js. Se usi CommonJS, sostituisci import con require().
import crypto from "node:crypto";
Generazione di numeri casuali e chiavi
La sicurezza dipende spesso dalla qualità della casualità. Evita generatori non crittografici
(come Math.random()) per qualunque cosa riguardi chiavi, token, reset link, nonce e IV.
Usa invece crypto.randomBytes() o crypto.randomUUID().
import crypto from "node:crypto";
// 32 byte = 256 bit (adatto come chiave AES-256)
const key = crypto.randomBytes(32);
// Token per link “one-time” (es. reset password)
const token = crypto.randomBytes(32).toString("base64url");
// UUID v4 (non è un segreto, ma è comodo come identificatore)
const id = crypto.randomUUID();
console.log({ keyLen: key.length, token, id });
Se devi generare un valore numerico casuale in un intervallo, usa crypto.randomInt()
per evitare bias e prevedibilità.
import crypto from "node:crypto";
// intero in [0, 10) quindi 0..9
const n = crypto.randomInt(0, 10);
console.log(n);
Hashing: integrità e fingerprint
Una funzione di hash (es. SHA-256) trasforma un input in un digest fisso. Non è cifratura: non puoi “decifrare” un hash. Gli hash sono utili per: confronto di contenuti, checksum, deduplicazione e fingerprint.
import crypto from "node:crypto";
function sha256Hex(data) {
return crypto.createHash("sha256").update(data).digest("hex");
}
console.log(sha256Hex("hello"));
Se l’input è grande (file, stream), usa l’API a streaming:
import crypto from "node:crypto";
import fs from "node:fs";
const hash = crypto.createHash("sha256");
const stream = fs.createReadStream("video.mp4");
stream.on("data", (chunk) => hash.update(chunk));
stream.on("end", () => {
console.log(hash.digest("hex"));
});
Nota: non usare SHA-1 (obsoleto per collisioni) e diffida di MD5 (rotto per collisioni da anni). Per checksum non crittografici (integrità “casuale” non avversaria) esistono alternative più veloci, ma per scenari con attaccante scegli SHA-256 o superiore.
HMAC: integrità autenticata
Un HMAC combina un hash con una chiave segreta e produce un tag di autenticazione. A differenza di un hash semplice, un attaccante non può produrre un HMAC valido senza la chiave. È utile per firmare webhook, session token “stateless” e messaggi che devono essere verificati.
import crypto from "node:crypto";
const secret = crypto.randomBytes(32);
const payload = JSON.stringify({ userId: "u_123", exp: 1700000000 });
const mac = crypto.createHmac("sha256", secret).update(payload).digest("base64url");
// Verifica: usare timingSafeEqual per ridurre rischi di timing attack
const mac2 = crypto.createHmac("sha256", secret).update(payload).digest();
const ok = crypto.timingSafeEqual(
Buffer.from(mac, "base64url"),
mac2
);
console.log({ mac, ok });
Se confronti stringhe con === rischi micro-differenze di tempo che, in alcuni contesti,
possono essere sfruttate. Per confronti crittografici preferisci timingSafeEqual.
Password: hashing, salting e derivazione
Le password non vanno mai cifrate e “salvate”: vanno derivate con funzioni lente
e resistenti a GPU/ASIC, con un sale unico per utente. In Node.js, per scelta pratica,
sono comuni scrypt e pbkdf2. In molti sistemi moderni si preferiscono Argon2/bcrypt,
ma qui restiamo sulle API native.
Derivazione con scrypt
scrypt è memory-hard: richiede molta memoria oltre al tempo CPU, rendendo più costoso
l’attacco con hardware parallelo.
import crypto from "node:crypto";
function hashPassword(password) {
const salt = crypto.randomBytes(16); // sale unico
const keyLen = 32;
// Parametri esemplificativi: calibrare su macchina target (tempo accettabile lato server)
const derivedKey = crypto.scryptSync(password, salt, keyLen, { N: 2 ** 15, r: 8, p: 1 });
// Conserva salt e derivedKey (non la password). Formato semplice: base64url:salt:hash
return `${salt.toString("base64url")}:${derivedKey.toString("base64url")}`;
}
function verifyPassword(password, stored) {
const [saltB64, hashB64] = stored.split(":");
const salt = Buffer.from(saltB64, "base64url");
const expected = Buffer.from(hashB64, "base64url");
const actual = crypto.scryptSync(password, salt, expected.length, { N: 2 ** 15, r: 8, p: 1 });
return crypto.timingSafeEqual(expected, actual);
}
const stored = hashPassword("p@ssw0rd!");
console.log(verifyPassword("p@ssw0rd!", stored)); // true
console.log(verifyPassword("wrong", stored)); // false
PBKDF2 quando serve compatibilità
pbkdf2 è più diffuso in ambienti legacy e standardizzati. Scegli un numero di iterazioni
adeguato (oggi spesso centinaia di migliaia o più), e un hash robusto (es. SHA-256).
import crypto from "node:crypto";
function pbkdf2Hash(password) {
const salt = crypto.randomBytes(16);
const iterations = 310_000;
const keyLen = 32;
const digest = "sha256";
const dk = crypto.pbkdf2Sync(password, salt, iterations, keyLen, digest);
return `${iterations}:${salt.toString("base64url")}:${dk.toString("base64url")}`;
}
function pbkdf2Verify(password, stored) {
const [itStr, saltB64, dkB64] = stored.split(":");
const iterations = Number(itStr);
const salt = Buffer.from(saltB64, "base64url");
const expected = Buffer.from(dkB64, "base64url");
const actual = crypto.pbkdf2Sync(password, salt, iterations, expected.length, "sha256");
return crypto.timingSafeEqual(expected, actual);
}
Suggerimento operativo: memorizza anche i parametri (iterazioni, N/r/p) insieme all’hash per poterli aggiornare nel tempo e migrare gradualmente gli utenti (rehash al prossimo login).
Cifratura simmetrica (AES-GCM)
Per cifrare dati a riposo o in transito (oltre a TLS), la scelta moderna è un algoritmo AEAD (Authenticated Encryption with Associated Data) come AES-256-GCM o ChaCha20-Poly1305. L’AEAD garantisce riservatezza e integrità: se il ciphertext viene alterato, la decifratura fallisce.
Schema consigliato
- Chiave segreta casuale (es. 32 byte per AES-256).
- Nonce/IV unico per ogni messaggio (tipicamente 12 byte per GCM).
- Tag di autenticazione (GCM produce un tag, spesso 16 byte).
- Opzionale: AAD (dati non cifrati ma autenticati, es. ID record, versione, contesto).
import crypto from "node:crypto";
// Per demo. In produzione: conserva la chiave in un KMS o in un secret manager.
const key = crypto.randomBytes(32); // AES-256
function encryptJson(obj, aadText = "") {
const iv = crypto.randomBytes(12); // 96-bit nonce per GCM
const cipher = crypto.createCipheriv("aes-256-gcm", key, iv);
if (aadText) cipher.setAAD(Buffer.from(aadText, "utf8"));
const plaintext = Buffer.from(JSON.stringify(obj), "utf8");
const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]);
const tag = cipher.getAuthTag(); // 16 byte default
// serializzazione: iv | tag | ciphertext (tutti base64url)
return [
iv.toString("base64url"),
tag.toString("base64url"),
ciphertext.toString("base64url"),
].join(".");
}
function decryptJson(token, aadText = "") {
const [ivB64, tagB64, ctB64] = token.split(".");
const iv = Buffer.from(ivB64, "base64url");
const tag = Buffer.from(tagB64, "base64url");
const ciphertext = Buffer.from(ctB64, "base64url");
const decipher = crypto.createDecipheriv("aes-256-gcm", key, iv);
if (aadText) decipher.setAAD(Buffer.from(aadText, "utf8"));
decipher.setAuthTag(tag);
const plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
return JSON.parse(plaintext.toString("utf8"));
}
const aad = "record:42:v1";
const enc = encryptJson({ hello: "world" }, aad);
const dec = decryptJson(enc, aad);
console.log(enc);
console.log(dec);
Punti critici:
- Mai riutilizzare lo stesso IV con la stessa chiave in GCM: compromette la sicurezza.
- Gestisci correttamente il tag: se manca o è errato,
final()deve fallire. - Se usi AAD, deve essere identico in cifratura e decifratura.
Derivare una chiave da una passphrase
Se l’utente inserisce una passphrase (che non è già una chiave casuale), devi derivare una chiave con KDF (scrypt/pbkdf2) e usare un sale. È preferibile conservare chiavi casuali generate dal sistema, ma a volte serve compatibilità (es. file cifrati con password).
import crypto from "node:crypto";
function deriveKeyFromPassphrase(passphrase, saltB64) {
const salt = saltB64 ? Buffer.from(saltB64, "base64url") : crypto.randomBytes(16);
const key = crypto.scryptSync(passphrase, salt, 32, { N: 2 ** 15, r: 8, p: 1 });
return { key, saltB64: salt.toString("base64url") };
}
Crittografia asimmetrica (RSA/ECC)
Nella crittografia asimmetrica esistono una chiave pubblica e una privata. Tipicamente:
- Si usa la chiave pubblica per cifrare (o verificare firme) e la privata per decifrare (o firmare).
- Si cifrano “direttamente” solo piccoli payload (o chiavi simmetriche), non file interi.
- Spesso si usa un approccio ibrido: cifri i dati con AES e cifri la chiave AES con RSA/ECC.
Generare una coppia di chiavi RSA
import crypto from "node:crypto";
const { publicKey, privateKey } = crypto.generateKeyPairSync("rsa", {
modulusLength: 3072,
publicKeyEncoding: { type: "spki", format: "pem" },
privateKeyEncoding: { type: "pkcs8", format: "pem" },
});
console.log(publicKey);
console.log(privateKey);
Cifrare una chiave simmetrica con RSA-OAEP
RSA moderno usa OAEP (non PKCS#1 v1.5) e un hash robusto. L’idea: generi una chiave AES casuale per il messaggio, cifri il messaggio con AES-GCM e poi cifri la chiave AES con la chiave pubblica del destinatario.
import crypto from "node:crypto";
function wrapKeyWithRSA(publicKeyPem, keyToWrap) {
return crypto.publicEncrypt(
{
key: publicKeyPem,
padding: crypto.constants.RSA_PKCS1_OAEP_PADDING,
oaepHash: "sha256",
},
keyToWrap
);
}
function unwrapKeyWithRSA(privateKeyPem, wrappedKey) {
return crypto.privateDecrypt(
{
key: privateKeyPem,
padding: crypto.constants.RSA_PKCS1_OAEP_PADDING,
oaepHash: "sha256",
},
wrappedKey
);
}
Con ECC, in genere non si “cifra” direttamente come con RSA: si usa ECDH per derivare un segreto condiviso
e poi si cifra simmetricamente. Node.js supporta ECDH con crypto.createECDH().
Firme digitali (RSA/ECDSA)
Le firme digitali permettono di verificare che un messaggio provenga da chi possiede la chiave privata e che non sia stato modificato. A differenza di HMAC, qui la chiave di verifica è pubblica: chiunque può verificare, ma solo il titolare della chiave privata può firmare.
import crypto from "node:crypto";
function signMessage(privateKeyPem, message) {
const sign = crypto.createSign("RSA-SHA256");
sign.update(message);
sign.end();
return sign.sign(privateKeyPem, "base64url");
}
function verifyMessage(publicKeyPem, message, signatureB64) {
const verify = crypto.createVerify("RSA-SHA256");
verify.update(message);
verify.end();
return verify.verify(publicKeyPem, signatureB64, "base64url");
}
In alternativa, puoi usare l’API “one-shot” crypto.sign() e crypto.verify(),
spesso più semplice in servizi REST.
import crypto from "node:crypto";
const msg = Buffer.from("payload da firmare", "utf8");
// Esempio con chiave privata in PEM
const sig = crypto.sign("sha256", msg, privateKeyPem); // Buffer
const ok = crypto.verify("sha256", msg, publicKeyPem, sig);
console.log(ok);
Formati chiave, PEM e gestione sicura
In Node.js incontrerai spesso chiavi in formato PEM. Le chiavi private vanno protette:
- Non committare chiavi private nel repository.
- Preferisci un KMS (Cloud KMS, AWS KMS, Azure Key Vault) o un secret manager.
- Limita i permessi di lettura e ruota le chiavi periodicamente.
- Se devi salvarle su disco, cifra il file e controlla i permessi del filesystem.
Node.js può anche importare/esportare chiavi con crypto.createPublicKey() e crypto.createPrivateKey(),
e lavorare con oggetti KeyObject per evitare di maneggiare stringhe PEM in molte parti del codice.
import crypto from "node:crypto";
const pub = crypto.createPublicKey(publicKeyPem);
const priv = crypto.createPrivateKey(privateKeyPem);
console.log(pub.asymmetricKeyType); // "rsa" o "ec"
console.log(priv.type); // "private"
Errori comuni e checklist
- Confondere hash e cifratura: un hash non si recupera, la cifratura sì (con chiave).
- Riutilizzare IV/nonce con GCM: genera un IV nuovo per ogni cifratura.
- Usare algoritmi obsoleti: evita MD5, SHA-1, DES, RC4.
- Conservare segreti in chiaro: preferisci KMS/secret manager e rotazione.
- Confronti non costanti: usa
timingSafeEqualper MAC e hash sensibili. - Parametri KDF troppo deboli: calibra scrypt/pbkdf2 in base alla tua infrastruttura.
- Inventare protocolli: quando possibile, usa standard consolidati (TLS, JOSE/JWT ben configurati, libs mature).
Conclusione
Con il modulo crypto puoi coprire la maggior parte dei bisogni crittografici di un backend Node.js:
hashing e HMAC per integrità, KDF per password e chiavi derivate, AES-GCM per cifratura simmetrica robusta,
e firme/chiavi asimmetriche per autenticità e scambio sicuro. La differenza tra un’implementazione “che funziona”
e una sicura sta soprattutto nei dettagli: gestione corretta di IV e tag, parametri aggiornabili, e protezione delle chiavi.