Codice aperto alla revisione

Modulo crittografico del client

Questo è il codice che cifra e decifra i messaggi nel browser: generazione della coppia di chiavi RSA-OAEP 2048, cifratura ibrida AES-256-GCM, protezione della chiave privata con PBKDF2 a 250.000 iterazioni e cifratura degli allegati. La pagina serve direttamente il file usato in produzione, quindi non può divergere dalla versione in esecuzione: chiunque può verificare che nessuna chiave e nessun testo in chiaro venga inviato al server.

Nota: è pubblicato il modulo crittografico, non l'intera applicazione. Segnalazioni e revisioni a info@mailcripty.com.

/**
 * MAILCRIPTY — primitive crittografiche end-to-end (Web Crypto API).
 * Tutto gira nel browser: il server vede solo blob cifrati.
 */

export type EncryptedBlob = {
  salt: string;
  iv: string;
  data: string;
};

export type EncryptedMessage = {
  iv: string;
  ciphertext: string;
  encryptedAesKey: string;
};

const buf2b64 = (buf: ArrayBuffer | Uint8Array): string => {
  const bytes = buf instanceof Uint8Array ? buf : new Uint8Array(buf);
  let binary = "";
  for (let i = 0; i < bytes.length; i += 1) binary += String.fromCharCode(bytes[i]!);
  return btoa(binary);
};

const toArrayBuffer = (u: Uint8Array): ArrayBuffer => {
  const out = new ArrayBuffer(u.byteLength);
  new Uint8Array(out).set(u);
  return out;
};

const b642buf = (b64: string): ArrayBuffer =>
  toArrayBuffer(Uint8Array.from(atob(b64), (c) => c.charCodeAt(0)));

const str2buf = (str: string): ArrayBuffer => toArrayBuffer(new TextEncoder().encode(str));
const buf2str = (buf: ArrayBuffer): string => new TextDecoder().decode(buf);

const subtle = () => {
  if (typeof window === "undefined" || !window.crypto?.subtle) {
    throw new Error("Crittografia non disponibile in questo contesto");
  }
  return window.crypto.subtle;
};

export async function generateKeyPair(): Promise<CryptoKeyPair> {
  return subtle().generateKey(
    {
      name: "RSA-OAEP",
      modulusLength: 2048,
      publicExponent: new Uint8Array([1, 0, 1]),
      hash: "SHA-256",
    },
    true,
    ["encrypt", "decrypt"],
  ) as Promise<CryptoKeyPair>;
}

export async function exportKey(key: CryptoKey): Promise<string> {
  return JSON.stringify(await subtle().exportKey("jwk", key));
}

export async function importPublicKey(jwkStr: string): Promise<CryptoKey> {
  return subtle().importKey(
    "jwk",
    JSON.parse(jwkStr) as JsonWebKey,
    { name: "RSA-OAEP", hash: "SHA-256" },
    true,
    ["encrypt"],
  );
}

export async function importPrivateKey(jwkStr: string): Promise<CryptoKey> {
  return subtle().importKey(
    "jwk",
    JSON.parse(jwkStr) as JsonWebKey,
    { name: "RSA-OAEP", hash: "SHA-256" },
    true,
    ["decrypt"],
  );
}

async function deriveKeyFromPassword(password: string, salt: BufferSource): Promise<CryptoKey> {
  const keyMaterial = await subtle().importKey("raw", str2buf(password), { name: "PBKDF2" }, false, [
    "deriveKey",
  ]);
  return subtle().deriveKey(
    { name: "PBKDF2", salt, iterations: 250_000, hash: "SHA-256" },
    keyMaterial,
    { name: "AES-GCM", length: 256 },
    false,
    ["encrypt", "decrypt"],
  );
}

/** Cifra la chiave privata con la Master Password (zero-knowledge). */
export async function encryptPrivateKey(
  privateKey: CryptoKey,
  password: string,
): Promise<EncryptedBlob> {
  const salt = window.crypto.getRandomValues(new Uint8Array(16));
  const iv = window.crypto.getRandomValues(new Uint8Array(12));
  const wrappingKey = await deriveKeyFromPassword(password, salt);
  const data = str2buf(await exportKey(privateKey));
  const encrypted = await subtle().encrypt(
    { name: "AES-GCM", iv: iv },
    wrappingKey,
    data,
  );
  return { salt: buf2b64(salt), iv: buf2b64(iv), data: buf2b64(encrypted) };
}

export async function decryptPrivateKey(
  blob: EncryptedBlob,
  password: string,
): Promise<CryptoKey> {
  const wrappingKey = await deriveKeyFromPassword(password, b642buf(blob.salt));
  const decrypted = await subtle().decrypt(
    { name: "AES-GCM", iv: b642buf(blob.iv) },
    wrappingKey,
    b642buf(blob.data),
  );
  return importPrivateKey(buf2str(decrypted));
}

/** Cifratura ibrida: AES-GCM per il testo, RSA-OAEP per la chiave AES. */
export async function encryptMessage(
  plaintext: string,
  recipientPublicKeyJwk: string,
): Promise<EncryptedMessage> {
  const recipientPublicKey = await importPublicKey(recipientPublicKeyJwk);
  const aesKey = await subtle().generateKey({ name: "AES-GCM", length: 256 }, true, [
    "encrypt",
    "decrypt",
  ]);
  const iv = window.crypto.getRandomValues(new Uint8Array(12));
  const ciphertext = await subtle().encrypt(
    { name: "AES-GCM", iv: iv },
    aesKey,
    str2buf(plaintext),
  );
  const exportedAesKey = await subtle().exportKey("raw", aesKey);
  const encryptedAesKey = await subtle().encrypt(
    { name: "RSA-OAEP" },
    recipientPublicKey,
    exportedAesKey,
  );
  return {
    iv: buf2b64(iv),
    ciphertext: buf2b64(ciphertext),
    encryptedAesKey: buf2b64(encryptedAesKey),
  };
}

export async function decryptMessage(
  message: EncryptedMessage,
  privateKey: CryptoKey,
): Promise<string> {
  const rawAesKey = await subtle().decrypt(
    { name: "RSA-OAEP" },
    privateKey,
    b642buf(message.encryptedAesKey),
  );
  const aesKey = await subtle().importKey("raw", rawAesKey, { name: "AES-GCM" }, false, [
    "decrypt",
  ]);
  const plaintext = await subtle().decrypt(
    { name: "AES-GCM", iv: b642buf(message.iv) },
    aesKey,
    b642buf(message.ciphertext),
  );
  return buf2str(plaintext);
}

/** Cifra dati binari (allegati) con AES-GCM; la chiave AES viene protetta con RSA per ogni destinatario. */
export async function encryptBytes(
  data: ArrayBuffer,
  publicKeysJwk: string[],
): Promise<{ bytes: ArrayBuffer; iv: string; wrappedKeys: string[] }> {
  const aesKey = await subtle().generateKey({ name: "AES-GCM", length: 256 }, true, [
    "encrypt",
    "decrypt",
  ]);
  const iv = window.crypto.getRandomValues(new Uint8Array(12));
  const bytes = await subtle().encrypt({ name: "AES-GCM", iv: iv }, aesKey, data);
  const rawAes = await subtle().exportKey("raw", aesKey);
  const wrappedKeys: string[] = [];
  for (const jwk of publicKeysJwk) {
    const pub = await importPublicKey(jwk);
    wrappedKeys.push(buf2b64(await subtle().encrypt({ name: "RSA-OAEP" }, pub, rawAes)));
  }
  return { bytes, iv: buf2b64(iv), wrappedKeys };
}

export async function decryptBytes(
  bytes: ArrayBuffer,
  iv: string,
  wrappedKey: string,
  privateKey: CryptoKey,
): Promise<ArrayBuffer> {
  const rawAes = await subtle().decrypt({ name: "RSA-OAEP" }, privateKey, b642buf(wrappedKey));
  const aesKey = await subtle().importKey("raw", rawAes, { name: "AES-GCM" }, false, ["decrypt"]);
  return subtle().decrypt({ name: "AES-GCM", iv: b642buf(iv) }, aesKey, bytes);
}

export function fingerprint(publicKeyJwk: string): string {
  const jwk = JSON.parse(publicKeyJwk) as { n?: string };
  const n = jwk.n ?? "";
  return (n.slice(0, 4) + n.slice(-8))
    .replace(/[^a-zA-Z0-9]/g, "")
    .toUpperCase()
    .match(/.{1,4}/g)
    ?.join(" ") ?? "—";
}