Documentación técnica detallada sobre la arquitectura criptográfica, el modelo de seguridad y la ingeniería de privacidad de PDF Pro. Escrita para usuarios preocupados por la seguridad, auditores y revisores.
Transferencia de archivos con cifrado de extremo a extremo
Libro blanco WP-001 de PDF Pro — Arquitectura y análisis de seguridad
Este libro blanco describe la arquitectura del sistema de Transferencia Segura de PDF Pro, un mecanismo de transferencia de archivos de conocimiento cero en el que los archivos se cifran en el lado del cliente utilizando AES-256-GCM antes de subirse. El servidor solo almacena texto cifrado opaco y no tiene capacidad para descifrar, inspeccionar o leer el contenido de los archivos en ningún momento. El sistema admite dos modos de clave: una clave aleatoria autogenerada (transportada a través del fragmento de la URL, que nunca se envía al servidor) o una clave derivada de una contraseña mediante PBKDF2. En ambos modos, la clave de cifrado existe únicamente en los navegadores del remitente y el destinatario. Este documento detalla las primitivas criptográficas, el flujo de datos, el modelo de amenazas y las limitaciones honestas del sistema.
El sistema de Transferencia Segura sigue una separación estricta cliente-servidor en la que todas las operaciones criptográficas ocurren exclusivamente en el navegador:
El servidor está diseñado intencionalmente para ser una "tubería tonta" para los datos cifrados. No tiene conocimiento de la clave de cifrado, la contraseña ni el contenido de los archivos. Esto se aplica de forma arquitectónica, no solo por política.
Principio de diseño: el servidor debería poder verse comprometido sin comprometer los datos de los usuarios. Incluso con acceso total a la base de datos y ejecución de código del lado del servidor, un atacante no puede descifrar los archivos transferidos.
Todo el cifrado de archivos usa el algoritmo de cifrado autenticado AES-256-GCM, accedido a través de la Web Crypto API nativa del navegador. Esto proporciona tanto confidencialidad (los datos no se pueden leer) como integridad (los datos no se pueden modificar sin que se detecte).
| Parámetro | Valor | Justificación |
|---|---|---|
| Algoritmo | AES-256-GCM | Aprobado por NIST, cifrado autenticado con datos asociados (AEAD) |
| Tamaño de clave | 256 bits | Longitud máxima de clave AES; proporciona 128 bits de seguridad frente a fuerza bruta |
| Tamaño del IV | 96 bits (12 bytes) | Longitud de IV recomendada por NIST para el modo GCM |
| Tamaño de la etiqueta | 128 bits (16 bytes) | Etiqueta de autenticación de longitud completa para máxima protección de integridad |
| Generación del IV | crypto.getRandomValues() | Generador de números aleatorios criptográficamente seguro |
// Flujo de cifrado simplificado (Web Crypto API) const iv = crypto.getRandomValues(new Uint8Array(12)); const salt = crypto.getRandomValues(new Uint8Array(16)); // Derivar la clave desde la contraseña (ver Sección 1.4) const key = await deriveKey(passphrase, salt); // Cifrar el archivo const ciphertext = await crypto.subtle.encrypt( { name: "AES-GCM", iv: iv }, key, fileArrayBuffer ); // Paquete: [salt (16B)] + [iv (12B)] + [ciphertext + tag] const blob = concatenate(salt, iv, ciphertext);
El blob cifrado final es la concatenación de tres componentes: el salt de 16 bytes (usado para la derivación de clave), el IV de 12 bytes (usado para AES-GCM) y el texto cifrado con la etiqueta de autenticación GCM de 16 bytes adjuntada. Este blob es lo que el servidor recibe y almacena.
La Transferencia Segura admite dos modos de clave mutuamente excluyentes. El modo lo elige el remitente en el momento de crear la transferencia.
En el modo predeterminado no interviene ninguna contraseña. El navegador genera directamente una clave AES de 256 bits criptográficamente aleatoria:
crypto.getRandomValues() y crypto.subtle.generateKey().#).Cuando el remitente opta por establecer una contraseña, la clave se deriva de esa contraseña mediante PBKDF2:
| Parámetro | Valor | Justificación |
|---|---|---|
| Algoritmo | PBKDF2 | Recomendado por NIST SP 800-132; ampliamente auditado |
| Función hash | SHA-256 | Hash criptográfico estándar; salida de 256 bits |
| Iteraciones | 600.000 | Cumple con la recomendación de OWASP 2023 para PBKDF2-SHA256 |
| Tamaño del salt | 128 bits (16 bytes) | Único por transferencia; previene ataques con tablas arcoíris |
| Longitud de la clave de salida | 256 bits | Coincide con el requisito de clave AES-256 |
async function deriveKey(passphrase, salt) { // Importar la contraseña como material de clave en bruto const keyMaterial = await crypto.subtle.importKey( "raw", new TextEncoder().encode(passphrase), { name: "PBKDF2" }, false, ["deriveKey"] ); // Derivar la clave AES-256-GCM return crypto.subtle.deriveKey( { name: "PBKDF2", salt: salt, iterations: 600000, hash: "SHA-256" }, keyMaterial, { name: "AES-GCM", length: 256 }, false, ["encrypt", "decrypt"] ); }
Nota de seguridad: en el Modo B la seguridad depende de la entropía de la contraseña. Recomendamos contraseñas de 12 caracteres o más que combinen mayúsculas, minúsculas, dígitos y símbolos.
El ciclo de vida completo de una transferencia segura sigue este recorrido:
crypto.getRandomValues().#).crypto.getRandomValues().| Elemento de datos | ¿El servidor tiene acceso? | Detalles |
|---|---|---|
| Blob cifrado (salt + IV + ciphertext + tag) | Sí | Datos binarios opacos; el servidor no puede interpretar el contenido |
| ID de la transferencia | Sí | Los identificadores de transferencia son valores UUID v4 generados por gen_random_uuid() de PostgreSQL, que proporcionan 122 bits de aleatoriedad criptográfica desde el CSPRNG del servidor |
| Nombre original del archivo | Sí | Almacenado en los metadatos para mostrarlo al destinatario |
| Tamaño original del archivo | Sí | Almacenado en los metadatos con fines de visualización |
| Marca de tiempo de expiración | Sí | Se utiliza para aplicar el borrado automático |
| Recuento/límite de descargas | Sí | Se utiliza para aplicar el borrado al leer y el límite de descargas |
| ID del usuario remitente (si está autenticado) | Sí | Vincula la transferencia a la cuenta para su gestión |
| Contraseña | No — nunca | Nunca se envía al servidor; nunca sale del navegador |
| Clave de cifrado derivada | No — nunca | Existe únicamente en la memoria del navegador durante el cifrado/descifrado |
| Contenido del archivo en texto claro | No — nunca | Solo el texto cifrado llega al servidor |
| Número de iteraciones de PBKDF2 | No | Codificado en el cliente; no se transmite al servidor |
Todas las transferencias seguras son efímeras por diseño. No existe opción de almacenamiento permanente.
| Opción | Comportamiento | Aplicación |
|---|---|---|
| Borrar al leer | El blob cifrado se elimina inmediatamente después de la primera descarga con éxito | En el servidor: el blob se elimina del almacenamiento cuando finaliza el flujo de descarga |
| 1 hora | Se elimina automáticamente tras 1 hora, independientemente del estado de descarga | En el servidor: tarea de limpieza programada + comprobación de expiración al acceder |
| 24 horas | Se elimina automáticamente tras 24 horas | Igual que arriba |
| 7 días | Retención máxima; se elimina automáticamente tras 7 días | Igual que arriba |
Cuando una transferencia expira o se borra al leer, el blob cifrado se elimina permanentemente de Supabase Storage. El registro de metadatos se conserva durante 30 días en estado de borrado lógico (para investigación de abusos) antes de purgarse permanentemente. El registro de metadatos no contiene la clave de cifrado, la contraseña ni ninguna información que pueda usarse para reconstruir el archivo.
| Amenaza | Vector de ataque | Mitigación |
|---|---|---|
| Compromiso del servidor | El atacante obtiene acceso total a la base de datos y al almacenamiento | Todos los datos almacenados son texto cifrado AES-256-GCM. El atacante solo obtiene blobs opacos. Sin la contraseña, forzar AES de 256 bits es computacionalmente inviable. |
| Interceptación de red (MITM) | El atacante intercepta los datos en tránsito | Todo el tráfico usa TLS 1.3. Incluso si TLS se viese comprometido, el atacante solo obtendría blobs cifrados (igual que en un compromiso del servidor). |
| Contraseña débil | El atacante fuerza por fuerza bruta una contraseña corta o común | PBKDF2 con 600K iteraciones hace que cada intento sea costoso computacionalmente. Una contraseña de 4 caracteres aún requeriría una cantidad significativa de cómputo. La interfaz aplica una longitud mínima de contraseña y ofrece retroalimentación de fortaleza. |
| Interceptación de la contraseña | El atacante intercepta la contraseña compartida entre remitente y destinatario | La contraseña se comparte fuera de banda (no a través de nuestro sistema). Recomendamos compartirla por un canal distinto al de la URL de la transferencia. Esto es responsabilidad del usuario. |
| Manipulación del código del cliente | El atacante modifica el JavaScript servido al usuario | Todos los recursos se sirven por HTTPS con la CDN de Vercel. Los hashes Subresource Integrity (SRI) protegen frente a manipulación a nivel de CDN. Los usuarios pueden verificar el código fuente en las DevTools del navegador. |
| Extracción de memoria | El atacante extrae la clave de cifrado de la memoria del navegador | Las claves de la Web Crypto API se marcan como no extraíbles siempre que es posible. Las claves derivadas existen en memoria solo durante la operación de cifrado/descifrado. El aislamiento de memoria del navegador proporciona protección a nivel del sistema operativo. |
| Ataque de repetición | El atacante reproduce un blob cifrado capturado | Cada transferencia tiene un ID único y está protegida por límites de descarga y expiración. Las transferencias con borrado al leer se eliminan tras el primer acceso. |
Limitaciones honestas: ningún sistema de seguridad es perfecto. Creemos en la divulgación transparente de las limitaciones conocidas.
Firma criptográfica de documentos con prioridad a la privacidad
Libro blanco WP-002 de PDF Pro — Arquitectura y análisis de seguridad
Este libro blanco describe la arquitectura del sistema de Firma con Privacidad de PDF Pro, un mecanismo criptográfico de firma de documentos en el que el PDF nunca sale del navegador del firmante. El sistema utiliza ECDSA con la curva P-256 y hashing SHA-256 para producir firmas digitales separadas. Al servidor solo se transmiten el hash del documento, la firma criptográfica y la clave pública. Las claves privadas se generan, se cifran y se almacenan exclusivamente en el navegador del usuario mediante IndexedDB, protegidas por derivación de clave PBKDF2 (600.000 iteraciones) y cifrado AES-256-GCM. Este documento detalla la arquitectura completa de firma y verificación, el modelo de gestión de claves, el esquema del payload firmado, el diseño del registro de auditoría, el modelo de amenazas y las limitaciones honestas.
El sistema de Firma con Privacidad se diseña en torno a una restricción fundamental: el PDF nunca debe transmitirse al servidor. Esto se aplica arquitectónicamente mediante un modelo de firma separada.
Garantía central: el servidor nunca ve, recibe ni procesa el PDF. Los únicos datos relacionados con el documento que el servidor recibe son un hash SHA-256 — un valor de tamaño fijo de 256 bits a partir del cual no se puede reconstruir el documento original.
| Parámetro | Valor | Justificación |
|---|---|---|
| Algoritmo de firma | ECDSA (Elliptic Curve Digital Signature Algorithm) | NIST FIPS 186-4; firmas compactas; alta seguridad por bit de clave |
| Curva | P-256 (secp256r1 / prime256v1) | Aprobada por NIST; nivel de seguridad de 128 bits; amplio soporte en la Web Crypto API |
| Función hash | SHA-256 | NIST FIPS 180-4; digest de 256 bits; resistente a colisiones |
| Tamaño de clave | Clave privada de 256 bits, clave pública de 512 bits (sin comprimir) | Estándar para P-256; equivalente a ~3072 bits de RSA |
| Tamaño de la firma | 64 bytes (r: 32 bytes, s: 32 bytes) | Compacto; adecuado para almacenamiento y transmisión |
| Formato de firma | IEEE P1363 (r || s en bruto) | Salida nativa de la Web Crypto API; codificada en base64url para su almacenamiento |
Codificación de la firma: ECDSA con P-256 de la Web Crypto API produce una firma en bruto de 64 bytes compuesta por dos enteros de 32 bytes (r || s) en formato big-endian de ancho fijo. Esta salida en bruto se codifica en base64url para su almacenamiento. No está codificada en DER — es el formato IEEE P1363 que produce nativamente la Web Crypto API.
// 1. Calcular el hash del PDF (en el cliente) const fileBuffer = await file.arrayBuffer(); const hashBuffer = await crypto.subtle.digest("SHA-256", fileBuffer); const hashHex = Array.from(new Uint8Array(hashBuffer)) .map(b => b.toString(16).padStart(2, '0')).join(''); // 2. Firmar el hash con la clave privada (en el cliente) const signature = await crypto.subtle.sign( { name: "ECDSA", hash: "SHA-256" }, privateKey, // CryptoKey desde IndexedDB (descifrada) hashBuffer ); // 3. Enviar al servidor: hash + firma + clave pública (NO el PDF) await submitSignature({ documentHash: hashHex, signature: base64Encode(signature), publicKey: exportedPublicKeyJWK });
| Tipo de clave | Contexto | Ciclo de vida | Almacenamiento |
|---|---|---|---|
| Efímera | Usuarios invitados (no autenticados) | Se genera por sesión; se destruye al cerrar la pestaña | Solo en memoria (objeto CryptoKey); nunca se persiste |
| Persistente | Usuarios autenticados (con sesión iniciada) | Se genera una vez; persiste entre sesiones; revocable | IndexedDB (cifrada con PBKDF2 + AES-256-GCM) |
// Generar par de claves ECDSA P-256 (Web Crypto API) const keyPair = await crypto.subtle.generateKey( { name: "ECDSA", namedCurve: "P-256" }, true, // extraíble (necesario para cifrado + almacenamiento) ["sign", "verify"] ); // Exportar la clave pública como JWK para registrarla en el servidor const publicKeyJWK = await crypto.subtle.exportKey("jwk", keyPair.publicKey); // Exportar la clave privada como JWK para su almacenamiento cifrado const privateKeyJWK = await crypto.subtle.exportKey("jwk", keyPair.privateKey);
Formato de la clave pública: las claves públicas se exportan y almacenan en formato JWK (JSON Web Key). La huella digital de la clave se calcula como SHA-256 del JWK canónico que contiene solo los campos públicos {crv, kty, x, y} con las claves ordenadas alfabéticamente.
Las claves privadas persistentes nunca se almacenan en texto claro. Antes de escribirse en IndexedDB, la clave privada (exportada como JSON JWK) se cifra siguiendo el mismo patrón que la Transferencia Segura:
| Parámetro | Valor |
|---|---|
| Derivación de clave | PBKDF2-SHA256, 600.000 iteraciones |
| Salt | Aleatorio de 16 bytes (por clave) |
| Cifrado | AES-256-GCM |
| IV | Aleatorio de 12 bytes (por cifrado) |
| Entrada | JWK de la clave privada (cadena JSON codificada en UTF-8) |
| Salida almacenada en IndexedDB | { salt, iv, ciphertext, publicKeyJWK, keyId, createdAt } |
// Estructura del registro de IndexedDB para una clave de firma cifrada { "keyId": "uuid-v4-unique-identifier", "publicKey": { /* formato JWK, sin cifrar */ }, "encryptedPrivateKey": { "salt": "base64-encoded-16-bytes", "iv": "base64-encoded-12-bytes", "ciphertext": "base64-encoded-aes-gcm-ciphertext" }, "algorithm": "ECDSA", "curve": "P-256", "createdAt": "2026-04-16T00:00:00.000Z", "userId": "supabase-auth-user-id" }
Recuperación de claves: si el usuario olvida su contraseña de firma, la clave privada no se puede recuperar. No tenemos la contraseña, la clave derivada ni ningún mecanismo para saltarnos la protección PBKDF2 + AES-GCM. Los usuarios deben exportar copias de seguridad de sus claves.
PDF Pro utiliza un modelo de firma separada, lo que significa que la firma se almacena por separado del documento. Este es el mecanismo crítico de privacidad: el documento nunca sale del dispositivo del firmante.
Garantía criptográfica: SHA-256 es una función hash de un solo sentido. Dado únicamente el hash e3b0c44298fc1c14..., se proporciona una sólida garantía criptográfica, bajo los supuestos de seguridad de ECDSA y SHA-256, de que el documento original no puede reconstruirse. El hash no revela nada sobre el contenido, la longitud o la estructura del documento más allá de confirmar su identidad cuando se vuelve a calcular.
El siguiente esquema JSON define el registro de firma completo almacenado en el servidor:
{
"schemaVersion": "1.0",
"signatureId": "uuid-v4",
"documentHash": "sha256-hex-64-chars",
"hashAlgorithm": "SHA-256",
"signature": "base64-encoded-ecdsa-signature",
"signatureAlgorithm": "ECDSA",
"curve": "P-256",
"publicKey": {
"kty": "EC",
"crv": "P-256",
"x": "base64url-encoded-x-coordinate",
"y": "base64url-encoded-y-coordinate"
},
"signer": {
"identityLevel": "authenticated | self-asserted",
"displayName": "string or null",
"email": "string or null",
"userId": "supabase-uid or null"
},
"timestamp": "ISO-8601-UTC",
"metadata": {
"fileName": "original-file-name.pdf",
"fileSize": 123456,
"pageCount": 12,
"clientVersion": "2.0.0",
"userAgent": "browser-user-agent-string"
},
"auditChain": {
"previousEventHash": "sha256-of-previous-audit-event or null",
"eventHash": "sha256-of-this-record"
}
}La verificación de firmas es un proceso en dos fases: verificación criptográfica en el cliente seguida de comprobación cruzada en el servidor.
const isValid = await crypto.subtle.verify( { name: "ECDSA", hash: "SHA-256" }, importedPublicKey, signatureBuffer, hashBuffer );
isValid === true, la firma es criptográficamente válida: el documento no se ha modificado desde la firma y la firma fue producida por el titular de la clave privada correspondiente.| Resultado | Significado |
|---|---|
| Válida (autenticada) | La firma es criptográficamente válida Y la clave pública pertenece a un usuario de PDF Pro registrado y autenticado. |
| Válida (autoafirmada) | La firma es criptográficamente válida, pero la identidad del firmante está autoafirmada (usuario invitado o nombre no verificado). |
| Inválida | La verificación criptográfica ha fallado. El documento ha sido modificado desde la firma, o la firma está corrupta. |
| Revocada | La firma era válida, pero ha sido revocada explícitamente por el firmante. |
| No se encontró firma | No existe ningún registro de firma para este hash de documento. |
Todos los eventos de firma y verificación se registran en un registro de auditoría a prueba de manipulaciones. Los eventos se encadenan por hash: cada evento incluye el hash SHA-256 del evento anterior, formando una cadena de solo-adición similar a una blockchain.
| Tipo de evento | Disparador | Datos registrados |
|---|---|---|
KEY_REGISTERED | El usuario registra una nueva clave pública | JWK de la clave pública, ID de usuario, marca de tiempo |
DOCUMENT_SIGNED | El usuario firma un documento | Hash del documento, firma, clave pública, identidad del firmante, marca de tiempo |
SIGNATURE_VERIFIED | Cualquier usuario verifica una firma | Hash del documento, resultado de la verificación, info del verificador (si está autenticado), marca de tiempo |
SIGNATURE_REVOKED | El firmante revoca una firma | ID de firma, motivo de revocación, marca de tiempo |
KEY_REVOKED | El usuario revoca una clave pública | ID de la clave pública, motivo de revocación, marca de tiempo |
// Cada evento de auditoría incluye: { "eventId": "uuid-v4", "eventType": "DOCUMENT_SIGNED", "timestamp": "ISO-8601-UTC", "data": { /* payload específico del evento */ }, "previousEventHash": "sha256-of-previous-event-json", "eventHash": "sha256-of-this-event-json-without-eventHash" } // Detección de manipulación: para verificar la cadena, calcula: // SHA-256(JSON.stringify(event without eventHash field)) // y confirma que coincide con eventHash. // Después confirma que previousEventHash coincide con el eventHash del evento anterior.
Si se modifica algún evento de la cadena, todos los enlaces de hash posteriores se romperán, haciendo que la manipulación sea detectable de inmediato. Esto proporciona una sólida garantía de integridad del registro de auditoría.
Limitación importante: la cadena de hashes hace que la manipulación sea detectable dentro de la secuencia de eventos registrados. Sin embargo, un administrador de base de datos con acceso directo podría en teoría eliminar y reconstruir la cadena. Para garantías más fuertes se requeriría un timestamping externo o una atestación de terceros, que no están implementados en esta versión.
| Nivel | Requisitos | Propiedades de confianza | Caso de uso |
|---|---|---|---|
| Autenticada | Usuario de PDF Pro con sesión iniciada y correo verificado; par de claves persistente registrado en la cuenta | Correo verificado por Supabase Auth; clave pública vinculada a una cuenta autenticada; registro de auditoría enlazado al ID de usuario | Documentos empresariales, contratos, acuerdos formales |
| Autoafirmada | Usuario invitado o autenticado con nombre introducido por sí mismo; par de claves efímero o persistente | Integridad criptográfica garantizada; la identidad del firmante es autodeclarada y no se verifica de forma independiente | Firma rápida, documentos personales, acuerdos informales |
Nota: en la interfaz del producto, 'self_asserted' puede mostrarse como 'Identidad autoafirmada' o 'Firma de invitado'. 'authenticated' puede mostrarse como 'Cuenta autenticada'. Son etiquetas de visualización para los mismos niveles de identidad subyacentes.
Vinculación de identidad: una firma "autenticada" significa que la clave pública está registrada en una cuenta de PDF Pro con un correo electrónico verificado. NO significa que la identidad real del firmante haya sido verificada mediante documento de identidad oficial, biometría o verificación presencial. No realizamos comprobaciones Know Your Customer (KYC).
| Amenaza | Vector de ataque | Mitigación |
|---|---|---|
| Ataque de repetición | El atacante copia una firma válida y la aplica a otro documento | La firma está ligada al hash SHA-256 del documento específico. Un documento diferente tendrá un hash diferente y la verificación ECDSA fallará. |
| Falsificación | El atacante crea una firma válida sin la clave privada | La seguridad de ECDSA P-256 se basa en el Problema del Logaritmo Discreto Elíptico (ECDLP). Falsificar una firma sin la clave privada es computacionalmente inviable (nivel de seguridad de 128 bits). |
| Sustitución de claves | El atacante registra su propia clave pública y afirma que una firma fue realizada por otra persona | Las firmas autenticadas vinculan la clave pública a un correo verificado. El registro de auditoría anota qué clave firmó qué documento. Los eventos de registro de clave se encadenan por hash. |
| Sustitución del documento | El atacante modifica un PDF firmado y afirma que la firma sigue siendo válida | Cualquier modificación del PDF cambia su hash SHA-256. La firma existente fallará la verificación contra el nuevo hash. Encontrar una colisión (documento distinto con el mismo hash) requiere ~2^128 operaciones. |
| Robo de la clave privada | El atacante extrae la clave privada cifrada de IndexedDB | La clave privada está cifrada con AES-256-GCM, cuya clave deriva de PBKDF2 (600K iteraciones). Sin la contraseña, descifrar la clave es computacionalmente inviable. |
| Compromiso del servidor | El atacante obtiene acceso total al servidor | El servidor solo tiene claves públicas y registros de firmas. Las claves privadas nunca llegan al servidor. Un atacante no puede falsificar nuevas firmas. Podría eliminar o modificar registros existentes, pero las roturas de la cadena de hashes serían detectables. |
| Manipulación del registro de auditoría | El atacante modifica eventos del registro de auditoría en el servidor | Eventos encadenados por hash: modificar cualquier evento rompe la cadena a partir de ese punto. Herramientas de verificación independientes pueden detectar roturas en la cadena. |
Lo que las firmas de PDF Pro NO son:
Nuestro compromiso: construimos la seguridad mediante la arquitectura, no mediante el marketing. Estos libros blancos describen exactamente cómo funcionan nuestros sistemas, incluidas sus limitaciones. Creemos que los usuarios preocupados por la seguridad merecen total transparencia técnica. Si tienes dudas sobre cualquier aspecto de nuestra arquitectura, contáctanos en info@webdesign9.com.