La crittografia end-to-end non è più un argomento riservato ai team di Signal o WhatsApp. Nel 2026 qualsiasi sviluppatore che gestisce messaggi privati, documenti sanitari, dati finanziari o backup degli utenti deve sapere come implementarla. Questo tutorial ti guida passo dopo passo nella costruzione di un sistema di crittografia end-to-end funzionante in Node.js, usando libsodium, lo scambio di chiavi X25519 e la cifratura autenticata XChaCha20-Poly1305. Al termine avrai un progetto completo, testabile e pronto a essere adattato in produzione, con una sezione finale dedicata alla migrazione post-quantum.

Aggiornato all’11 giugno 2026. Tempo di completamento stimato: 60-90 minuti. Livello: intermedio (serve confidenza con JavaScript asincrono e con la riga di comando).

Cos’è la crittografia end-to-end e perché conta nel 2026

La crittografia end-to-end (in inglese E2EE, end-to-end encryption) garantisce che solo il mittente e il destinatario possano leggere il contenuto di un messaggio. Le chiavi di decifratura non lasciano mai i dispositivi degli utenti. Nemmeno il server che trasporta i dati, l’amministratore di sistema o un attaccante che compromette l’infrastruttura può accedere al testo in chiaro. Questo è il punto che distingue la crittografia end-to-end dalla classica cifratura in transito (TLS) o a riposo (cifratura del disco): in quei due casi il server vede comunque i dati in chiaro a un certo punto.

La differenza è sostanziale per la conformità normativa. Il GDPR (Regolamento UE 2016/679) considera la cifratura una misura tecnica adeguata ai sensi dell’articolo 32, e nel 2026 le autorità di controllo europee la citano regolarmente nelle istruttorie sui data breach. Quando i dati esfiltrati sono cifrati end-to-end e le chiavi restano sui dispositivi, l’impatto di una violazione si riduce drasticamente, perché l’attaccante ottiene solo ciphertext inutilizzabile.

I tre obiettivi che costruiremo passo dopo passo sono questi:

  • Riservatezza: nessuno tranne il destinatario legge il messaggio.
  • Autenticità e integrità: il destinatario verifica che il messaggio arrivi davvero dal mittente e che non sia stato modificato di un solo bit.
  • Forward secrecy: la compromissione di una chiave oggi non espone i messaggi scambiati ieri.

Per ottenere questi tre obiettivi non scriveremo primitive crittografiche a mano. La regola d’oro della crittografia applicata resta valida nel 2026: non implementare algoritmi da zero. Useremo libsodium, la libreria moderna derivata da NaCl che espone API ad alto livello difficili da usare in modo insicuro. La documentazione ufficiale di libsodium descrive la versione 1.0.22-stable come libreria per cifratura, decifratura, firme e hashing delle password.

Prerequisiti: versioni e strumenti necessari

Prima di scrivere codice, verifica di avere l’ambiente corretto. Tutte le versioni qui sotto sono quelle consigliate a giugno 2026.

ComponenteVersione minimaVersione consigliataNote
Node.js20 LTS22 LTSNecessario per ES modules e API crypto native moderne
npm10.x10.x o superioreIncluso con Node.js
libsodium-wrappers0.7.x0.8.4Wrapper JavaScript/WebAssembly di libsodium
libsodium (nativa)1.0.201.0.22-stableUsata internamente dal wrapper
EditorqualsiasiVS CodeCon supporto ESLint consigliato

Controlla la versione di Node.js installata con un singolo comando. Se ottieni una versione inferiore alla 20, aggiorna prima di proseguire, perché le API crypto.webcrypto e il supporto stabile agli ES modules sono indispensabili per il progetto.

$ node --version
v22.11.0

$ npm --version
10.9.0

Una nota importante sulla scelta della libreria. Node.js ha un modulo crypto nativo capace di AES-256-GCM e di ECDH, e per molti casi d’uso basta. In questo tutorial usiamo libsodium-wrappers perché espone XChaCha20-Poly1305 (nonce a 192 bit, molto più tollerante alla generazione casuale) e le API crypto_kx e crypto_box, pensate esattamente per lo scenario end-to-end. Mostreremo comunque l’equivalente con il modulo nativo dove utile.

Architettura del progetto di crittografia end-to-end

Prima del codice, fissiamo il modello mentale. Immagina due utenti, Alice e Bob, che vogliono scambiarsi messaggi tramite un server che non deve mai vedere il testo in chiaro. Il flusso della crittografia end-to-end si articola in quattro fasi.

  1. Generazione delle chiavi: ogni utente genera localmente una coppia di chiavi X25519 (pubblica e privata). La chiave privata non lascia mai il dispositivo.
  2. Scambio delle chiavi pubbliche: gli utenti si scambiano le sole chiavi pubbliche tramite il server. Da queste derivano un segreto condiviso identico su entrambi i lati, senza mai trasmetterlo.
  3. Cifratura: il mittente cifra il messaggio con una chiave di sessione derivata dal segreto condiviso, usando XChaCha20-Poly1305.
  4. Decifratura e verifica: il destinatario decifra e verifica contestualmente l’autenticità grazie al tag Poly1305 incorporato.

Il segreto condiviso si ottiene con la matematica delle curve ellittiche (Diffie-Hellman su Curve25519, da cui il nome X25519). La proprietà chiave è questa: Alice combina la propria chiave privata con la pubblica di Bob, Bob combina la propria privata con la pubblica di Alice, e i due ottengono lo stesso valore senza che quel valore transiti mai sulla rete. Le chiavi pubbliche X25519 sono lunghe 32 byte, compatte e facili da archiviare.

Step 1: Inizializzare il progetto e installare libsodium

Crea una nuova cartella, inizializza il progetto come modulo ES e installa l’unica dipendenza necessaria. Useremo "type": "module" per scrivere import moderni invece di require.

$ mkdir e2ee-node && cd e2ee-node
$ npm init -y
$ npm pkg set type="module"
$ npm install [email protected]

added 1 package in 1s

Un dettaglio cruciale di libsodium-wrappers: la libreria viene caricata in modo asincrono (è compilata in WebAssembly) e devi attendere la promise sodium.ready prima di chiamare qualsiasi funzione. Dimenticare questo passaggio è il primo errore in assoluto di chi inizia. Creiamo un piccolo modulo di bootstrap che esporta l’istanza già pronta.

// sodium.js
import _sodium from 'libsodium-wrappers';

let ready = false;

export async function getSodium() {
  if (!ready) {
    await _sodium.ready;
    ready = true;
  }
  return _sodium;
}

Da qui in avanti ogni modulo che ha bisogno di crittografia chiamerà await getSodium(). In questo modo la libreria è inizializzata una sola volta per processo, anche se la importi in più file.

Step 2: Generare le coppie di chiavi X25519

Ogni utente ha bisogno di una coppia di chiavi per lo scambio. La funzione crypto_kx_keypair genera una chiave pubblica e una privata da 32 byte ciascuna, pensate proprio per il key exchange. Creiamo il modulo keys.js.

// keys.js
import { getSodium } from './sodium.js';

export async function generateKeyPair() {
  const sodium = await getSodium();
  const { publicKey, privateKey } = sodium.crypto_kx_keypair();
  return { publicKey, privateKey };
}

// Utility: byte -> base64 per il trasporto/archiviazione
export async function toBase64(bytes) {
  const sodium = await getSodium();
  return sodium.to_base64(bytes, sodium.base64_variants.ORIGINAL);
}

export async function fromBase64(str) {
  const sodium = await getSodium();
  return sodium.from_base64(str, sodium.base64_variants.ORIGINAL);
}

La chiave pubblica può essere inviata al server e condivisa con gli altri utenti senza rischi. La chiave privata, invece, deve restare sul dispositivo: salvala in un keystore sicuro (Keychain su macOS, DPAPI su Windows, libsecret su Linux) oppure cifrala con una password derivata via Argon2 prima di scriverla su disco. Non salvarla mai in chiaro in un database lato server, perché questo annullerebbe l’intera proprietà end-to-end.

Step 3: Lo scambio di chiavi (key exchange) con crypto_kx

Qui avviene la magia del Diffie-Hellman. Le funzioni crypto_kx_client_session_keys e crypto_kx_server_session_keys prendono la coppia di chiavi locale e la chiave pubblica della controparte, e restituiscono due chiavi di sessione da 32 byte: una per ricevere (rx) e una per trasmettere (tx). Il design garantisce che la chiave tx del client corrisponda alla chiave rx del server e viceversa.

// session.js
import { getSodium } from './sodium.js';

// Lato che inizia la conversazione (client)
export async function clientSession(myKeys, theirPublicKey) {
  const sodium = await getSodium();
  const { sharedRx, sharedTx } = sodium.crypto_kx_client_session_keys(
    myKeys.publicKey,
    myKeys.privateKey,
    theirPublicKey
  );
  return { rx: sharedRx, tx: sharedTx };
}

// Lato che riceve la conversazione (server/peer)
export async function serverSession(myKeys, theirPublicKey) {
  const sodium = await getSodium();
  const { sharedRx, sharedTx } = sodium.crypto_kx_server_session_keys(
    myKeys.publicKey,
    myKeys.privateKey,
    theirPublicKey
  );
  return { rx: sharedRx, tx: sharedTx };
}

Attenzione alla simmetria dei ruoli: se Alice usa clientSession, Bob deve usare serverSession, altrimenti le chiavi rx e tx non combaceranno e la decifratura fallirà. È una convenzione, non una gerarchia di sicurezza: entrambi i lati restano paritari. Nella pratica chi avvia la conversazione assume il ruolo di client.

Il segreto condiviso non viene mai inviato sulla rete. Sul filo viaggiano solo le due chiavi pubbliche da 32 byte. Un attaccante che intercetta entrambe le chiavi pubbliche non riesce comunque a ricavare le chiavi di sessione, perché il problema del logaritmo discreto su Curve25519 è computazionalmente intrattabile per i computer classici.

Step 4: Cifrare i messaggi con XChaCha20-Poly1305

Con la chiave di sessione possiamo cifrare. Usiamo crypto_aead_xchacha20poly1305_ietf_encrypt, una funzione di cifratura autenticata (AEAD) che produce contemporaneamente ciphertext e tag di autenticazione. XChaCha20-Poly1305 combina lo stream cipher ChaCha20 (specificato in RFC 8439) con il MAC Poly1305, e adotta un nonce esteso a 192 bit (24 byte).

// cipher.js
import { getSodium } from './sodium.js';

export async function encrypt(plaintext, key, associatedData = null) {
  const sodium = await getSodium();
  // Nonce a 192 bit: generato in modo casuale a ogni messaggio
  const nonce = sodium.randombytes_buf(
    sodium.crypto_aead_xchacha20poly1305_ietf_NPUBBYTES
  );
  const message = typeof plaintext === 'string'
    ? sodium.from_string(plaintext)
    : plaintext;

  const ciphertext = sodium.crypto_aead_xchacha20poly1305_ietf_encrypt(
    message,
    associatedData,   // dati autenticati ma non cifrati (es. ID mittente)
    null,             // nsec, sempre null in questa variante
    nonce,
    key
  );

  return { ciphertext, nonce };
}

Il parametro associatedData (AAD, associated data) merita attenzione. Non viene cifrato, ma viene autenticato: se qualcuno lo modifica, la decifratura fallisce. È perfetto per legare il ciphertext a metadati che devono restare in chiaro ma immutabili, come l’ID del mittente, un timestamp o il numero di sequenza del messaggio. Usarlo previene gli attacchi di sostituzione del contesto, in cui un attaccante riusa un ciphertext valido in una conversazione diversa.

Il nonce a 192 bit di XChaCha20 è il motivo principale per cui lo preferiamo ad AES-256-GCM in questo scenario. Con AES-GCM il nonce è di soli 96 bit e generarlo casualmente porta a una probabilità di collisione non trascurabile dopo qualche miliardo di messaggi con la stessa chiave. Con 192 bit la generazione puramente casuale è sicura per qualsiasi volume realistico, eliminando la necessità di un contatore stateful.

Step 5: Decifrare e verificare l’autenticità

La decifratura è speculare. La funzione crypto_aead_xchacha20poly1305_ietf_decrypt verifica il tag Poly1305 prima di restituire il testo in chiaro: se il ciphertext, il nonce o l’AAD sono stati alterati, lancia un’eccezione e non restituisce nulla. Questa è la garanzia di integrità: non esiste modo di ottenere testo in chiaro da un messaggio manomesso.

// cipher.js (continua)
export async function decrypt(ciphertext, nonce, key, associatedData = null) {
  const sodium = await getSodium();
  try {
    const decrypted = sodium.crypto_aead_xchacha20poly1305_ietf_decrypt(
      null,             // nsec, sempre null
      ciphertext,
      associatedData,
      nonce,
      key
    );
    return sodium.to_string(decrypted);
  } catch (err) {
    // Tag non valido: messaggio manomesso o chiave errata
    throw new Error('Decifratura fallita: messaggio non autentico');
  }
}

Non ignorare mai l’eccezione e non restituire un valore parziale. Un fallimento di decifratura significa una di queste cose: chiave sbagliata, nonce sbagliato, AAD diverso, oppure un tentativo di manomissione. In tutti i casi il comportamento corretto è rifiutare il messaggio. Mostrare messaggi di errore diversi per “chiave errata” e “tag non valido” può aprire la porta a un oracle attack: meglio un errore generico.

Step 6: Gestione corretta dei nonce

Il nonce (number used once) è il dato più sottovalutato e più pericoloso di tutto il sistema. La regola assoluta degli schemi AEAD è: mai riusare la stessa coppia (chiave, nonce) per due messaggi diversi. Il riuso del nonce con la stessa chiave rompe sia la riservatezza sia, in molti casi, l’integrità, perché permette di recuperare il keystream e di forgiare tag validi.

Esistono due strategie corrette. La prima, che abbiamo già adottato, è generare un nonce casuale a 192 bit per ogni messaggio: con XChaCha20 lo spazio è così grande che le collisioni sono trascurabili. La seconda è usare un contatore monotono crescente, utile quando vuoi anche rilevare e rifiutare i replay. Ecco un esempio del secondo approccio, che incorpora il numero di sequenza nel nonce.

// counter-nonce.js
import { getSodium } from './sodium.js';

export function makeCounterNonce(sodium, counter) {
  const nonce = new Uint8Array(
    sodium.crypto_aead_xchacha20poly1305_ietf_NPUBBYTES
  );
  // Scrive il contatore (big-endian) negli ultimi 8 byte del nonce
  const view = new DataView(nonce.buffer);
  view.setBigUint64(
    nonce.length - 8,
    BigInt(counter),
    false // big-endian
  );
  return nonce;
}

Se scegli il contatore, devi persistere il valore in modo durevole: un riavvio che azzera il contatore reintroduce il riuso del nonce. Per questo, per la maggior parte delle applicazioni, il nonce casuale a 192 bit è la scelta più semplice e robusta. La tabella riassume il confronto tra le due strategie e i due cifrari principali.

CaratteristicaXChaCha20-Poly1305ChaCha20-Poly1305 (IETF)AES-256-GCM
Dimensione nonce192 bit (24 byte)96 bit (12 byte)96 bit (12 byte)
Nonce casuale sicuroSì, sempreSolo fino a circa 2^32 msgSolo fino a circa 2^32 msg
Accelerazione hardwareNo (CPU generica)NoSì (AES-NI)
Robustezza nonce casualeMolto altaMediaMedia
Tag di autenticazione128 bit128 bit128 bit
Disponibile in libsodiumSì (alto livello)Non come primitiva

Step 7: Forward secrecy con la rotazione delle chiavi (ratchet)

Finora abbiamo una sessione cifrata, ma se un attaccante registra tutto il traffico e in futuro ruba la chiave privata di lungo termine, può decifrare l’intero storico. La forward secrecy elimina questo rischio: ogni messaggio (o gruppo di messaggi) usa una chiave effimera che viene distrutta subito dopo l’uso. La compromissione della chiave di oggi non rivela i messaggi di ieri.

Il riferimento di settore è il Double Ratchet del Signal Protocol, che combina uno scambio Diffie-Hellman effimero a ogni passo con un ratchet simmetrico via funzione di derivazione delle chiavi. Implementare un Double Ratchet completo va oltre questo tutorial, ma possiamo costruire una versione semplificata di ratchet simmetrico che fa avanzare la chiave a ogni messaggio usando una KDF (key derivation function). Usiamo crypto_kdf_derive_from_key.

// ratchet.js
import { getSodium } from './sodium.js';

const CONTEXT = 'e2ee-ctx'; // 8 byte di contesto

// Deriva la chiave del messaggio N e la prossima chiave di catena
export async function ratchetStep(chainKey, counter) {
  const sodium = await getSodium();
  const messageKey = sodium.crypto_kdf_derive_from_key(
    32, counter, CONTEXT, chainKey
  );
  const nextChainKey = sodium.crypto_kdf_derive_from_key(
    32, counter + 1, CONTEXT, chainKey
  );
  // La vecchia chainKey va azzerata dal chiamante dopo questo step
  return { messageKey, nextChainKey };
}

Il principio è semplice: ogni messaggio ottiene una messageKey univoca derivata dalla catena, e la catena avanza in modo irreversibile. Dato che la KDF è a senso unico, conoscere una chiave futura non permette di tornare indietro alle chiavi passate. Ricorda di sovrascrivere in memoria le chiavi usate con sodium.memzero non appena hai finito, per ridurre la finestra di esposizione in caso di dump della RAM.

Step 8: Firmare l’identità con Ed25519

Lo scambio X25519 protegge dalla lettura, ma da solo non protegge dall’attacco man-in-the-middle: se un attaccante sostituisce le chiavi pubbliche durante lo scambio, può inserirsi al centro della conversazione. La difesa è autenticare le chiavi pubbliche con una firma digitale Ed25519 legata a un’identità verificabile. Ed25519 è veloce, produce firme da 64 byte e chiavi pubbliche da 32 byte.

// identity.js
import { getSodium } from './sodium.js';

export async function createIdentity() {
  const sodium = await getSodium();
  const { publicKey, privateKey } = sodium.crypto_sign_keypair();
  return { signPub: publicKey, signPriv: privateKey };
}

// Firma la chiave pubblica X25519 con la chiave di identità Ed25519
export async function signKey(x25519PublicKey, signPriv) {
  const sodium = await getSodium();
  return sodium.crypto_sign_detached(x25519PublicKey, signPriv);
}

export async function verifyKey(signature, x25519PublicKey, signPub) {
  const sodium = await getSodium();
  return sodium.crypto_sign_verify_detached(
    signature, x25519PublicKey, signPub
  );
}

Nel mondo reale la chiave di identità Ed25519 viene verificata fuori banda: tramite un QR code scansionato di persona (il “safety number” di Signal), un certificato emesso da una PKI aziendale, o la trust on first use con notifica all’utente in caso di cambio chiave. Senza questo anello, la crittografia end-to-end protegge dal server curioso ma non da un man-in-the-middle attivo capace di sostituire le chiavi.

Step 9: Serializzare e trasportare il messaggio cifrato

Il ciphertext e il nonce sono array di byte (Uint8Array). Per inviarli su una API JSON o salvarli in un database, vanno serializzati. Lo standard pratico è codificarli in base64 e impacchettarli in un oggetto con un campo di versione, utile per future migrazioni di algoritmo.

// envelope.js
import { toBase64, fromBase64 } from './keys.js';

export async function pack(ciphertext, nonce, senderId) {
  return JSON.stringify({
    v: 1,
    alg: 'xchacha20poly1305',
    sender: senderId,
    nonce: await toBase64(nonce),
    ct: await toBase64(ciphertext)
  });
}

export async function unpack(json) {
  const obj = JSON.parse(json);
  if (obj.v !== 1) throw new Error('Versione envelope non supportata');
  return {
    senderId: obj.sender,
    nonce: await fromBase64(obj.nonce),
    ciphertext: await fromBase64(obj.ct)
  };
}

Il campo v di versione non è un dettaglio cosmetico: quando migrerai a una primitiva post-quantum (Step 12) potrai distinguere i vecchi messaggi dai nuovi e decifrare entrambi durante il periodo di transizione. Lega il campo sender all’AAD durante la cifratura, così l’identità del mittente resta autenticata e non sostituibile.

Step 10: Il progetto completo che mette tutto insieme

È il momento di assemblare i moduli in un flusso end-to-end completo, da Alice a Bob, con scambio di chiavi, cifratura e decifratura. Crea demo.js.

// demo.js
import { generateKeyPair } from './keys.js';
import { clientSession, serverSession } from './session.js';
import { encrypt, decrypt } from './cipher.js';
import { pack, unpack } from './envelope.js';

async function main() {
  // 1. Alice e Bob generano le proprie coppie di chiavi
  const alice = await generateKeyPair();
  const bob = await generateKeyPair();

  // 2. Scambio: ognuno conosce la chiave pubblica dell'altro
  const aliceSession = await clientSession(alice, bob.publicKey);
  const bobSession = await serverSession(bob, alice.publicKey);

  // 3. Alice cifra un messaggio per Bob (usa la sua chiave tx)
  const aad = new TextEncoder().encode('[email protected]');
  const { ciphertext, nonce } = await encrypt(
    'Ciao Bob, questo messaggio è end-to-end.',
    aliceSession.tx,
    aad
  );

  // 4. Serializzazione e "invio" tramite il server
  const envelope = await pack(ciphertext, nonce, '[email protected]');
  console.log('Sul filo viaggia solo:', envelope.slice(0, 80), '...');

  // 5. Bob riceve, deserializza e decifra (usa la sua chiave rx)
  const received = await unpack(envelope);
  const aadBob = new TextEncoder().encode(received.senderId);
  const plaintext = await decrypt(
    received.ciphertext,
    received.nonce,
    bobSession.rx,
    aadBob
  );

  console.log('Bob legge:', plaintext);
}

main().catch(console.error);

Nota la simmetria delle chiavi di sessione: Alice cifra con aliceSession.tx e Bob decifra con bobSession.rx. Grazie al design di crypto_kx queste due chiavi sono identiche. Per il traffico nella direzione opposta (Bob verso Alice) useresti bobSession.tx e aliceSession.rx.

Step 11: Eseguire i test e leggere l’output di esempio

Avvia la demo e osserva il risultato. Il server vede solo ciphertext base64 illeggibile, mentre Bob ricostruisce il testo originale.

$ node demo.js
Sul filo viaggia solo: {"v":1,"alg":"xchacha20poly1305","sender":"[email protected]","nonce":"k9Xq2..."} ...
Bob legge: Ciao Bob, questo messaggio è end-to-end.

Aggiungiamo un test che dimostra la proprietà di integrità: se modifichiamo un solo byte del ciphertext, la decifratura deve fallire. Questo è il cuore della verifica di autenticità.

// tamper-test.js
import { generateKeyPair } from './keys.js';
import { clientSession, serverSession } from './session.js';
import { encrypt, decrypt } from './cipher.js';

const alice = await generateKeyPair();
const bob = await generateKeyPair();
const aSess = await clientSession(alice, bob.publicKey);
const bSess = await serverSession(bob, alice.publicKey);

const { ciphertext, nonce } = await encrypt('dati riservati', aSess.tx);

// Manomissione: capovolgiamo un bit
ciphertext[0] ^= 0x01;

try {
  await decrypt(ciphertext, nonce, bSess.rx);
  console.log('ERRORE: la manomissione non è stata rilevata!');
} catch (e) {
  console.log('OK: manomissione rilevata ->', e.message);
}
$ node tamper-test.js
OK: manomissione rilevata -> Decifratura fallita: messaggio non autentico

Un benchmark rapido aiuta a capire le prestazioni. Su hardware desktop moderno senza accelerazione AES-NI, XChaCha20-Poly1305 cifra centinaia di megabyte al secondo, più che sufficiente per messaggistica e file di dimensione media. Su CPU con AES-NI, AES-256-GCM può risultare più veloce su file molto grandi, ma per messaggi corti la differenza è irrilevante e la robustezza del nonce a 192 bit pesa di più nella scelta.

Step 12: Migrazione post-quantum con KEM ibrido ML-KEM

Nel 2026 la minaccia quantistica non è più teorica nella pianificazione di sicurezza. Il rischio concreto è il “harvest now, decrypt later”: un attaccante registra oggi il traffico cifrato con X25519 e lo decifra in futuro, quando un computer quantistico abbastanza grande romperà la crittografia a curve ellittiche. Per i dati che devono restare riservati per un decennio, questo rischio è reale adesso.

La risposta è il KEM (key encapsulation mechanism) ibrido. ML-KEM, lo standard NIST pubblicato come FIPS 203 e basato sull’algoritmo Kyber, è il KEM post-quantum di riferimento. La strategia consigliata per la transizione non è sostituire X25519, ma affiancarlo: il segreto condiviso finale deriva dalla combinazione del segreto X25519 e del segreto ML-KEM. Così la sessione resta sicura finché almeno uno dei due rimane intatto.

// hybrid-kdf.js (concetto)
// ss_classico  = X25519(privA, pubB)          // 32 byte
// ss_pq        = ML-KEM-768 encapsulate(...)   // segreto post-quantum
// chiave finale = BLAKE2b( ss_classico || ss_pq )
import { getSodium } from './sodium.js';

export async function combineSecrets(ssClassic, ssPq) {
  const sodium = await getSodium();
  const concat = new Uint8Array(ssClassic.length + ssPq.length);
  concat.set(ssClassic, 0);
  concat.set(ssPq, ssClassic.length);
  // BLAKE2b come KDF per derivare 32 byte di chiave di sessione
  return sodium.crypto_generichash(32, concat);
}

Al momento libsodium-wrappers non espone ML-KEM come primitiva di prima classe, quindi nel 2026 servono librerie dedicate (ad esempio i binding di liboqs o le API sperimentali del modulo crypto in alcune build di Node.js) per la parte post-quantum, mentre libsodium continua a fornire il ramo classico X25519 e la KDF di combinazione. Il campo v di versione che abbiamo previsto nell’envelope serve proprio a gestire la coesistenza tra messaggi classici e ibridi durante la migrazione. Per approfondire lo stato dell’arte, consulta la nostra guida dedicata alla crittografia post-quantum.

5 errori comuni da evitare nella crittografia end-to-end

La maggior parte delle vulnerabilità nei sistemi E2EE non nasce da algoritmi deboli, ma da errori di implementazione. Ecco i cinque più frequenti, con la relativa correzione.

  • Riuso del nonce. Usare due volte la stessa coppia (chiave, nonce) è l’errore più grave: espone il keystream e permette di forgiare messaggi. Correzione: nonce casuale a 192 bit con XChaCha20, oppure contatore monotono persistito su disco.
  • Cifratura senza autenticazione. Usare uno stream cipher puro senza MAC lascia il ciphertext manipolabile. Correzione: usa sempre un AEAD come XChaCha20-Poly1305, che integra l’autenticazione.
  • Chiave statica senza ratchet. Cifrare anni di messaggi con la stessa chiave annulla la forward secrecy. Correzione: ruota le chiavi con un ratchet (Step 7) e azzera quelle vecchie.
  • Chiave privata sul server. Salvare la chiave privata lato server distrugge l’intera proprietà end-to-end. Correzione: la chiave privata resta sul dispositivo, cifrata a riposo.
  • Nessuna autenticazione delle chiavi pubbliche. Senza firma Ed25519 e verifica fuori banda, un man-in-the-middle attivo sostituisce le chiavi. Correzione: firma le chiavi (Step 8) e verifica i safety number.

Un sesto errore, più sottile, è dimenticare di chiamare await sodium.ready: il programma fallisce con metodi undefined in modo confuso. Il modulo di bootstrap dello Step 1 lo previene centralizzando l’inizializzazione.

Risoluzione dei problemi: 8 errori frequenti e soluzioni

Quando qualcosa non funziona, questa tabella di troubleshooting copre i casi più comuni che incontrerai mentre sviluppi un sistema di crittografia end-to-end in Node.js.

Sintomo / ErroreCausa probabileSoluzione
TypeError: ...is not a functionChiamata prima di sodium.readyAttendi await getSodium() prima di ogni operazione
Decifratura sempre fallita tra due peerRuoli client/server invertitiUn lato usa clientSession, l’altro serverSession
Decifratura con chiave errataChiavi rx/tx scambiateCifra con tx, decifra con la rx corrispondente
Decifratura fallita dopo modifica AADAAD diverso tra cifratura e decifraturaPassa lo stesso identico AAD in entrambe le funzioni
incorrect base64 in fase di unpackVariante base64 diversaUsa la stessa variante (ORIGINAL) per encode e decode
Caratteri accentati corrottiConversione stringa/byte errataUsa from_string/to_string di libsodium (UTF-8)
ERR_REQUIRE_ESMMix di require e importImposta "type": "module" e usa solo import
Collisione nonce su volumi enormiContatore non persistito dopo riavvioPersisti il contatore o passa al nonce casuale XChaCha20

Se la decifratura fallisce in modo intermittente solo in produzione e non in locale, controlla la serializzazione: middleware che alterano l’encoding (ad esempio body-parser che reinterpreta i byte come UTF-8) sono una causa frequente di corruzione del ciphertext. Trasporta sempre i byte in base64 o base64url, mai come stringa grezza.

Consigli avanzati per la produzione

Una volta che il flusso base funziona, queste pratiche elevano il progetto a livello produttivo.

Azzeramento sicuro della memoria

JavaScript non garantisce quando il garbage collector libererà un buffer contenente chiavi. Usa sodium.memzero(buffer) per sovrascrivere esplicitamente le chiavi di sessione e i testi in chiaro non appena hai finito. Riduce la finestra in cui un dump della memoria espone segreti. Non è una garanzia assoluta in un runtime gestito, ma è una difesa in profondità che ha senso adottare.

Limiti di dimensione e streaming

Le API crypto_aead_..._encrypt caricano l’intero messaggio in memoria. Per file di grandi dimensioni usa l’API di streaming crypto_secretstream_xchacha20poly1305, che cifra a chunk e gestisce automaticamente i nonce e un tag finale che impedisce il troncamento del flusso. È la scelta giusta per allegati, backup e trasferimenti di file end-to-end.

Audit e dipendenze

Esegui npm audit a ogni build e blocca la versione esatta di libsodium-wrappers nel file di lock. La supply chain di npm resta un vettore d’attacco primario nel 2026: una dipendenza crittografica compromessa annulla qualsiasi garanzia. Verifica gli hash dei pacchetti e considera l’uso di un registry privato con mirroring per gli ambienti sensibili.

Confronto: libsodium contro il modulo crypto nativo di Node.js

Molti chiedono se serva davvero una dipendenza esterna. La risposta dipende dal caso d’uso. Il modulo crypto nativo di Node.js supporta AES-256-GCM e ECDH e non richiede pacchetti aggiuntivi, ma le sue API a basso livello lasciano più spazio all’errore. La tabella riassume i criteri di scelta.

Criteriolibsodium-wrapperscrypto nativo Node.js
Dipendenze esterneUna (libsodium-wrappers)Nessuna
API ad alto livello sicureSì (crypto_box, crypto_kx)No, a basso livello
XChaCha20-Poly1305Sì, nonce 192 bitNo (solo ChaCha20 IETF 96 bit)
Scambio chiavi prontoSì (crypto_kx)Manuale via ECDH + KDF
Streaming AEADSì (secretstream)Parziale, manuale
Funziona anche nel browserSì (WebAssembly)No (solo Node)

In sintesi: per un’applicazione end-to-end completa, soprattutto se il codice gira anche su client browser, libsodium-wrappers riduce drasticamente la superficie d’errore. Per una singola operazione di cifratura a riposo lato server, il modulo nativo è più che adeguato. La nostra guida ad AES-256 in Node.js copre quest’ultimo scenario in dettaglio.

Domande frequenti sulla crittografia end-to-end

Qual è la differenza tra crittografia end-to-end e HTTPS?

HTTPS (basato su TLS) cifra i dati in transito tra client e server, ma il server vede comunque il testo in chiaro. La crittografia end-to-end cifra i dati in modo che solo i due endpoint (mittente e destinatario) possano leggerli, escludendo anche il server intermedio. Le due tecnologie sono complementari: si usa TLS per il trasporto e l’E2EE per il contenuto.

libsodium è sicuro per la produzione nel 2026?

Sì. libsodium è una delle librerie crittografiche più verificate e usate, derivata da NaCl di Daniel J. Bernstein. La versione documentata è 1.0.22-stable e il wrapper JavaScript libsodium-wrappers 0.8.4 è mantenuto attivamente. Le sue API ad alto livello sono progettate per essere difficili da usare in modo insicuro.

Devo usare XChaCha20-Poly1305 o AES-256-GCM?

Entrambi sono sicuri. XChaCha20-Poly1305 ha un nonce a 192 bit che rende la generazione casuale sicura per qualsiasi volume, mentre AES-256-GCM ha un nonce a 96 bit più soggetto a collisioni e beneficia dell’accelerazione hardware AES-NI. Per la messaggistica end-to-end, XChaCha20-Poly1305 è la scelta più robusta per gestione dei nonce.

La crittografia end-to-end protegge dai computer quantistici?

Lo scambio X25519 da solo non è resistente ai computer quantistici. Per proteggere i dati a lungo termine dal rischio “harvest now, decrypt later”, affianca a X25519 un KEM post-quantum come ML-KEM (FIPS 203), combinando i due segreti in un approccio ibrido, come descritto nello Step 12.

Cosa succede se perdo la chiave privata?

Se la chiave privata viene persa, i messaggi cifrati per quella chiave diventano irrecuperabili: è il prezzo della vera crittografia end-to-end, perché nessun server custodisce una copia. Le soluzioni pratiche includono il backup cifrato della chiave (protetto da una passphrase robusta derivata con Argon2) o un sistema di recupero con chiavi multiple.

Questo codice è pronto per la produzione così com’è?

Il tutorial fornisce le fondamenta corrette, ma un sistema di produzione richiede in più: gestione completa del ratchet (Double Ratchet), autenticazione robusta delle identità, gestione del recupero chiavi, audit di sicurezza indipendente e protezione dell’archiviazione delle chiavi private. Usa questo progetto come base solida da estendere, non come prodotto finito.

Posso usare lo stesso codice nel browser?

Sì. Uno dei vantaggi di libsodium-wrappers è che, essendo compilato in WebAssembly, funziona in modo identico in Node.js e nel browser. Questo permette di condividere la logica crittografica tra client e server, riducendo le incongruenze. Ricorda comunque che nel browser la protezione della chiave privata è più delicata.

Conclusione: una base solida per la crittografia end-to-end

Hai costruito un sistema di crittografia end-to-end funzionante in Node.js: generazione di chiavi X25519, scambio sicuro con crypto_kx, cifratura autenticata con XChaCha20-Poly1305, gestione corretta dei nonce, forward secrecy con un ratchet simmetrico, firma delle identità con Ed25519 e una strategia di migrazione post-quantum. I tre obiettivi iniziali (riservatezza, autenticità, forward secrecy) sono coperti dal codice che hai scritto.

Il passo successivo è estendere il ratchet a un Double Ratchet completo e integrare la verifica delle identità nella tua applicazione reale. Per i riferimenti normativi e tecnici, consulta la documentazione ufficiale di libsodium, la specifica del Double Ratchet di Signal, lo standard FIPS 203 del NIST per ML-KEM e la RFC 8439 per ChaCha20-Poly1305. La crittografia applicata premia chi resta aggiornato: rivedi le tue scelte ogni anno, perché lo scenario delle minacce, soprattutto quella quantistica, evolve in fretta.

Fonti e approfondimenti: documentazione ufficiale libsodium, modulo crypto di Node.js, specifica Double Ratchet di Signal, NIST FIPS 203 (ML-KEM), RFC 8439 (ChaCha20-Poly1305).