Firma digital · PWA offline
Firmador FirmaOKPWA que firma y valida PDF con certificados .p12 sin que nada salga del dispositivo
PWA que firma y valida PDF (PAdES-B) con certificados .p12 sin conexión: la clave no sale del dispositivo y refirmar conserva las firmas previas.
Tecnologías
- React 19
- TypeScript
- Vite
- Tailwind CSS v4
- vite-plugin-pwa (Workbox)
- WebCrypto
- PKI.js + asn1js
- node-forge
- pdf-lib
- @signpdf
- pdf.js
- IndexedDB (idb)
- Argon2id (hash-wasm)
- react-rnd
- Vitest + Testing Library
- Docker + nginx
- Railway
- Google Analytics 4

54
casos de prueba automatizados
8
AC ecuatorianas cubiertas por pruebas
7
módulos en src/modules
64 MB
de memoria por cada derivación Argon2id
Mi rol
Desarrollador web y de aplicaciones en Azirgo SAS. Trabajé en el firmador web de FirmaOK: la bóveda cifrada del certificado, la firma PAdES con WebCrypto, el sello visible, la multifirma por actualización incremental, el validador offline y la PWA que lo sirve sin conexión.
El problema
FirmaOK necesitaba un firmador que corriera en cualquier navegador sin reintroducir el problema de fondo: los firmadores web habituales piden subir el documento y el certificado .p12 - con su clave privada - a un servidor ajeno. Tenía que funcionar entero en el navegador y sin conexión, proteger el certificado guardado aunque otra persona usara el equipo, conservar las firmas que el PDF ya traía y leer bien los certificados de las distintas entidades acreditadas de Ecuador, que no guardan los datos del firmante de la misma forma.
Qué construí
- Importación del certificado .p12 / .pfx una sola vez: se lee en el navegador, se guarda cifrado en IndexedDB y se desbloquea con una contraseña maestra; admite varios certificados bajo la misma contraseña, sin duplicados gracias a la huella SHA-256.
- Firma PAdES-B visible en cualquier página del PDF: el usuario arrastra el recuadro sobre el documento y su posición se convierte a coordenadas PDF.
- Sello configurable con vista previa idéntica al resultado: QR, nombre del firmante, fecha opcional, cargo opcional para persona jurídica y una nota libre de hasta dos líneas, ampliable en un diálogo.
- Multifirma: firmar un PDF que ya estaba firmado añade la nueva firma por actualización incremental y las anteriores siguen siendo válidas.
- Validador offline: firmante, cédula o RUC, razón social y cargo, emisor, vigencia y huella del certificado, perfil PAdES (B-B, B-T o B-LTA), integridad y bytes añadidos después de cada firma; reconoce también los sellos de tiempo del documento.
- Lectura de los datos del firmante según el esquema ecuatoriano de las AC acreditadas - Security Data, BCE, Uanataca, ANF, FirmaSegura, CorpNewBest, Lazzate y AppFirmas -, con el tipo de firmante: persona natural, natural con RUC o jurídica.
- Bloqueo de la firma con certificados vencidos o todavía no vigentes, con acceso directo a importar uno vigente.
- Consentimiento informado y aviso de privacidad (LOPDP) antes del primer uso, con borrado de cada certificado en cualquier momento.
- PWA instalable que funciona sin conexión, con tema claro y oscuro, rutas /firmar y /validar, y un botón «Firmar y compartir» que abre la hoja nativa del sistema con el PDF firmado como adjunto.
Cómo está estructurado
Una SPA de Vite + React sin backend: todo el dominio vive en src/modules, un módulo por responsabilidad, y las páginas solo orquestan.
- cert-vault: lee el .p12 con node-forge, importa la clave como no extraíble, la cifra con AES-256-GCM bajo la contraseña maestra (Argon2id) y la guarda en IndexedDB con la huella SHA-256 del certificado como id; también desbloquea, lista y borra.
- crypto-core: arma el CMS SignedData de PAdES-B con PKI.js sobre el motor WebCrypto del navegador.
- pdf-signer: dibuja el sello, reserva el hueco de la firma, deja que se calcule el /ByteRange, firma con WebCrypto y guarda en incremental cuando el PDF ya venía firmado.
- pdf-validator: extrae cada firma trabajando sobre Uint8Array, sin Buffer, lee el diccionario de firma y verifica con PKI.js.
- pdf-viewer: renderiza con pdf.js a la densidad de píxeles del dispositivo, con un arranque del worker que se recupera si falla, y convierte la posición del recuadro arrastrable (react-rnd) a puntos PDF con origen abajo a la izquierda.
- privacy-lopda y pwa: la pantalla de consentimiento versionada, el aviso de privacidad y el aviso de instalación de la PWA.
- lib/ecuador-cert: el lector del esquema ecuatoriano que comparten la bóveda, para el sello, y el validador, para el reporte, de modo que los dos muestran al firmante igual.
- Enrutado mínimo sin dependencias - /firmar y /validar - que el fallback de nginx y el del service worker resuelven también sin conexión. El despliegue es un Dockerfile de dos etapas (build con Node y pnpm, servido con nginx) con CSP, nosniff y caché inmutable para los assets con hash.
La firma, paso a paso
Cada firma es PAdES-B (perfil B-B): un CMS «detached» embebido en el PDF que cubre todos los bytes del archivo menos el hueco donde él mismo se guarda.
- Se comprueba que el certificado esté vigente a la hora de la firma; si no lo está, el proceso se detiene ahí.
- Si el PDF ya contiene un /ByteRange se trabaja en incremental; si no, se reescribe entero, lo que además repara PDFs con la xref rota.
- Se dibuja el sello en la página elegida y se añade el campo de firma con SubFilter ETSI.CAdES.detached, razón, lugar - la dirección del certificado o, si falta, la razón social - y un hueco de 24.576 bytes en /Contents para el CMS.
- @signpdf calcula el /ByteRange - los dos tramos del archivo a cada lado de /Contents - y entrega esos bytes a un firmante propio que delega en WebCrypto.
- El CMS se arma con PKI.js: resumen SHA-256 de esos bytes y atributos firmados content-type, message-digest, signing-time y signing-certificate-v2, que ata la firma al certificado exacto. Se firma con RSASSA-PKCS1-v1_5 y la clave no extraíble, y se embebe la cadena de certificados.
- El DER del CMS se escribe en el hueco reservado y el PDF firmado se descarga o se comparte con la hoja nativa del sistema.
- Validar recorre el camino inverso: localiza cada /ByteRange, parsea el CMS, recalcula el resumen de los bytes cubiertos, verifica la firma y detecta el perfil (B-B, B-T o B-LTA) y cuántos bytes se añadieron después de cada firma.
Por dentro del sitio

Consentimiento informado (LOPDP): todo se procesa en el dispositivo y el certificado se guarda cifrado. 
Importación de un certificado de prueba .p12 y creación de la contraseña maestra. 
Firma completada con el certificado de prueba, lista para compartir o descargar. 
Dos certificados de prueba guardados y cifrados en el dispositivo. 
Validación offline: las dos firmas del contrato de prueba son válidas. 
Detalle técnico de una firma: emisor, vigencia, huella SHA-256, perfil PAdES e integridad.
Decisiones técnicas
- La clave privada se importa en WebCrypto como no extraíble y solo con permiso de firma, y el buffer PKCS#8 se sobrescribe con ceros después. Un script inyectado podría, como mucho, pedir una firma mientras la sesión está abierta, pero no llevarse la clave; una CSP estricta - default-src 'self' y ningún script inline - cierra además esa puerta.
- Una sola contraseña maestra, derivada con Argon2id (64 MB de memoria y 3 pasadas, en WebAssembly), cifra con AES-256-GCM todos los certificados guardados. La clave AES nunca se persiste: se vuelve a derivar en cada desbloqueo, y la contraseña del .p12 no se pide otra vez después de importarlo.
- Refirmar se hace por actualización incremental (ISO 32000-1 §7.5.6) y no con el guardado normal de pdf-lib: reescribir el archivo mueve todos los offsets y rompe el /ByteRange de las firmas anteriores. Los bytes originales quedan intactos y solo se añaden al final los objetos nuevos, con su propia sección xref.
- El service worker precachea todo el shell de la aplicación, incluido el worker de pdf.js, para que firmar y validar funcione sin conexión. Ese worker se emite como .js y no como .mjs porque algunos hostings sirven .mjs con un tipo MIME que el navegador rechaza, y nginx entrega sw.js e index.html sin caché para que las actualizaciones lleguen.
- Privacidad desde la primera pantalla: consentimiento conforme a la LOPDP antes de usar la app, aviso de privacidad siempre a mano y borrado de cada certificado cuando el usuario quiera (derecho de supresión). En claro solo quedan los datos para distinguir un certificado de otro - nombre, tipo de firmante, cédula, razón social y vigencia -, y la clave y el resto de metadatos van cifrados; cuando se incorporó Google Analytics, el consentimiento cambió de versión para que todos volvieran a verlo.
- El sello se dibuja en el contenido de la página y el campo de firma lleva un widget de 0×0: con un widget del tamaño del sello, visores como Google Drive o Adobe en el móvil lo pintaban con el resaltado celeste de los campos de formulario.
Retos
- pdf-lib solo sabe reescribir el documento entero. Guardar en incremental exigió tomar una huella de cada objeto al cargar el PDF, escribir solo los que cambiaron, encadenar la nueva xref con /Prev - también sobre originales con xref-stream (PDF 1.5+) - y reservar los números de objeto de la revisión anterior, porque pdf-lib descarta los object streams al parsear y reutilizaría números de objetos vivos. Además, @signpdf llama siempre «Signature1» al campo nuevo y dos campos con el mismo nombre son el mismo campo para los visores, así que se renombra al primer SignatureN libre.
- Leer al firmante en certificados de todas las AC acreditadas: cada una usa su propio arco OID (1.3.6.1.4.1.<PEN>.3.N), Uanataca añade un sufijo intermedio, AppFirmas no usa extensiones y pone cédula y RUC en el Subject DN con prefijos, algunas entregan el RUC en binario y un OID heredado de ANF trae su número de arco en lugar del RUC. Se resolvió detectando el arco por su sufijo y aceptando solo valores con forma de cédula (10 dígitos) o de RUC (13), además de corregir los nombres con tildes y Ñ que llegaban mal decodificados.
- Un validador que no exagere sin conexión: descarta coincidencias de /ByteRange que no son un CMS real, soporta CMS con longitud indefinida (BER), separa los sellos de tiempo del documento (ETSI.RFC3161) de las firmas de persona y reporta B-T solo si hay un token RFC 3161 de verdad. Lo que no se puede comprobar offline - la revocación OCSP/CRL y la cadena de confianza - aparece como no verificado en el propio reporte.
- El desbloqueo biométrico con WebAuthn y la extensión PRF llegó a implementarse, con ruta al autenticador de plataforma, un respaldo y una salida para cuando el desbloqueo se quedaba colgado, y después se retiró para dejar un único método de desbloqueo: la contraseña maestra con Argon2id.
Resultado
El firmador es una PWA estática que se construye con Docker y se sirve con nginx en Railway: firma y valida PDF en el navegador, sin un backend que reciba documentos ni certificados, y sigue funcionando sin conexión. Refirmar un documento conserva íntegras las firmas anteriores, y el núcleo - lectura del .p12, bóveda cifrada, CMS PAdES, firma, multifirma y detección de manipulación - está cubierto por pruebas automáticas, incluidos casos que reproducen la estructura de los certificados de ocho AC ecuatorianas.