§ NOTAS TÉCNICAS aGo · 2026-05-24

Firmar PDFs sin servidor con pdf-lib

aGo Signer firma PDFs sin que el documento salga del navegador. Esta nota cuenta el stack técnico, qué garantías reales entrega cada pieza, y los límites legales que asumimos desde el primer día.

El contexto

La premisa de Signer es simple: una persona recibe un contrato en PDF, quiere firmarlo, agregar la fecha, y devolverlo. Para acuerdos simples (NDA entre dos empresas chicas, propuesta comercial, autorización interna) el flujo no requiere notario ni prestador acreditado. Lo que sí requiere es que el firmante sea identificable y que después no haya forma fácil de alterar el documento sin que se note.

El constraint duro de aGo lab aplica acá con fuerza extra: el documento puede ser confidencial. Un contrato laboral, un acuerdo de socios, un PDF con datos personales. Subirlo a un servicio en la nube para firmar es exactamente lo que el usuario quiere evitar. Cliente puro o nada.

El segundo constraint es claridad legal. Si decimos "firma electrónica" sin más, alguien va a pensar que es FEA y va a usar Signer para un contrato que requiere FEA. Eso es un problema para el usuario y un problema reputacional para aGo lab. Desde la primera iteración asumimos: nombramos lo que es, no prometemos lo que no es.

Tercer constraint: usabilidad. La firma se dibuja en pantalla con mouse, trackpad o pluma. El usuario ve el PDF, hace click donde quiere firmar, dibuja, y descarga. Si el flujo tiene 8 pasos, no se usa.

Las opciones que evaluamos

1. pdf-lib + pdfjs-dist + Web Crypto, todo en cliente

pdf-lib (de Andrew Dillon) es una biblioteca JS para crear y modificar PDFs. Soporta embedir imágenes, agregar texto, manipular páginas, y firmar campos. pdfjs-dist (de Mozilla) renderiza PDFs en canvas para preview. Web Crypto provee SHA-256 nativo. Suma ~250 KB gzip combinados.

Ventaja: stack maduro, mantenido, sin servidor. Contra: la firma manuscrita que producimos no es FEA.

2. HelloSign / DocuSign SDK

Suben el documento, gestionan firma con identidad verificada del firmante (correo confirmado, SMS, o firma con certificado X.509 si pagas más). Producen documentos con valor legal mayor en jurisdicciones que lo reconocen.

Contra fatal para tools.ago.cl: el archivo sale del navegador y se procesa en un tercero. Inaceptable para nuestra propuesta. Además, dependencia comercial y costo por documento. Lo descartamos sin evaluación profunda porque no aplica a la propuesta de valor.

3. Backend propio con OpenSSL para firmar con certificado X.509

Funciona, produce una firma criptográfica embebida en el PDF según PAdES (PDF Advanced Electronic Signatures). Si además el certificado lo emite un prestador acreditado ante la Subsecretaría de Economía en Chile, se puede llegar a FEA.

Contra: requiere backend (rompe el modelo de tools.ago.cl), requiere certificado X.509 por firmante (costo y trámite), y para FEA real requiere prestador acreditado. Esto sí es el camino correcto cuando el caso lo exige, pero está fuera del alcance de Signer.

La decisión y por qué

Implementamos opción 1 con un encuadre muy explícito en la UI. Signer produce un PDF con: la imagen de la firma manuscrita del usuario (dibujada en canvas), nombre y RUT/identificador opcional, sello de tiempo con la fecha local, hash SHA-256 del documento embebido en metadata, y un campo de texto visible que dice "Firma manuscrita digital, no FEA". Esto último es decisión nuestra: preferimos que el documento mismo declare qué tipo de firma porta, no solo la UI al momento de firmar.

Razón principal: el caso de uso real de Signer son acuerdos simples donde la prueba de voluntad importa más que la prueba criptográfica. Una NDA entre dos personas que después se llevan el PDF firmado a un acuerdo verbal de confianza. Si esa NDA termina en juicio (raro), un perito puede verificar el hash, comparar con el documento original y constatar que no fue alterado después de firmar. Eso es bastante en términos prácticos, sin pretender más.

Razón secundaria: el costo de "casi llegar a FEA" es altísimo y termina mal. Si embebiéramos una firma cripto que parece FEA pero no es FEA, generamos confusión legal y exposición al usuario. La decisión de no acercarnos a esa frontera nos protege a nosotros y a quien firma.

Razón operacional: stack JS estándar, sin backend, mantenible por uno o dos developers. pdf-lib tiene siete años de mantenimiento y comunidad activa. pdfjs-dist lo mantiene Mozilla. Si una de las dos rompe API en una major version, migrar es un par de tardes, no un proyecto.

Cómo lo implementamos

Tres piezas: render del PDF para que el usuario vea dónde firmar, captura de la firma manuscrita en un canvas, escritura del PDF firmado con metadata y hash. El siguiente snippet muestra la pieza de escritura, que es donde la mayoría de la lógica vive.

// src/islands/signer/lib/sign-pdf.ts
// Firma un PDF con imagen de firma manuscrita, timestamp y hash.
// Todo corre en el navegador. El archivo nunca sale del cliente.

import { PDFDocument, StandardFonts, rgb, degrees } from 'pdf-lib';

export interface SignatureBlock {
  signerName: string;
  signerId?: string;          // RUT, DNI, opcional
  signatureImage: Blob;       // PNG transparente, generado por canvas
  page: number;               // 1-indexed
  x: number;                  // unidades PDF, esquina inferior izquierda
  y: number;
  width: number;
  height: number;
}

export interface SignResult {
  pdfBytes: Uint8Array;
  hashSha256: string;
  signedAt: string;
  signatureType: 'manuscrita-no-fea';
}

export async function signPdf(
  pdfBytes: ArrayBuffer,
  block: SignatureBlock
): Promise<SignResult> {
  const pdfDoc = await PDFDocument.load(pdfBytes);
  const pages = pdfDoc.getPages();
  if (block.page < 1 || block.page > pages.length) {
    throw new Error(`Pagina ${block.page} fuera de rango (1..${pages.length})`);
  }
  const page = pages[block.page - 1];

  // Embed de la imagen de firma. PNG con alpha preserva el trazo.
  const sigBuffer = await block.signatureImage.arrayBuffer();
  const sigImage = await pdfDoc.embedPng(sigBuffer);
  page.drawImage(sigImage, {
    x: block.x,
    y: block.y,
    width: block.width,
    height: block.height
  });

  // Caja con datos del firmante debajo de la firma.
  const font = await pdfDoc.embedFont(StandardFonts.Helvetica);
  const signedAt = new Date().toISOString();
  const labelLines = [
    block.signerName,
    block.signerId ? `ID: ${block.signerId}` : null,
    `Firmado: ${signedAt}`,
    'Firma manuscrita digital, no FEA'
  ].filter(Boolean) as string[];

  let textY = block.y - 12;
  for (const line of labelLines) {
    page.drawText(line, {
      x: block.x,
      y: textY,
      size: 8,
      font,
      color: rgb(0.18, 0.18, 0.18)
    });
    textY -= 10;
  }

  // Metadata del documento.
  pdfDoc.setTitle(pdfDoc.getTitle() || 'Documento firmado');
  pdfDoc.setSubject(`Firmado por ${block.signerName} el ${signedAt}`);
  pdfDoc.setProducer('aGo Signer (tools.ago.cl)');
  pdfDoc.setModificationDate(new Date());

  // Hash del PDF resultante (antes del setKeywords con el hash mismo,
  // hay un huevo y gallina: el hash cambia si lo embebes en el PDF.
  // Estrategia: hasheamos el cuerpo sin el campo Keywords, despues lo agregamos).
  const tentativeBytes = await pdfDoc.save({ useObjectStreams: false });
  const hash = await sha256Hex(tentativeBytes);

  pdfDoc.setKeywords([
    `sha256:${hash}`,
    'firma:manuscrita-no-fea',
    `signer:${block.signerName}`,
    `signedAt:${signedAt}`
  ]);

  const finalBytes = await pdfDoc.save({ useObjectStreams: false });

  return {
    pdfBytes: finalBytes,
    hashSha256: hash,
    signedAt,
    signatureType: 'manuscrita-no-fea'
  };
}

async function sha256Hex(bytes: Uint8Array): Promise<string> {
  const buf = await crypto.subtle.digest('SHA-256', bytes);
  return Array.from(new Uint8Array(buf))
    .map(b => b.toString(16).padStart(2, '0'))
    .join('');
}

// Verificacion: dado un PDF firmado, extrae el hash declarado en Keywords y
// recalcula el hash del documento. Si difieren, el PDF fue alterado despues.
// Nota: esta verificacion compara contra "el documento como esta ahora",
// no contra "el documento como fue firmado", porque al recalcular el hash
// el campo Keywords cambia. Estrategia practica: el hash se guarda tambien
// en log externo (chat, email de confirmacion) y se compara visualmente.
export async function quickVerify(pdfBytes: ArrayBuffer): Promise<{
  declaredHash: string | null;
  currentHash: string;
}> {
  const pdfDoc = await PDFDocument.load(pdfBytes);
  const keywords = pdfDoc.getKeywords()?.split(',').map(k => k.trim()) ?? [];
  const declared = keywords.find(k => k.startsWith('sha256:'))?.slice('sha256:'.length) ?? null;
  const current = await sha256Hex(new Uint8Array(pdfBytes));
  return { declaredHash: declared, currentHash: current };
}

Tres detalles del código importan. Primero, el problema "huevo y gallina" del hash: si embebes el hash en el PDF, el hash cambia al embeberlo. Lo resolvemos generando primero un PDF sin el campo Keywords con el hash, calculando el hash sobre ese cuerpo, y agregando después. Esto significa que el hash declarado verifica al PDF "sin keywords con sha256", no al PDF final. Es una de esas decisiones donde lo elegante choca con lo posible, y elegimos lo posible documentando el matiz.

Segundo, la línea "Firma manuscrita digital, no FEA" se dibuja explícitamente en el documento. Es decisión consciente: cualquier persona que reciba el PDF firmado, sin abrir metadata, puede leer qué tipo de firma porta. Nada de letra chica.

Tercero, useObjectStreams: false en el save. Object streams comprimen y son más eficientes, pero generan PDFs menos predecibles cuando se vuelven a leer con herramientas viejas. Para PDFs firmados que pueden circular por muchas manos, la previsibilidad gana.

Limitaciones que descubrimos

La verificación de integridad tiene un asterisco práctico. El hash declarado en Keywords vale para comparar contra una copia del hash guardada fuera del PDF. Si solo tienes el PDF firmado, recalcular el hash y compararlo contra el que está dentro no prueba nada, porque ambos están en el mismo documento y un atacante con suficiente conocimiento puede alterar ambos. La solución correcta es registrar el hash en un canal externo al momento de firmar: email de confirmación al firmante con el hash, registro en un servicio de timestamping, anclaje en una blockchain pública si el caso lo amerita. Para acuerdos simples, el email de confirmación es suficiente.

pdf-lib no soporta firma con certificado X.509 embebido (PAdES). El issue está abierto desde 2019. Para eso, hoy, hay que recurrir a backend con node-signpdf u OpenSSL. Si necesitas eso, Signer no es la herramienta.

Algunos PDFs con formularios complejos o con firmas previas certificadas se corrompen al editarlos con pdf-lib. Detectamos el caso y avisamos al usuario antes de firmar: "este PDF ya tiene firma certificada, no podemos modificarlo sin invalidarla". El detector usa pdfDoc.getForm().getSignatures().

El tamaño del PDF crece con cada firma. Una página con tres firmas pesa más que la versión sin firmar. No es problema técnico, es un efecto del embedding de imágenes PNG. Si el documento se va a circular mucho conviene reducir la resolución de la imagen de firma antes de embeberla.

Cuándo NO uses este approach

Si tu trámite requiere FEA acreditada por ley (compraventa de inmuebles, ciertas escrituras, documentos que la Ley 19.799 exige FEA), Signer no aplica y ninguna firma manuscrita digital aplica. Tienes que ir a un prestador acreditado.

Si necesitas un workflow multi-firmante con sello de tiempo certificado (TSA), notificaciones automáticas, y registro de evidencia auditable, los servicios comerciales tipo DocuSign o un backend propio con TSA son el camino. Signer es para acuerdos directos entre partes.

Si tu caso involucra documentos que se vuelven a editar muchas veces (anexos, addendas), considera un workflow donde el "documento" sea un identificador en una base, no un PDF que circula. PDF firmado es snapshot, no flujo vivo.

Esta nota describe parte de cómo construimos aGo Signer. Abre Signer, sube un PDF y fírmalo para ver el approach en vivo.

¿Necesitas algo parecido para tu producto? aGo lab construye software cliente puro para empresas. Conversemos.

§ CÓMO CITAR ESTE ARTÍCULO

aGo lab. (2026). "pdf-lib, cómo embebimos una firma manuscrita en el navegador". tools.ago.cl/notas/pdf-lib-firma-cliente. Recuperado el 2026-05-25.

Esta nota es contenido propio de aGo lab y se publica bajo política de atribución requerida. Si la citas o referencias, incluye el link a la URL original.

§ aGo lab estudio

¿Quieres que aGo lab implemente esto para ti?

Si esta decisión técnica te resuelve un problema y necesitas aplicarla a un sistema propio, conversemos.

§ COMMAND

↑↓ navega · Enter abre · Esc cierra