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 siemprecrypto.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.subtlees 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