Crittografia con Node.js

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:

  1. Mai riutilizzare lo stesso IV con la stessa chiave in GCM: compromette la sicurezza.
  2. Gestisci correttamente il tag: se manca o è errato, final() deve fallire.
  3. 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 timingSafeEqual per 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.

Torna su