Ir al contenido
HexaTransfer
Volver al blog
Cifrado y seguridad

Tutorial cifrado del lado del cliente: constrúyelo desde cero

Tutorial paso a paso para implementar cifrado del lado del cliente. Cifra archivos en el navegador antes de que salgan del dispositivo.

El cifrado de archivos en el navegador requiere aproximadamente 80 líneas de JavaScript usando la Web Crypto API. El patrón es claro: genera una clave AES-256-GCM en el navegador, cifra el archivo con un nonce aleatorio de 96 bits, sube el criptotexto por HTTPS/TLS 1.3, y comparte la URL resultante con la clave incrustada en el identificador de fragmento (#key=...), que los navegadores nunca transmiten a los servidores. El destinatario descifra en el navegador usando ese mismo fragmento. Este tutorial recorre una implementación funcional, incluyendo fragmentación para archivos grandes, claves derivadas de contraseña vía PBKDF2 con 600.000 iteraciones, y los errores que rompen los primeros intentos.

La arquitectura en un diagrama

[Navegador emisor]                     [Servidor]                   [Navegador receptor]
  Lee archivo → clave AES (aleatoria)   Acepta POST                   GET criptotexto
  Cifra con AES-256-GCM                 Almacena blob cifrado         Extrae clave del fragmento #
  POST criptotexto                      Sin clave, sin texto plano    Descifra en navegador
  Construye URL con #key=...            Devuelve URL de descarga      Guarda archivo en disco

El servidor es un almacén de blobs mudo. Solo ve criptotexto y no puede descifrarlo. La clave de descifrado vive en el fragmento de la URL, que los navegadores tratan de forma especial: nunca se envía en la línea de la petición HTTP. Esta es la base de todos los servicios de transferencia de archivos de conocimiento cero, incluido HexaTransfer.

Paso 1: genera una clave simétrica

async function generateKey() {
  return await crypto.subtle.generateKey(
    { name: "AES-GCM", length: 256 },
    true, // extractable para poder exportarla a la URL
    ["encrypt", "decrypt"]
  );
}

El indicador extractable: true es obligatorio porque necesitas serializar la clave en un fragmento de URL. Si construyes un flujo donde la clave solo vive en memoria (por ejemplo, una herramienta de pegar y enviar), ponlo a false.

Paso 2: lee el archivo como ArrayBuffer

async function readFile(file) {
  return new Promise((resolve, reject) => {
    const reader = new FileReader();
    reader.onload = () => resolve(reader.result);
    reader.onerror = () => reject(reader.error);
    reader.readAsArrayBuffer(file);
  });
}

Esto carga el archivo completo en memoria. Está bien para archivos de menos de 500 MB. Para archivos más grandes, salta directamente a la sección de streaming.

Paso 3: cifra el buffer

async function encryptFile(key, plaintext) {
  const iv = crypto.getRandomValues(new Uint8Array(12));
  const ciphertext = await crypto.subtle.encrypt(
    { name: "AES-GCM", iv },
    key,
    plaintext
  );
  // Prepend del IV al criptotexto para que el receptor pueda extraerlo
  const combined = new Uint8Array(iv.length + ciphertext.byteLength);
  combined.set(iv, 0);
  combined.set(new Uint8Array(ciphertext), iv.length);
  return combined.buffer;
}

El nonce (IV) es de 96 bits (12 bytes), según NIST SP 800-38D. No es secreto pero debe ser único por clave. Los nonces aleatorios son seguros aquí porque generamos una clave nueva por archivo. Anteponer el IV al criptotexto es una convención habitual; el receptor lo separa antes de descifrar.

Paso 4: sube el criptotexto

async function uploadCiphertext(ciphertext) {
  const response = await fetch("/api/upload", {
    method: "POST",
    body: ciphertext,
    headers: { "Content-Type": "application/octet-stream" },
  });
  const { fileId } = await response.json();
  return fileId;
}

El servidor recibe un blob binario, le asigna un ID, lo almacena y devuelve ese ID. Ninguna cabecera revela el nombre del archivo, ningún parámetro de consulta porta la clave. Si el disco duro del servidor es robado mañana, un atacante verá ruido.

Paso 5: construye la URL compartida con la clave en el fragmento

async function buildShareURL(fileId, key) {
  const rawKey = await crypto.subtle.exportKey("raw", key);
  const keyBase64 = btoa(String.fromCharCode(...new Uint8Array(rawKey)))
    .replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
  return `${location.origin}/f/${fileId}#${keyBase64}`;
}

La codificación base64url (con - y _ en lugar de + y /) evita problemas de escape en URLs. El relleno = se elimina por estética.

El fragmento (#...) es la magia aquí. Cuando el receptor carga la URL, el navegador mantiene el fragmento en el lado del cliente. La petición GET a /f/{fileId} no incluye #keyBase64 en la línea de la petición, así que el servidor nunca conoce la clave. Verifícalo tú mismo abriendo las herramientas de desarrollo del navegador en cualquier URL con fragmento y observando la pestaña Red.

Paso 6: descifrado en el lado del receptor

async function downloadAndDecrypt() {
  const fileId = location.pathname.split("/").pop();
  const keyBase64 = location.hash.slice(1);
  const rawKey = Uint8Array.from(
    atob(keyBase64.replace(/-/g, "+").replace(/_/g, "/")),
    c => c.charCodeAt(0)
  );
  const key = await crypto.subtle.importKey(
    "raw", rawKey, "AES-GCM", false, ["decrypt"]
  );
  const response = await fetch(`/api/download/${fileId}`);
  const combined = new Uint8Array(await response.arrayBuffer());
  const iv = combined.slice(0, 12);
  const ciphertext = combined.slice(12);
  const plaintext = await crypto.subtle.decrypt(
    { name: "AES-GCM", iv }, key, ciphertext
  );
  const blob = new Blob([plaintext]);
  const url = URL.createObjectURL(blob);
  const a = document.createElement("a");
  a.href = url;
  a.download = "archivo-descargado";
  a.click();
}

La etiqueta de autenticación de GCM se verifica durante decrypt(). Si el criptotexto fue manipulado, la llamada lanza OperationError, un modo de fallo limpio.

Claves derivadas de contraseña vía PBKDF2

Si el usuario proporciona una contraseña en lugar de una clave aleatoria, deriva la clave AES vía PBKDF2:

async function deriveKey(password, salt) {
  const passwordKey = await crypto.subtle.importKey(
    "raw", new TextEncoder().encode(password),
    "PBKDF2", false, ["deriveKey"]
  );
  return await crypto.subtle.deriveKey(
    {
      name: "PBKDF2",
      salt,
      iterations: 600000,
      hash: "SHA-256",
    },
    passwordKey,
    { name: "AES-GCM", length: 256 },
    false,
    ["encrypt", "decrypt"]
  );
}

600.000 iteraciones de PBKDF2-SHA-256 es el mínimo de OWASP de 2023. El salt debe ser de 16 bytes aleatorios y almacenarse junto al criptotexto (no es secreto, solo debe ser único). Para código nuevo, considera Argon2id vía una biblioteca como argon2-browser: resiste mucho mejor los ataques con GPU que PBKDF2.

Streaming de archivos grandes

Los archivos de más de 500 MB deben fragmentarse. Lee vía File.stream(), cifra cada fragmento y sube de forma secuencial:

async function encryptStream(file, key) {
  const reader = file.stream().getReader();
  const chunks = [];
  let chunkIndex = 0;
  while (true) {
    const { done, value } = await reader.read();
    if (done) break;
    const iv = new Uint8Array(12);
    // Codifica el índice del fragmento en el nonce para garantizar unicidad
    new DataView(iv.buffer).setBigUint64(4, BigInt(chunkIndex++));
    const ct = await crypto.subtle.encrypt(
      { name: "AES-GCM", iv }, key, value
    );
    chunks.push({ iv, ct });
  }
  return chunks;
}

Derivar el nonce del índice del fragmento garantiza la unicidad sin rastrear estado. El reensamblado en el lado del receptor descifra los fragmentos en orden y los concatena.

Para AEAD con verdadero streaming, crypto_secretstream_xchacha20poly1305 de libsodium vía libsodium.js es más limpio y detecta ataques de truncamiento. Web Crypto no tiene un primitivo equivalente en 2026.

Pruebas y errores frecuentes

Errores comunes que debes evitar:

  • Usar Math.random() para claves o nonces: usa siempre crypto.getRandomValues().
  • Reutilizar un nonce con la misma clave: rompe la seguridad de GCM. Las claves aleatorias por archivo hacen esto seguro; los flujos por fragmentos necesitan nonces únicos por fragmento.
  • No comprobar HTTPS: crypto.subtle es undefined en orígenes inseguros. Prueba en localhost o con un certificado autofirmado durante el desarrollo.
  • Almacenar claves en localStorage: cualquier XSS en tu origen puede leerlo. Usa el patrón de fragmento de URL, o claves no extraíbles.
  • Olvidar incluir el IV con el criptotexto: el descifrado falla sin un error útil. Prepende siempre o serializa junto al criptotexto.
  • Mala gestión del fragmento: no publiques accidentalmente la URL (con fragmento) en un servicio de terceros. Compártela solo por canales extremo a extremo si el fragmento es sensible.

Responsabilidades del servidor

El trabajo del servidor en una arquitectura de cifrado del lado del cliente es pequeño: aceptar POST, almacenar blob, devolver ID, servir GET del blob, eliminar al expirar. Sin criptografía. Lo que el servidor sí debe hacer más allá del almacenamiento:

  • Imponer límites de tamaño de archivo (prevenir abusos)
  • Limitar la tasa de subidas y descargas
  • Establecer retención corta (7 días es un valor razonable por defecto, como HexaTransfer)
  • Registrar solo lo necesario (marca de tiempo de subida, sin IPs si se prioriza la privacidad)
  • Servir sobre TLS 1.3 con HSTS
  • Cabeceras CORS que restrinjan orígenes si la API solo se llama desde tus dominios

Uniéndolo todo

Una aplicación mínima funcional cabe en un archivo HTML más un backend de 50 líneas en Express. Dependencias totales: ninguna en el cliente (Web Crypto es nativa), Express más multer en el servidor. El cifrado es tan sólido como el primitivo AES-256-GCM porque eso es literalmente lo que estás usando. No hay ningún algoritmo secreto que puedas equivocarte, solo los primitivos a usar correctamente.

Las partes más difíciles son los casos límite: archivos grandes, flujos de contraseña a clave, la UX del receptor cuando falla el descifrado, y gestionar enlaces caducados con gracia. La criptografía central es directa.

Pruébalo en hexatransfer.com — gratis, sin cuenta, hasta 10 GB.

Envía archivos grandes de forma segura con cifrado de extremo a extremo

Transfiere archivos de hasta 10 GB gratis con cifrado de extremo a extremo. Sin necesidad de cuenta. Tus archivos se cifran en tu navegador antes de subirlos: nadie más puede leerlos.

Enviar un archivo