Crittografia nel browser con JavaScript

La crittografia nel browser è oggi una componente pratica di molte applicazioni web: autenticazione, protezione di dati locali, cifratura end-to-end, firma di messaggi, derivazione di chiavi da password. In JavaScript moderno, la strada corretta passa quasi sempre per la Web Crypto API (window.crypto e crypto.subtle), che espone primitive implementate nativamente, più veloci e molto meno soggette a errori rispetto a implementazioni “a mano” in puro JavaScript.

Questo articolo mostra un percorso completo: concetti di base, generazione di casualità, hashing, cifratura simmetrica con AES-GCM, derivazione di chiavi da password con PBKDF2, cifratura asimmetrica (RSA-OAEP) e firma digitale (ECDSA). Chiude con linee guida di sicurezza e scelte architetturali.

Prerequisiti e compatibilità

  • Contesto sicuro: la maggior parte delle funzioni crittografiche richiede HTTPS o localhost.
  • API asincrona: crypto.subtle è basata su Promise; userai async/await.
  • Algoritmi supportati: tipicamente AES-GCM, RSA-OAEP, ECDSA, PBKDF2, SHA-256/384/512. La disponibilità può variare su browser molto vecchi.

Glossario essenziale

  • Hash: funzione unidirezionale che produce una “impronta” (es. SHA-256). Non è cifratura.
  • Cifratura simmetrica: stessa chiave per cifrare/decifrare (AES). Richiede scambio sicuro della chiave.
  • Cifratura asimmetrica: coppia di chiavi pubblica/privata (RSA). Utile per scambio chiavi o cifrare piccoli dati.
  • Firma digitale: con chiave privata firmi, con pubblica verifichi integrità e autenticità (ECDSA/RSA-PSS).
  • KDF: funzione di derivazione chiave da password (PBKDF2, scrypt, Argon2). In Web Crypto, PBKDF2 è lo standard.
  • IV/nonce: valore casuale/unique per cifrature AEAD come AES-GCM. Non va mai riutilizzato con la stessa chiave.

Casualità: crypto.getRandomValues

Evita Math.random() per qualsiasi uso crittografico. Per generare byte casuali usa crypto.getRandomValues, basato su un CSPRNG (generatore pseudocasuale crittograficamente sicuro).

function randomBytes(length) {
  const bytes = new Uint8Array(length);
  crypto.getRandomValues(bytes);
  return bytes;
}

// Esempio: 12 byte per un nonce/IV AES-GCM
const iv = randomBytes(12);
console.log(iv);

Conversioni utili: stringhe, ArrayBuffer e Base64

La Web Crypto API lavora con ArrayBuffer/TypedArray. Per gestire testo, usa TextEncoder e TextDecoder. Per trasportare dati binari in JSON, spesso conviene Base64.

const enc = new TextEncoder();
const dec = new TextDecoder();

function utf8ToBytes(str) {
  return enc.encode(str);
}

function bytesToUtf8(bytes) {
  return dec.decode(bytes);
}

// Base64 <-> Uint8Array (senza dipendenze)
function bytesToBase64(bytes) {
  let binary = "";
  for (const b of bytes) binary += String.fromCharCode(b);
  return btoa(binary);
}

function base64ToBytes(b64) {
  const binary = atob(b64);
  const bytes = new Uint8Array(binary.length);
  for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
  return bytes;
}

Hashing con SHA-256

L’hash è utile per verificare integrità, creare identificatori o combinare dati in modo unidirezionale. Non usare l’hash per “nascondere” segreti: se l’input è prevedibile, un attaccante può fare brute force. Per password, serve un KDF con sale e iterazioni.

async function sha256Hex(message) {
  const data = utf8ToBytes(message);
  const digest = await crypto.subtle.digest("SHA-256", data);
  const bytes = new Uint8Array(digest);
  return [...bytes].map(b => b.toString(16).padStart(2, "0")).join("");
}

console.log(await sha256Hex("ciao mondo"));

Cifratura simmetrica moderna: AES-GCM

AES-GCM è una modalità AEAD: cifra e autentica insieme. Questo significa che, oltre a rendere il testo illeggibile, rileva modifiche (integrità) e produce un tag di autenticazione incluso nel ciphertext. È la scelta predefinita nel browser per cifratura di dati.

Generare una chiave AES-GCM

async function generateAesKey() {
  return crypto.subtle.generateKey(
    { name: "AES-GCM", length: 256 },
    true,                // extractable (valuta bene se ti serve esportarla)
    ["encrypt", "decrypt"]
  );
}

const aesKey = await generateAesKey();

Cifrare e decifrare

Con AES-GCM è fondamentale che l’IV (nonce) sia unico per ogni cifratura con la stessa chiave. In pratica: genera sempre un nuovo IV casuale di 12 byte e salvalo insieme al ciphertext.

async function aesGcmEncrypt(key, plaintext, aad = null) {
  const iv = randomBytes(12);
  const pt = utf8ToBytes(plaintext);

  const alg = { name: "AES-GCM", iv };
  if (aad) alg.additionalData = utf8ToBytes(aad);

  const ctBuffer = await crypto.subtle.encrypt(alg, key, pt);
  const ctBytes = new Uint8Array(ctBuffer);

  return {
    iv: bytesToBase64(iv),
    ciphertext: bytesToBase64(ctBytes),
    aad: aad ?? undefined
  };
}

async function aesGcmDecrypt(key, payload) {
  const iv = base64ToBytes(payload.iv);
  const ct = base64ToBytes(payload.ciphertext);

  const alg = { name: "AES-GCM", iv };
  if (payload.aad) alg.additionalData = utf8ToBytes(payload.aad);

  const ptBuffer = await crypto.subtle.decrypt(alg, key, ct);
  return bytesToUtf8(new Uint8Array(ptBuffer));
}

// Demo
const encrypted = await aesGcmEncrypt(aesKey, "Segreto nel browser", "meta:esempio");
const decrypted = await aesGcmDecrypt(aesKey, encrypted);
console.log(encrypted, decrypted);

Esportare e importare una chiave AES

Se ti serve persistere una chiave (ad esempio in IndexedDB), puoi esportarla in formato “raw”. Nota: rendere una chiave extractable facilita l’esportazione ma aumenta la superficie d’attacco (una XSS potrebbe rubarla). Valuta attentamente il modello di minaccia.

async function exportAesKeyRaw(key) {
  const raw = await crypto.subtle.exportKey("raw", key);
  return bytesToBase64(new Uint8Array(raw));
}

async function importAesKeyRaw(b64) {
  const raw = base64ToBytes(b64);
  return crypto.subtle.importKey(
    "raw",
    raw,
    { name: "AES-GCM" },
    true,
    ["encrypt", "decrypt"]
  );
}

Derivare una chiave da password: PBKDF2

Le password umane hanno bassa entropia. Per trasformarle in chiavi crittografiche devi usare una KDF: sale casuale + molte iterazioni. PBKDF2 è disponibile nativamente in Web Crypto. Parametri consigliati: sale unico (almeno 16 byte casuali) e un numero di iterazioni calibrato (spesso centinaia di migliaia o più), bilanciando sicurezza e prestazioni sul dispositivo target.

async function deriveAesKeyFromPassword(password, saltBytes, iterations = 310000) {
  const baseKey = await crypto.subtle.importKey(
    "raw",
    utf8ToBytes(password),
    "PBKDF2",
    false,
    ["deriveKey"]
  );

  return crypto.subtle.deriveKey(
    {
      name: "PBKDF2",
      salt: saltBytes,
      iterations,
      hash: "SHA-256"
    },
    baseKey,
    { name: "AES-GCM", length: 256 },
    true,
    ["encrypt", "decrypt"]
  );
}

// Uso tipico: genera sale e conservalo insieme al ciphertext
const salt = randomBytes(16);
const keyFromPwd = await deriveAesKeyFromPassword("passphrase forte", salt);

// Cifra usando la chiave derivata
const sealed = await aesGcmEncrypt(keyFromPwd, "Dati cifrati con password");
sealed.salt = bytesToBase64(salt);
console.log(sealed);

Quando devi decifrare, recuperi il sale e ri-derivi la chiave con la stessa password e lo stesso numero di iterazioni.

async function decryptWithPassword(password, payload, iterations = 310000) {
  const salt = base64ToBytes(payload.salt);
  const key = await deriveAesKeyFromPassword(password, salt, iterations);
  return aesGcmDecrypt(key, payload);
}

Cifratura asimmetrica nel browser: RSA-OAEP

L’asimmetrica è utile soprattutto per scambiare chiavi o cifrare piccoli messaggi (ad esempio una chiave AES). RSA-OAEP è la scelta tipica per cifratura con RSA. Per grandi quantità di dati, usa invece un approccio ibrido: RSA per cifrare la chiave simmetrica, AES-GCM per cifrare i dati.

Generare una coppia di chiavi RSA-OAEP

async function generateRsaOaepKeyPair() {
  return crypto.subtle.generateKey(
    {
      name: "RSA-OAEP",
      modulusLength: 2048,
      publicExponent: new Uint8Array([0x01, 0x00, 0x01]),
      hash: "SHA-256"
    },
    true,
    ["encrypt", "decrypt"]
  );
}

const rsa = await generateRsaOaepKeyPair();

Cifrare una chiave AES (approccio ibrido)

async function wrapAesKeyWithRsa(publicKey, aesKey) {
  // Esporta AES in raw e cifra con RSA-OAEP
  const rawAes = await crypto.subtle.exportKey("raw", aesKey);
  const wrapped = await crypto.subtle.encrypt(
    { name: "RSA-OAEP" },
    publicKey,
    rawAes
  );
  return bytesToBase64(new Uint8Array(wrapped));
}

async function unwrapAesKeyWithRsa(privateKey, wrappedB64) {
  const wrapped = base64ToBytes(wrappedB64);
  const rawAes = await crypto.subtle.decrypt(
    { name: "RSA-OAEP" },
    privateKey,
    wrapped
  );
  return crypto.subtle.importKey(
    "raw",
    rawAes,
    { name: "AES-GCM" },
    true,
    ["encrypt", "decrypt"]
  );
}

// Demo ibrida
const sessionKey = await generateAesKey();
const wrappedKey = await wrapAesKeyWithRsa(rsa.publicKey, sessionKey);
const recoveredKey = await unwrapAesKeyWithRsa(rsa.privateKey, wrappedKey);

const msg = await aesGcmEncrypt(recoveredKey, "Messaggio con chiave di sessione");
console.log(await aesGcmDecrypt(sessionKey, msg));

Firma digitale: ECDSA

Se vuoi garantire che un messaggio provenga da chi possiede una certa chiave privata e che non sia stato alterato, usa una firma digitale. In Web Crypto, ECDSA con curva P-256 è molto diffuso.

async function generateEcdsaKeyPair() {
  return crypto.subtle.generateKey(
    { name: "ECDSA", namedCurve: "P-256" },
    true,
    ["sign", "verify"]
  );
}

async function signMessage(privateKey, message) {
  const sig = await crypto.subtle.sign(
    { name: "ECDSA", hash: "SHA-256" },
    privateKey,
    utf8ToBytes(message)
  );
  return bytesToBase64(new Uint8Array(sig));
}

async function verifyMessage(publicKey, message, signatureB64) {
  const sig = base64ToBytes(signatureB64);
  return crypto.subtle.verify(
    { name: "ECDSA", hash: "SHA-256" },
    publicKey,
    sig,
    utf8ToBytes(message)
  );
}

// Demo
const ecdsa = await generateEcdsaKeyPair();
const data = "Ordine #123: paga 49.90 EUR";
const signature = await signMessage(ecdsa.privateKey, data);
console.log("Firma valida?", await verifyMessage(ecdsa.publicKey, data, signature));

Esportare chiavi pubbliche in formato standard

Per interoperabilità, le chiavi pubbliche si esportano spesso come spki (public key), mentre le private come pkcs8. Puoi serializzarle in Base64 per trasportarle.

async function exportPublicKeySpki(publicKey) {
  const spki = await crypto.subtle.exportKey("spki", publicKey);
  return bytesToBase64(new Uint8Array(spki));
}

async function exportPrivateKeyPkcs8(privateKey) {
  const pkcs8 = await crypto.subtle.exportKey("pkcs8", privateKey);
  return bytesToBase64(new Uint8Array(pkcs8));
}

async function importRsaPublicKeySpki(spkiB64, hash = "SHA-256") {
  const spki = base64ToBytes(spkiB64);
  return crypto.subtle.importKey(
    "spki",
    spki,
    { name: "RSA-OAEP", hash },
    true,
    ["encrypt"]
  );
}

Linee guida e trappole comuni

  • Non reinventare primitive: evita implementazioni homemade di AES/RSA in JS. Usa Web Crypto o librerie mature se serve compatibilità particolare.
  • Non riutilizzare l’IV in AES-GCM: con la stessa chiave è un errore grave. Genera IV casuali e unici, salva IV insieme al ciphertext.
  • Non confondere hash e cifratura: l’hash non si “decifra”. Per password usa PBKDF2 con sale e molte iterazioni.
  • Proteggi dalle XSS: se un attaccante esegue JS nella tua pagina, può rubare chiavi e plaintext. La crittografia nel browser non sostituisce CSP, sanitizzazione e hardening.
  • Gestione chiavi: se esporti chiavi (extractable) e le conservi in chiaro, stai spostando il problema. Preferisci conservazione protetta (es. chiavi non estraibili, isolamento, flussi E2EE).
  • Parametri KDF: scegli iterazioni PBKDF2 adeguate al target. Troppo basse facilitano attacchi offline; troppo alte degradano UX.
  • Verifica autenticità: la cifratura da sola non autentica chi invia. Usa firme o protocolli E2EE completi se ti serve autenticazione.
  • Attenzione ai canali laterali: evita confronti di stringhe per segreti con tempi variabili; per token sensibili, cerca confronti a tempo costante lato server. Nel browser è più difficile, ma vale la regola di minimizzare dati sensibili esposti.

Pattern pratici: payload JSON “self-contained”

Un modo semplice di trasportare dati cifrati è creare un oggetto con metadati minimi: algoritmo, IV, sale (se usi password), ciphertext. Un esempio:

{
  "alg": "AES-256-GCM",
  "iv": "Base64...",
  "salt": "Base64...",       // opzionale, se la chiave è derivata da password
  "iter": 310000,            // opzionale
  "ciphertext": "Base64...",
  "aad": "meta:..."          // opzionale
}

Evita di inserire informazioni inutili e non mettere mai la password nel payload. Se usi AAD (additional authenticated data), ricorda che non è cifrato ma è autenticato: serve per vincolare la decifratura a un contesto (es. userId, versione schema).

Quando usare librerie esterne

Web Crypto copre la maggior parte dei casi d’uso moderni, ma potresti voler una libreria esterna per:

  • Compatibilità con formati o algoritmi non supportati (es. curve particolari, protocolli specifici).
  • Implementazioni complete di protocolli E2EE (double ratchet, X3DH) dove la corretta progettazione è più importante delle primitive.
  • Utilità di serializzazione/encoding avanzate (ASN.1, JWK, PEM) e gestione certificati.

Checklist finale

  1. Usa crypto.getRandomValues per sale/IV/nonce.
  2. Per cifrare dati nel browser, preferisci AES-GCM con IV unico.
  3. Per password, usa PBKDF2 con sale casuale e molte iterazioni.
  4. Per scambio chiavi o invio sicuro di una chiave di sessione, usa approccio ibrido: RSA-OAEP + AES-GCM.
  5. Per autenticità e non ripudio, usa ECDSA (o RSA-PSS) e verifica firme.
  6. Tratta XSS come nemico principale: la crittografia client-side non ti salva se l’attaccante esegue script.

Con queste basi puoi costruire funzioni robuste e riusabili per proteggere dati nel browser. Se il tuo obiettivo è una vera cifratura end-to-end tra utenti, considera protocolli dedicati e una modellazione esplicita delle minacce: chi può leggere cosa, quando e dove.

Torna su