Ir al contenido
HexaTransfer
Volver al blog
Analisis tecnicos

Construye una aplicación de compartir archivos cifrada desde cero

Tutorial paso a paso para construir una aplicación de compartir archivos cifrada. Cifrado en el frontend, backend seguro y despliegue completo.

El cifrado de extremo a extremo en una aplicación de compartir archivos solo funciona si la clave nunca toca el servidor. La AEPD lo deja claro en sus guías sobre el RGPD: tratar ciphertext opaco como dato personal exige mucho menos que tratar ficheros en claro. Así que la decisión de arquitectura más importante no es el proveedor de nube ni el framework: es dónde vive la clave. La respuesta correcta es en el fragmento de la URL, donde el navegador garantiza que jamás se envía al servidor.

Este tutorial muestra cómo construir una aplicación funcional con Vite + React en el frontend, Fastify en el backend, almacenamiento compatible con S3 (Cloudflare R2 o Backblaze B2), y un enlace de descarga donde la clave reside tras el #. El resultado cabe en unas 400 líneas de código y cuesta menos de 5 € al mes en producción.

La decisión de arquitectura que más importa

Genera una clave AES-256-GCM aleatoria de 256 bits en el navegador, cifra el archivo con ella, sube el ciphertext y coloca la clave en el fragmento de URL: https://tuapp.com/f/abc123#k=base64clave. Los navegadores nunca envían fragmentos en peticiones HTTP, así que la clave permanece en el cliente.

Segunda decisión obligatoria: subidas fragmentadas. Para archivos superiores a 100 MB necesitas multipart reanudable, o cualquier Wi-Fi inestable matará la transferencia. La API multipart de S3 admite fragmentos de mínimo 5 MB y hasta 10.000 partes, lo que da un techo de 50 GB. Planifícalo desde el primer día.

Estructura del proyecto

Parte con dos paquetes: un frontend Vite + React y un backend Fastify. El frontend gestiona todo el cifrado; el backend gestiona el almacenamiento y los metadatos. Usa TypeScript para obtener seguridad de tipos en ArrayBuffer y CryptoKey.

pnpm create vite@latest frontend -- --template react-ts
pnpm create fastify backend

Añade al backend: @fastify/multipart, @aws-sdk/client-s3, @aws-sdk/s3-request-presigner y better-sqlite3 para los metadatos de los enlaces. El esquema SQLite puede ser mínimo: shares(id, object_key, size_bytes, expires_at, download_count, max_downloads). Sin nombres de archivo, sin datos de usuario, sin IPs.

Pipeline de cifrado en el frontend

Genera una clave, cifra con AES-256-GCM y produce un blob de ciphertext más una clave en base64 para el fragmento de URL:

async function encryptFile(file: File) {
  const key = await crypto.subtle.generateKey(
    { name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt']
  );
  const iv = crypto.getRandomValues(new Uint8Array(12));
  const plaintext = await file.arrayBuffer();
  const ciphertext = await crypto.subtle.encrypt(
    { name: 'AES-GCM', iv }, key, plaintext
  );
  const rawKey = await crypto.subtle.exportKey('raw', key);
  const blob = new Blob([iv, new Uint8Array(ciphertext)]);
  return { blob, keyBase64: toBase64(rawKey) };
}

Para archivos superiores a 50 MB, sustituye esto por una versión en streaming que cifre fragmentos de 4 MB y los concatene en un ReadableStream. El heap de Safari móvil se satura con más de 400 MB en un solo ArrayBuffer.

Endpoint de subida: el servidor no sabe nada útil

El backend acepta un POST con el ciphertext, genera un ID aleatorio de 16 caracteres, lo almacena en SQLite con caducidad y envía el cuerpo directamente a S3:

fastify.post('/upload', async (req, reply) => {
  const id = nanoid(16);
  const key = `blobs/${id}`;
  const upload = new Upload({
    client: s3,
    params: { Bucket: 'hexa-transfers', Key: key, Body: req.raw }
  });
  await upload.done();
  db.prepare('INSERT INTO shares VALUES (?, ?, ?, ?, 0, ?)').run(
    id, key, req.headers['content-length'], Date.now() + 7*86400*1000, 10
  );
  return { id };
});

Caducidad predeterminada de 7 días, máximo 10 descargas. Los valores agresivos son la alternativa al crecimiento ilimitado del almacenamiento. Usa un cron para eliminar blobs caducados cada noche.

Generar enlaces de descarga con la clave en el fragmento

Cuando la subida termina, construye la URL de compartición en el cliente:

const { id } = await uploadResponse.json();
const shareUrl = `${location.origin}/f/${id}#k=${keyBase64}`;

Ese fragmento nunca sale del navegador del usuario. Cuando el destinatario hace clic en el enlace, la app React lee window.location.hash, analiza la clave, descarga el ciphertext y descifra localmente. El servidor no tiene acceso al texto claro salvo que inyecte JavaScript malicioso, que es exactamente por qué debes desplegar una Content Security Policy estricta y anclar hashes de subrecursos.

Flujo de descarga

En la página de descarga, obtén el ciphertext como stream y descifra alineado con los fragmentos:

const keyRaw = base64ToBytes(location.hash.slice(3));
const key = await crypto.subtle.importKey(
  'raw', keyRaw, { name: 'AES-GCM' }, false, ['decrypt']
);
const resp = await fetch(`/blob/${id}`);
const iv = new Uint8Array(await resp.body.getReader().read().then(r => r.value.slice(0, 12)));
const ct = await resp.arrayBuffer();
const pt = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, ct.slice(12));
const url = URL.createObjectURL(new Blob([pt]));

Para archivos grandes, usa StreamSaver.js o la File System Access API para que los bytes descifrados lleguen directamente al disco en lugar de a la RAM.

Despliegue económico

Frontend: despliega en Cloudflare Pages o Vercel, ambos con nivel gratuito. Backend: un servidor de 5 € en DigitalOcean con Node 22 detrás de Caddy (TLS 1.3 automático vía Let's Encrypt). Almacenamiento: Cloudflare R2, con egreso gratuito. WeTransfer, Smash y SwissTransfer pagan una fortuna en egreso de AWS; R2 cambia la economía completamente.

Configura cabeceras estrictas en Caddy: Strict-Transport-Security, Content-Security-Policy: default-src 'self'; script-src 'self', X-Content-Type-Options: nosniff y Referrer-Policy: no-referrer. Estas cierran los vectores habituales para filtrar el fragmento de URL a través de referrers o scripts inyectados.

Cumplimiento del RGPD y la LOPDGDD por diseño

Dado que el servidor solo ve ciphertext, casi nada califica como dato personal según el artículo 4 del RGPD. Aun así, documenta tus relaciones con procesadores (R2 y DigitalOcean), establece una retención máxima de 30 días y publica un aviso claro de que los enlaces contienen claves y deben transmitirse por canales de confianza. La LOPDGDD exige medidas técnicas proporcionales al riesgo: el cifrado de extremo a extremo cubre esa exigencia con holgura. Añade limitación de tasa de 50 subidas por IP y hora para disuadir el abuso sin registrar identidades. INCIBE recomienda exactamente este enfoque en su guía de desarrollo seguro de aplicaciones web.

HexaTransfer está construido con esta arquitectura: Web Crypto en el navegador, almacenamiento opaco de blobs, claves en fragmentos de URL, sin texto claro en servidores. Pruébalo en https://hexatransfer.com — gratuito, sin cuenta, hasta 10 GB.

Qué añadir una vez que el MVP funcione

Una vez que el flujo básico esté operativo, las mejoras de mayor valor son: protección por contraseña sobre la clave del fragmento (PBKDF2 con 600.000 iteraciones), notificaciones de descarga por correo y reporte de abuso que permita marcar el hash del ciphertext sin revelar el contenido. Evita añadir cuentas de usuario a menos que tengas una razón específica: convierten tu herramienta de privacidad en una responsabilidad de datos de la noche a la mañana. Mantén la superficie pequeña y deja que el cifrado haga el trabajo pesado.

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