La crittografia a curva ellittica (ECC) permette di raggiungere 128 bit di sicurezza con una chiave da 256 bit, mentre RSA richiede 3.072 bit per lo stesso livello di protezione. Meno dati, meno CPU, stessa solidità crittografica: questo spiega perché ECC è lo standard scelto da TLS 1.3, SSH, Signal e Bitcoin. In questa guida percorri 12 step pratici in Node.js: dalla generazione di chiavi P-256, allo scambio ECDH, alla derivazione con HKDF, fino alla firma ECDSA e a un progetto completo di messaggistica cifrata.
Prerequisiti
- Node.js v20.0.0 o superiore (consigliato: v22 LTS o v26): tutte le API usate in questa guida sono native nel modulo
node:crypto, senza dipendenze esterne - npm v9+ per inizializzare il progetto
- Conoscenza base di JavaScript asincrono e dei tipi
Buffer - Terminale con accesso a
openssl(preinstallato su macOS e Linux; su Windows usa WSL o Git Bash) - Facoltativo:
Node.js v26.3.0per accedere alle API più recenti come il formatoraw-publicper le chiavi EC
Verifica la versione con node -v. Se usi Node.js 18, le funzioni hkdfSync e generateKeyPairSync con ec sono già disponibili.
Cos’è la crittografia a curva ellittica
Una curva ellittica è l’insieme dei punti che soddisfano l’equazione y² = x³ + ax + b su un campo finito. Il segreto crittografico sta nel problema del logaritmo discreto su curve ellittiche (ECDLP): dato un punto base G e un punto pubblico Q = kG, risalire allo scalare intero k (la chiave privata) è computazionalmente infeasibile per curve ben scelte.
Nella pratica questo significa due operazioni fondamentali:
- ECDH (Elliptic Curve Diffie-Hellman): Alice e Bob si scambiano le rispettive chiavi pubbliche. Ognuno moltiplica la propria chiave privata per la chiave pubblica dell’altro e ottiene lo stesso punto condiviso, che diventa il segreto comune.
- ECDSA (Elliptic Curve Digital Signature Algorithm): la chiave privata genera una firma che chiunque può verificare usando la chiave pubblica, senza conoscere la chiave privata.
Secondo il documento NIST SP 800-186, la sicurezza di una curva a k bit è circa k/2 bit: una curva P-256 offre 128 bit di sicurezza. Lo stesso livello richiede una chiave RSA da 3.072 bit. In termini di velocità, le operazioni ECC su P-256 risultano significativamente più rapide delle operazioni RSA equivalenti, con un consumo di memoria nettamente inferiore: un elemento determinante per dispositivi IoT, app mobili e server ad alto traffico.
Le applicazioni reali che usano ECC includono: TLS 1.3 (scambio di chiavi ECDHE con P-256 o X25519), SSH con chiavi Ed25519, JWT firmati ES256, certificati HTTPS ECDSA, e Bitcoin (secp256k1 per le firme delle transazioni).
Confronto tra le principali curve ellittiche
Non tutte le curve ellittiche sono equivalenti per sicurezza o interoperabilità. La tabella seguente riassume le curve più rilevanti per Node.js, con i rispettivi livelli di sicurezza e usi tipici.
| Curva | Nome OpenSSL | Sicurezza (bit) | Uso principale | Standard | Supporto Node.js |
|---|---|---|---|---|---|
| P-256 | prime256v1 | 128 | ECDH + ECDSA, TLS, JWT ES256 | NIST FIPS 186-5 | Nativo |
| P-384 | secp384r1 | 192 | ECDH + ECDSA, alta sicurezza | NIST FIPS 186-5 | Nativo |
| P-521 | secp521r1 | 256 | ECDH + ECDSA, sicurezza massima | NIST FIPS 186-5 | Nativo |
| secp256k1 | secp256k1 | 128 | Bitcoin, Ethereum | SEC 2 | Nativo |
| Ed25519 | ed25519 | 128 | Firma EdDSA deterministica, SSH | RFC 8032 | Nativo (v15+) |
| X25519 | x25519 | 128 | Solo ECDH, TLS 1.3 | RFC 7748 | Nativo (v17+) |
Per la maggior parte delle applicazioni Node.js P-256 è la scelta di default: è supportata da tutti i browser, dai provider TLS e dagli standard JWT. Se hai requisiti di sicurezza elevata (conformità FIPS a 192+ bit), usa P-384. Per le firme SSH prefer Ed25519: la generazione del nonce è deterministica e non soffre dei problemi di casualità che affliggono ECDSA.
Step 1: Generare una coppia di chiavi EC P-256
Il modulo node:crypto espone generateKeyPairSync e il suo equivalente asincrono generateKeyPair. Il parametro namedCurve accetta il nome OpenSSL della curva. Per P-256 il nome è 'prime256v1' (alias: 'secp256r1').
// step1-keygen.js
const { generateKeyPairSync, createHash } = require('node:crypto');
const { publicKey, privateKey } = generateKeyPairSync('ec', {
namedCurve: 'prime256v1', // P-256, 128-bit di sicurezza
publicKeyEncoding: { type: 'spki', format: 'pem' },
privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
});
console.log('=== CHIAVE PUBBLICA (SPKI PEM) ===');
console.log(publicKey);
console.log('=== CHIAVE PRIVATA (PKCS8 PEM) ===');
console.log(privateKey);
// Fingerprint SHA-256 della chiave pubblica
const fp = createHash('sha256').update(publicKey).digest('hex');
console.log('Fingerprint:', fp.match(/.{2}/g).join(':'));
Output atteso:
=== CHIAVE PUBBLICA (SPKI PEM) ===
-----BEGIN PUBLIC KEY-----
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEk3...
-----END PUBLIC KEY-----
=== CHIAVE PRIVATA (PKCS8 PEM) ===
-----BEGIN PRIVATE KEY-----
MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0w...
-----END PRIVATE KEY-----
Fingerprint: 3a:9f:c1:04:77:b2:...
Perché SPKI e PKCS8? Il formato spki (SubjectPublicKeyInfo) è lo standard X.509 per le chiavi pubbliche, compatibile con OpenSSL, Java e i browser. Il formato pkcs8 è la rappresentazione standard per le chiavi private EC e RSA. Se hai bisogno del formato raw per protocolli personalizzati, usa format: 'jwk' per ottenere un JSON Web Key direttamente.
Step 2: Salvare e ricaricare le chiavi da disco
In produzione le chiavi devono essere persistenti. Questo step mostra come scrivere le chiavi PEM su file, proteggere la chiave privata con una passphrase AES-256-CBC, e ricaricarle al successivo avvio del server.
// step2-keys-io.js
const { generateKeyPairSync, createPrivateKey, createPublicKey } = require('node:crypto');
const { writeFileSync, readFileSync, mkdirSync } = require('node:fs');
mkdirSync('./keys', { recursive: true });
// Genera con passphrase: la chiave privata è cifrata a riposo
const { publicKey, privateKey } = generateKeyPairSync('ec', {
namedCurve: 'prime256v1',
publicKeyEncoding: { type: 'spki', format: 'pem' },
privateKeyEncoding: {
type: 'pkcs8',
format: 'pem',
cipher: 'aes-256-cbc', // cifra la chiave privata su disco
passphrase: process.env.KEY_PASS || 'cambiamiInProduzione!',
},
});
writeFileSync('./keys/ec-public.pem', publicKey, { mode: 0o644 });
writeFileSync('./keys/ec-private.pem', privateKey, { mode: 0o600 });
console.log('Chiavi salvate in ./keys/');
// Ricarica le chiavi
const rawPriv = readFileSync('./keys/ec-private.pem', 'utf8');
const rawPub = readFileSync('./keys/ec-public.pem', 'utf8');
const loadedPriv = createPrivateKey({
key: rawPriv,
passphrase: process.env.KEY_PASS || 'cambiamiInProduzione!',
});
const loadedPub = createPublicKey(rawPub);
console.log('Tipo chiave privata:', loadedPriv.asymmetricKeyType); // ec
console.log('Tipo chiave pubblica:', loadedPub.asymmetricKeyType); // ec
Le autorizzazioni 0o600 impediscono la lettura della chiave privata da altri utenti Unix. In un container Docker, monta la chiave come secret (ad esempio con Docker Swarm Secrets o Kubernetes Secrets) anziché integrarla nell’immagine.
Step 3: Scambio di chiavi ECDH
ECDH (Elliptic Curve Diffie-Hellman) permette a due parti di concordare un segreto condiviso su un canale non cifrato, senza mai trasmettere la chiave privata. Ogni parte genera un key pair effimero, scambia la chiave pubblica, e calcola lo stesso segreto moltiplicando la propria chiave privata per la chiave pubblica dell’altro.
// step3-ecdh.js
const { createECDH } = require('node:crypto');
// Simulazione: Alice e Bob su macchine diverse
const alice = createECDH('prime256v1');
const bob = createECDH('prime256v1');
// Ogni parte genera il proprio key pair effimero
alice.generateKeys();
bob.generateKeys();
// Scambio pubblico dei punti (non segreto, può avvenire in chiaro)
const alicePubKey = alice.getPublicKey(); // Buffer (65 byte, non compresso)
const bobPubKey = bob.getPublicKey();
// Calcolo segreto condiviso (lato Alice)
const aliceSecret = alice.computeSecret(bobPubKey);
// Calcolo segreto condiviso (lato Bob)
const bobSecret = bob.computeSecret(alicePubKey);
// I segreti DEVONO essere identici
console.log('Segreti identici:', aliceSecret.equals(bobSecret)); // true
console.log('Lunghezza segreto:', aliceSecret.length, 'byte'); // 32 byte per P-256
console.log('Segreto (hex):', aliceSecret.toString('hex'));
Il segreto ECDH è il punto X della moltiplicazione scalare. Per P-256 è sempre esattamente 32 byte. Non usare questo segreto direttamente come chiave AES: il segreto ECDH ha una distribuzione non uniforme e potrebbe avere bias crittografici. Devi sempre passarlo attraverso una funzione di derivazione chiavi (KDF).
Step 4: Derivare una chiave AES con HKDF
HKDF (HMAC-based Key Derivation Function, RFC 5869) trasforma il segreto ECDH in una o più chiavi crittograficamente uniformi. Node.js v15+ include hkdfSync e la variante asincrona hkdf nel modulo node:crypto.
// step4-hkdf.js
const { createECDH, hkdfSync } = require('node:crypto');
const alice = createECDH('prime256v1');
const bob = createECDH('prime256v1');
alice.generateKeys();
bob.generateKeys();
const sharedSecret = alice.computeSecret(bob.getPublicKey());
// HKDF: hash, ikm (input key material), salt, info, lunghezza output
const aesKeyBuffer = hkdfSync(
'sha256', // algoritmo hash
sharedSecret, // IKM: segreto ECDH grezzo
'', // salt: stringa vuota (o un nonce pubblico concordato)
'aes-key-v1', // info: stringa di contesto per separare usi diversi
32 // lunghezza output: 32 byte = 256 bit per AES-256
);
const aesKey = Buffer.from(aesKeyBuffer);
console.log('Chiave AES-256 derivata:', aesKey.toString('hex')); // 64 char hex
// Per derivare anche una chiave MAC separata:
const macKeyBuffer = hkdfSync('sha256', sharedSecret, '', 'mac-key-v1', 32);
const macKey = Buffer.from(macKeyBuffer);
Il parametro info lega la chiave derivata al contesto specifico, impedendo il riutilizzo della stessa chiave in contesti diversi (ad esempio, separare la chiave di cifratura da quella MAC). Cambia il valore di info per ogni scopo: 'encrypt-v1', 'mac-v1', 'auth-v1'.
Step 5-6: Cifrare e decifrare con AES-256-GCM
AES-256-GCM è una modalità di cifratura autenticata (AEAD): garantisce sia la riservatezza (nessuno legge il messaggio) sia l’integrità (nessuno modifica il messaggio senza che te ne accorga). È la combinazione consigliata con ECDH per implementare la crittografia end-to-end.
// step5-6-aes-gcm.js
const { createCipheriv, createDecipheriv, randomBytes, hkdfSync, createECDH } = require('node:crypto');
// Setup: chiave AES derivata da ECDH (vedi Step 3-4)
const alice = createECDH('prime256v1');
const bob = createECDH('prime256v1');
alice.generateKeys();
bob.generateKeys();
const sharedSecret = alice.computeSecret(bob.getPublicKey());
const aesKey = Buffer.from(hkdfSync('sha256', sharedSecret, '', 'encrypt-v1', 32));
// --- CIFRATURA ---
function cifra(chiave, testo) {
const iv = randomBytes(12); // 96 bit: la dimensione corretta per AES-GCM
const cipher = createCipheriv('aes-256-gcm', chiave, iv);
const datiCifrati = Buffer.concat([
cipher.update(testo, 'utf8'),
cipher.final(),
]);
const authTag = cipher.getAuthTag(); // 16 byte: il tag di autenticazione
// Combina iv + authTag + testo cifrato per la trasmissione
return Buffer.concat([iv, authTag, datiCifrati]).toString('base64');
}
// --- DECIFRATURA ---
function decifra(chiave, payloadBase64) {
const payload = Buffer.from(payloadBase64, 'base64');
const iv = payload.subarray(0, 12);
const authTag = payload.subarray(12, 28);
const cifrato = payload.subarray(28);
const decipher = createDecipheriv('aes-256-gcm', chiave, iv);
decipher.setAuthTag(authTag);
return Buffer.concat([
decipher.update(cifrato),
decipher.final(), // lancia un errore se il tag non corrisponde
]).toString('utf8');
}
const messaggio = 'Messaggio segreto: deploy alle 03:00';
const cifrato = cifra(aesKey, messaggio);
const decifrato = decifra(aesKey, cifrato);
console.log('Cifrato (base64):', cifrato);
console.log('Decifrato:', decifrato);
console.log('Uguale:', messaggio === decifrato); // true
Il campo authTag (16 byte) è la firma crittografica del messaggio cifrato. Se un attaccante modifica anche un solo bit del testo cifrato, decipher.final() lancia un’eccezione ERR_CRYPTO_GCM_AUTH_FAILED, che devi gestire lato server per respingere il messaggio corrotto.
Step 7-8: Firma e verifica ECDSA
ECDSA permette di provare la provenienza di un dato: solo chi possiede la chiave privata può generare una firma valida, ma chiunque può verificarla con la chiave pubblica. Il modulo node:crypto espone sign e verify che accettano direttamente oggetti chiave PEM o KeyObject.
// step7-8-ecdsa.js
const { generateKeyPairSync, sign, verify } = require('node:crypto');
// Genera chiavi per l'identità (non effimere: queste restano a lungo termine)
const { publicKey, privateKey } = generateKeyPairSync('ec', {
namedCurve: 'prime256v1',
publicKeyEncoding: { type: 'spki', format: 'pem' },
privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
});
const documento = Buffer.from(JSON.stringify({
messaggio: 'Trasferisci 1000 EUR a Mario Rossi',
timestamp: Date.now(),
}));
// Step 7 - FIRMA
// sign(hash, data, privateKey): restituisce un Buffer DER con la firma
const firma = sign('sha256', documento, privateKey);
console.log('Firma (hex):', firma.toString('hex'));
console.log('Lunghezza firma:', firma.length, 'byte'); // DER: variabile, ~70-72 byte per P-256
// Step 8 - VERIFICA
const valida = verify('sha256', documento, publicKey, firma);
console.log('Firma valida:', valida); // true
// Tentativo di verifica con dato alterato
const documentoAlterato = Buffer.from('dato diverso');
const firmaInvalida = verify('sha256', documentoAlterato, publicKey, firma);
console.log('Firma su dato alterato:', firmaInvalida); // false
La firma ECDSA è codificata in formato DER (Distinguished Encoding Rules) e ha una lunghezza variabile: per P-256 è in genere tra 70 e 72 byte. Se hai bisogno del formato IEEE P1363 (firma a lunghezza fissa, usata nei JWT ES256), usa la libreria node-jose o converti manualmente i componenti r e s.
Confronto ECDSA vs EdDSA
| Caratteristica | ECDSA (P-256) | EdDSA (Ed25519) |
|---|---|---|
| Sicurezza (bit) | 128 | 128 |
| Nonce | Casuale (critico) | Deterministico (RFC 8032) |
| Rischio nonce reuse | Critico (espone la privkey) | Nessuno (nonce calcolato) |
| Lunghezza firma | 70-72 byte DER | 64 byte fissi |
| Standard | FIPS 186-5 | RFC 8032 |
| Supporto JWT | ES256, ES384, ES512 | EdDSA |
| Supporto SSH | ecdsa-sha2-nistp256 | ssh-ed25519 (preferito) |
Per nuovi sistemi dove non esistono vincoli di interoperabilità con ECDSA, preferisci Ed25519: il nonce deterministico elimina il rischio di compromissione accidentale della chiave privata dovuta a un generatore di numeri casuali difettoso.
Step 9: Calcolare il fingerprint di una chiave pubblica
Il fingerprint è un hash breve della chiave pubblica che ne permette l’identificazione e la verifica out-of-band (ad esempio per confronto telefonico o via QR code). È la tecnica usata da SSH, Signal e GPG per il modello Trust On First Use (TOFU).
// step9-fingerprint.js
const { generateKeyPairSync, createHash } = require('node:crypto');
const { publicKey } = generateKeyPairSync('ec', {
namedCurve: 'prime256v1',
publicKeyEncoding: { type: 'spki', format: 'pem' },
privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
});
// Fingerprint SHA-256 della chiave pubblica DER
function fingerprint(pubKeyPem) {
const keyObj = require('node:crypto').createPublicKey(pubKeyPem);
const der = keyObj.export({ type: 'spki', format: 'der' });
const hash = createHash('sha256').update(der).digest('hex');
return hash.match(/.{2}/g).join(':');
}
const fp = fingerprint(publicKey);
console.log('Fingerprint SHA-256:');
console.log(fp);
// Esempio: 3a:9f:c1:04:77:b2:e9:15:d4:fa:88:23:ab:cd:ef:01:...
// Verifica manuale: confronta questo valore con quello del peer
// tramite un canale sicuro (telefono, QR code, Signal)
Usare il DER (formato binario) anziché il PEM (stringa base64 con intestazioni) garantisce che il fingerprint non cambi se il PEM viene riformattato con interruzioni di riga diverse. Il valore esadecimale con separatori : è la rappresentazione standard adottata da OpenSSH.
Step 10-11: Gestione sicura delle chiavi in produzione
Dove non conservare le chiavi private
- Mai in variabili d’ambiente non cifrate: i file
.envin chiaro finiscono spesso nei repository Git. - Mai nel codice sorgente: ogni commit storico espone la chiave.
- Mai nei log di sistema: aggiungi sempre filtri per evitare che le chiavi finiscano in stdout.
- Mai in database senza cifratura a riposo: usa la cifratura con passphrase AES-256-CBC come mostrato nello Step 2.
Strategie consigliate per Node.js in produzione
- Variabili d’ambiente da vault: usa HashiCorp Vault, AWS Secrets Manager, o Azure Key Vault per iniettare la passphrase al boot del container.
- File system cifrati: monta le chiavi su volumi LUKS (vedi la guida LUKS su Ubuntu) o con tmpfs in-memory.
- Rotazione periodica: ruota le chiavi ECDH ogni sessione (chiavi effimere) e le chiavi ECDSA di identità ogni 12-24 mesi.
- Hardware Security Module (HSM): per applicazioni ad alta criticità, l’operazione di firma avviene dentro l’HSM e la chiave privata non lascia mai il dispositivo.
Step 12: Progetto completo – sistema di messaggistica sicura
Questo step mette insieme tutti i componenti precedenti in un modulo autonomo SecureMessenger: ogni istanza ha una coppia di chiavi di identità ECDSA (persistente) e scambia messaggi usando ECDH + HKDF + AES-256-GCM con un partner.
// secure-messenger.js
const {
generateKeyPairSync, createECDH, hkdfSync,
createCipheriv, createDecipheriv,
sign, verify, createHash, randomBytes,
} = require('node:crypto');
class SecureMessenger {
constructor(nome) {
this.nome = nome;
// Chiave di identità ECDSA (firma, lunga durata)
const { publicKey, privateKey } = generateKeyPairSync('ec', {
namedCurve: 'prime256v1',
publicKeyEncoding: { type: 'spki', format: 'pem' },
privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
});
this.identPub = publicKey;
this.identPriv = privateKey;
// Coppia ECDH effimera per questa sessione
this.ecdh = createECDH('prime256v1');
this.ecdh.generateKeys();
this.sessionKey = null;
}
// Restituisce la chiave ECDH pubblica da inviare al partner
getSessionPublicKey() {
return this.ecdh.getPublicKey('base64');
}
// Stabilisce la chiave di sessione dal punto ECDH del partner
stabilisciSessione(peerEcdhPubBase64) {
const peerPub = Buffer.from(peerEcdhPubBase64, 'base64');
const sharedSec = this.ecdh.computeSecret(peerPub);
this.sessionKey = Buffer.from(
hkdfSync('sha256', sharedSec, '', `${this.nome}-session-v1`, 32)
);
console.log(`[${this.nome}] Sessione stabilita. Chiave: ${this.sessionKey.toString('hex').slice(0, 16)}...`);
}
// Cifra e firma un messaggio
invia(testo) {
if (!this.sessionKey) throw new Error('Sessione non stabilita');
const iv = randomBytes(12);
const cipher = createCipheriv('aes-256-gcm', this.sessionKey, iv);
const cifrato = Buffer.concat([cipher.update(testo, 'utf8'), cipher.final()]);
const authTag = cipher.getAuthTag();
// Firma il contenuto cifrato con la chiave di identità ECDSA
const daFirmare = Buffer.concat([iv, authTag, cifrato]);
const firma = sign('sha256', daFirmare, this.identPriv);
return {
mittente: this.nome,
pubIdent: this.identPub,
iv: iv.toString('base64'),
authTag: authTag.toString('base64'),
cifrato: cifrato.toString('base64'),
firma: firma.toString('base64'),
};
}
// Verifica la firma e decifra il messaggio
ricevi(pacchetto) {
const iv = Buffer.from(pacchetto.iv, 'base64');
const authTag = Buffer.from(pacchetto.authTag, 'base64');
const cifrato = Buffer.from(pacchetto.cifrato, 'base64');
const firma = Buffer.from(pacchetto.firma, 'base64');
// 1. Verifica firma ECDSA
const daVerificare = Buffer.concat([iv, authTag, cifrato]);
const firmaOk = verify('sha256', daVerificare, pacchetto.pubIdent, firma);
if (!firmaOk) throw new Error('FIRMA NON VALIDA: messaggio rifiutato');
// 2. Decifra con AES-256-GCM
const decipher = createDecipheriv('aes-256-gcm', this.sessionKey, iv);
decipher.setAuthTag(authTag);
const testo = Buffer.concat([decipher.update(cifrato), decipher.final()]).toString('utf8');
return { mittente: pacchetto.mittente, testo };
}
}
// --- Demo ---
const alice = new SecureMessenger('Alice');
const bob = new SecureMessenger('Bob');
// Scambio chiavi ECDH (in un'app reale: via WebSocket o API)
alice.stabilisciSessione(bob.getSessionPublicKey());
bob.stabilisciSessione(alice.getSessionPublicKey());
// Alice invia un messaggio cifrato e firmato a Bob
const pacchetto = alice.invia('Ciao Bob! Questo messaggio è E2E cifrato e firmato.');
console.log('\nPacchetto cifrato:', JSON.stringify({
...pacchetto,
cifrato: pacchetto.cifrato.slice(0, 20) + '...',
}, null, 2));
// Bob riceve, verifica la firma e decifra
const ricevuto = bob.ricevi(pacchetto);
console.log('\nRicevuto da:', ricevuto.mittente);
console.log('Testo:', ricevuto.testo);
Output atteso:
[Alice] Sessione stabilita. Chiave: 3f8a2c1b9e4d7f05...
[Bob] Sessione stabilita. Chiave: 3f8a2c1b9e4d7f05...
Ricevuto da: Alice
Testo: Ciao Bob! Questo messaggio è E2E cifrato e firmato.
5 errori comuni nell’implementazione ECC
Errore 1: Riutilizzo del nonce ECDSA
ECDSA richiede un nonce k univoco per ogni firma. Se lo stesso k viene usato due volte con la stessa chiave privata, la chiave privata è matematicamente recuperabile da chiunque osservi le due firme. PlayStation 3 (2010) e alcune implementazioni Bitcoin antiche sono cadute in questa trappola. In Node.js il nonce è generato internamente dall’OpenSSL sottostante usando RAND_bytes, ma se usi librerie JavaScript pure verificate che usino RFC 6979 (nonce deterministico).
Errore 2: Usare il segreto ECDH grezzo come chiave AES
Il segreto ECDH è la coordinata X del punto risultante. Ha una distribuzione non uniforme e potrebbe avere bit fissi. Passarlo direttamente come chiave AES compromette la sicurezza. Sempre usa HKDF (o almeno SHA-256 del segreto) per derivare la chiave simmetrica.
Errore 3: Riutilizzo dell’IV in AES-GCM
AES-GCM è catastroficamente rotto se lo stesso IV viene usato due volte con la stessa chiave. Un attaccante può recuperare la chiave di autenticazione, falsificare messaggi e, in alcuni scenari, decifrare i dati. Genera sempre l’IV con randomBytes(12) per ogni messaggio. Con 12 byte casuali, la probabilità di collisione è trascurabile fino a circa 2^32 messaggi.
Errore 4: Non validare la chiave pubblica del peer
Un attaccante può inviare una chiave pubblica non valida (punto non sulla curva) per forzare un segreto ECDH prevedibile. Node.js valida automaticamente il punto durante computeSecret, ma se importi chiavi da formati personalizzati verifica sempre che il punto sia sulla curva. Usa createPublicKey con gestione delle eccezioni:
try {
const peerKey = createPublicKey({ key: datiPeer, format: 'der', type: 'spki' });
// Chiave valida, procedi
} catch (err) {
throw new Error('Chiave pubblica non valida: attacco possibile');
}
Errore 5: Mischiare curve diverse nello stesso protocollo
Usare P-256 per l’identità e secp256k1 per ECDH, o firmare con P-384 e verificare con P-256, causa errori silenziosi o vulnerabilità di downgrade. Stabilisci una politica di curva per l’intero sistema e documentala: usa solo P-256 o solo X25519 per ECDH, solo Ed25519 o solo ECDSA P-256 per le firme.
8 problemi comuni e soluzioni
| Problema | Messaggio di errore | Causa | Soluzione |
|---|---|---|---|
| ECDH produce segreti diversi | segreti non identici |
Versioni diverse di Node.js usano formati diversi per i punti pubblici | Specifica esplicitamente getPublicKey('uncompressed') su entrambe le parti |
| hkdfSync non disponibile | TypeError: hkdfSync is not a function |
Node.js < v15.0.0 | Aggiorna a Node.js v20+ o usa la libreria hkdf da npm |
| Firma non valida dopo serializzazione | false da verify() |
Il dato firmato è stato convertito in stringa UTF-8 perdendo byte | Firma sempre su Buffer, non su stringhe: Buffer.from(dato) |
| ERR_CRYPTO_GCM_AUTH_FAILED | Error: Unsupported state or unable to authenticate data |
IV o authTag errati, o dato cifrato alterato | Verifica l’ordine di concatenazione IV+authTag+cifrato e che il tag abbia esattamente 16 byte |
| Chiave PEM rifiutata | Error: error:09091064:PEM routines |
Intestazioni PEM mancanti o file corrotto | Assicurati che il PEM inizi con -----BEGIN senza spazi iniziali |
| Curva non supportata | Error: curve not supported |
OpenSSL sulla versione di Node non include la curva richiesta | Verifica con crypto.getCurves() le curve disponibili nella tua build |
| Chiave privata con passphrase sbagliata | Error: EVP_DecryptFinal_ex: bad decrypt |
Passphrase errata o encoding diverso | Passa la passphrase come Buffer se contiene caratteri non ASCII |
| Firma ECDSA DER troppo lunga per JWT | JWT non valido lato consumer | JWT ES256 richiede formato P1363 (64 byte fissi), non DER | Converti con derToJose dalla libreria ecdsa-sig-formatter |
Tecniche avanzate: Ed25519 e X25519
Le curve Curve25519 e le relative varianti Ed25519 (firma) e X25519 (key agreement) sono progettate con criteri di sicurezza più rigorosi delle curve NIST, sono resistenti agli attacchi timing side-channel e producono chiavi di dimensioni compatte. Node.js le supporta nativamente dalla v15 (Ed25519) e v17 (X25519).
// Ed25519: firma deterministica (nessun rischio nonce reuse)
const { generateKeyPairSync, sign, verify } = require('node:crypto');
const { publicKey, privateKey } = generateKeyPairSync('ed25519', {
publicKeyEncoding: { type: 'spki', format: 'pem' },
privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
});
const dato = Buffer.from('dati da firmare con Ed25519');
const firma = sign(null, dato, privateKey); // null: Ed25519 usa l'hash interno
const ok = verify(null, dato, publicKey, firma);
console.log('Firma Ed25519 valida:', ok);
console.log('Lunghezza firma:', firma.length, 'byte'); // Sempre 64 byte
// X25519: key agreement (usato da TLS 1.3 e Signal)
const { diffieHellman, generateKeyPairSync: genKP } = require('node:crypto');
const aliceKP = genKP('x25519', {
publicKeyEncoding: { type: 'spki', format: 'pem' },
privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
});
const bobKP = genKP('x25519', {
publicKeyEncoding: { type: 'spki', format: 'pem' },
privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
});
const aliceSegreto = diffieHellman({
privateKey: require('node:crypto').createPrivateKey(aliceKP.privateKey),
publicKey: require('node:crypto').createPublicKey(bobKP.publicKey),
});
console.log('Segreto X25519 (hex):', aliceSegreto.toString('hex').slice(0, 32), '...');
TLS 1.3 usa X25519 come curva di default per il key agreement (insieme a X448 per sicurezza elevata). Se stai costruendo un server HTTPS con Node.js, il modulo node:tls sceglie automaticamente X25519 o P-256 in base alla negoziazione con il client, vedi la guida Let’s Encrypt e Certbot: HTTPS Gratis in 10 Step per la configurazione TLS del server.
Confronto ECC vs RSA in Node.js
| Metrica | ECC P-256 | RSA-2048 | RSA-3072 |
|---|---|---|---|
| Sicurezza equivalente (bit) | 128 | 112 | 128 |
| Dimensione chiave privata | 32 byte | 256 byte | 384 byte |
| Dimensione chiave pubblica | 65 byte | 256 byte | 384 byte |
| Dimensione firma | 70-72 byte DER | 256 byte | 384 byte |
| Velocità generazione chiavi | Molto veloce | Lenta | Molto lenta |
| Compatibilità | Alta (TLS, JWT, SSH) | Massima (legacy) | Alta |
| Resistenza post-quantistica | No (entrambi vulnerabili) | No | No |
RSA-2048 è classificato a 112 bit di sicurezza, inferiore a P-256 (128 bit), secondo NIST SP 800-186. Se devi interoperare con sistemi legacy che richiedono RSA, usa RSA-3072 per raggiungere lo stesso livello P-256. Per sistemi completamente nuovi senza vincoli legacy, ECC P-256 o Ed25519 sono sempre la scelta migliore.
Copertura correlata
Articoli correlati su shattered.io
- Crittografia End-to-End in Node.js: 12 Step – Implementazione E2E con ECDH e AES-GCM
- Autenticazione JWT in Node.js: 12 Step – JWT con algoritmo ES256 (ECDSA P-256)
- OpenSSL 3.5 LTS: Chiavi e Certificati in 12 Step – Gestione certificati X.509 con OpenSSL
- GPG: Cifrare e Firmare File in 12 Step – Crittografia asimmetrica con GPG/PGP
- Let’s Encrypt e Certbot: HTTPS Gratis in 10 Step – Certificati TLS con curve ECDSA
- Hashing Password con bcrypt in Node.js: 12 Step – Sicurezza delle credenziali utente
- Crittografia: guida alla categoria – Tutti gli articoli sulla crittografia
Domande frequenti
Qual è la differenza tra P-256 e secp256k1?
Entrambe sono curve a 256 bit con 128 bit di sicurezza, ma hanno parametri matematici diversi. P-256 (anche chiamata secp256r1 o prime256v1) è la curva NIST standard usata in TLS, HTTPS e JWT. secp256k1 è la curva usata da Bitcoin ed Ethereum: è più rapida per alcune operazioni, ma non è approvata FIPS e non è supportata dai provider TLS nei browser. Per applicazioni web usa P-256; per blockchain usa secp256k1.
ECC è sicuro contro i computer quantistici?
No. L’algoritmo di Shor su un computer quantistico sufficientemente potente risolverebbe il problema ECDLP in tempo polinomiale, rendendo obsoleto ECC insieme a RSA. Il NIST ha standardizzato nel 2024 tre algoritmi post-quantistici (ML-KEM, ML-DSA, SLH-DSA) come successori. Per oggi ECC rimane sicuro contro i computer classici; inizia a pianificare la migrazione verso gli algoritmi post-quantistici per i sistemi con dati sensibili a lungo termine. Leggi la guida Crittografia Post-Quantistica per i dettagli sulla transizione.
Devo usare ECDSA o EdDSA per le firme?
Per i sistemi nuovi preferisci Ed25519 (EdDSA): il nonce è deterministico (RFC 6979 esteso), quindi non è possibile compromettere la chiave privata per un difetto del generatore casuale. Le firme hanno lunghezza fissa (64 byte), semplificando il parsing. ECDSA P-256 è necessario quando devi compatibilità con: JWT ES256, TLS con certificati ECDSA, o partner che richiedono FIPS 186-5.
Quanto è sicuro AES-256-GCM in combinazione con ECDH?
La combinazione ECDH-P256 + HKDF-SHA256 + AES-256-GCM è lo schema raccomandato per la crittografia ibrida asimmetrica/simmetrica. Offre 128 bit di sicurezza (limitata dalla curva P-256, non dall’AES che è a 256 bit). Questo schema è usato da TLS 1.3 (in varianti con X25519) e da Signal (con X3DH + Double Ratchet). La vulnerabilità principale non è il cifrario, ma gli errori implementativi: nonce AES-GCM riutilizzato, segreto ECDH usato grezzo, IV prevedibile.
Posso usare la stessa coppia di chiavi EC per ECDH e ECDSA?
Tecnicamente sì (con P-256), ma è una cattiva pratica per due motivi: separa i rischi (se la chiave ECDH è compromessa, le firme storiche rimangono valide) e rispetta il principio di singola responsabilità crittografica. La raccomandazione standard è usare chiavi dedicate per ogni scopo: una coppia per ECDSA (identità, firma) e coppie effimere distinte per ECDH (sessione, key agreement).
Come verifico quali curve supporta la mia versione di Node.js?
Usa crypto.getCurves() per ottenere la lista completa delle curve disponibili nell’OpenSSL collegato alla tua installazione Node.js:
const crypto = require('node:crypto');
const curve = crypto.getCurves();
const principali = ['prime256v1', 'secp384r1', 'secp521r1', 'secp256k1'];
principali.forEach(c => console.log(c, curve.includes(c) ? 'disponibile' : 'NON disponibile'));
Se una curva non è disponibile, aggiorna Node.js all’ultima versione LTS (v22 o v20) che include OpenSSL 3.x con tutte le curve NIST e Curve25519.
Quanto è diversa l’ECC dalla crittografia RSA per l’API Node.js?
L’API è quasi identica: entrambe usano generateKeyPairSync, sign, verify, createCipheriv. La differenza principale è nel tipo (‘ec’ vs ‘rsa’) e nei parametri: ECC usa namedCurve, RSA usa modulusLength. Per il key agreement, RSA usa RSA-OAEP (cifratura della chiave simmetrica), ECC usa ECDH (accordo matematico). ECC è generalmente preferito per nuove implementazioni per le chiavi più piccole e le operazioni più veloci.



