Artículo técnico

Clasificar qué cambió en un PDF después de firmarlo

Una firma sobre un PDF no prohíbe cambios posteriores. Fija un rango de bytes, y una actualización incremental añade bytes nuevos después, de modo que la firma sigue siendo matemáticamente válida mientras el documento adquiere contenido nuevo. Si ese contenido es aceptable es una cuestión de política, y DocMDP es donde el autor declara la política: ningún cambio en absoluto, solo rellenar formularios y firmar, o eso más anotaciones. Aplicarla significa clasificar qué cambió realmente, que es lo que hace AnalyzeModifications. Apuntadlo a una revisión anterior y leed GetModificationLevel para el veredicto global, y los accessors por hallazgo para el nivel, el número de objeto y la descripción de cada diferencia

Con eso en su sitio, aplicar DocMDP se colapsa en una comparación: si el nivel calculado está en o por debajo del nivel que la política permite

Diagrama de la escalera de niveles de modificación de PDFlibPas de mlNone a mlUnclassified mostrando la aplicación de la política DocMDP como una comparación en Delphi
La escalera TPLModificationLevel va de mlNone a mlUnclassified, y aplicar DocMDP se reduce a comparar el nivel calculado con la política

Por qué se espera que un PDF firmado cambie

Tres casos legítimos, y cubren la mayoría de lo que veréis. Un segundo firmante añade su firma. Un destinatario rellena campos de formulario que el autor dejó abiertos. Y se añade material de validación a largo plazo: respuestas OCSP y CRL escritas en el document security store del documento para que la firma siga siendo verificable cuando los responders ya no estén. Ese último no solo está permitido: es lo que un archivo bien gestionado hace a propósito con los documentos firmados

Así que «el fichero creció después de firmar» no aporta información. La pregunta es siempre qué se añadió, y la respuesta tiene que salir de comparar estados del documento en vez de observar bytes. La mecánica del append en sí se cubre en el artículo de actualización incremental

Clasificar por la forma del objeto, no por la ruta que lo produjo

El clasificador mira lo que un objeto es después del cambio, no qué llamada de la biblioteca lo creó. Es deliberado, porque el análisis se ejecuta contra ficheros producidos por otro software, donde no hay ruta de llamada que inspeccionar

Se reconocen cuatro formas. Los diccionarios de información del document security store y relacionados con la validación, los objetos de cross-reference stream, la entrada metadata del catálogo y los diccionarios de firma que llevan un rango de bytes son material de archivo a largo plazo. Un objeto que lleva tanto un tipo de campo como un valor de campo es relleno de formularios. Un objeto cuyo tipo es annotation, o cuyo subtype es uno de los listados en la Tabla 168 de ISO 32000-2, es un cambio de anotación. Todo lo demás queda sin clasificar

Árbol de decisión que PDFlibPas aplica a cada objeto PDF cambiado, ordenando las actualizaciones en niveles de archivo, relleno de formulario, anotación o sin clasificar
Cada objeto cambiado se clasifica por lo que es — security store, xref stream, campo, anotación — nunca por la llamada que lo produjo

Las eliminaciones se tratan con más rigor que las adiciones. Un objeto eliminado solo entra en la whitelist cuando el objeto del lado antiguo era él mismo material de archivo, lo que cubre el caso normal de un security store sustituido por uno más nuevo. Cualquier otra eliminación queda sin clasificar, porque borrar contenido de un documento firmado no es algo que un nivel de permiso autorice. Las diferencias a nivel de documento son aún más estrictas: un cambio en el número de páginas va directo a sin clasificar sin examinar objetos individuales, ya que ningún nivel DocMDP permite añadir o quitar páginas

La whitelist se equivoca hacia rechazar

Esa es la regla de diseño que gobierna cada decisión en el límite. Un cambio clasificado erróneamente como permitido es una firma que valida sobre contenido que el autor nunca autorizó. Un cambio clasificado erróneamente como sin clasificar es un documento que se marca y revisa una persona. Esos dos errores no son simétricos, así que la whitelist se mantiene estrecha y las formas no reconocidas caen a sin clasificar en lugar de adivinarse

Eso tiene una consecuencia práctica que conviene anticipar: los ficheros de productores inusuales a veces informarán de cambios sin clasificar que, al examinarlos, son benignos. La respuesta correcta es mirar el detalle del hallazgo y el número de objeto en lugar de ensanchar la whitelist, porque una whitelist que crece para silenciar informes concretos deja de ser un control de seguridad

uses
  PDFlibrary, PDFlibCompare;

var
  Pdf: TPDFlib;
  I, Level: Integer;
begin
  Pdf := TPDFlib.Create(nil);
  try
    Pdf.LoadFromFile('contract-countersigned.pdf', '');
    if Pdf.AnalyzeModifications('contract-as-signed.pdf', '') < 0 then
      raise Exception.Create('the earlier revision could not be loaded');

    // TPLModificationLevel ordenado mlNone, mlLTAUpdates, mlFormFilling,
    // mlAnnotations, mlUnclassified; el getter devuelve su ordinal
    Level := Pdf.GetModificationLevel;
    // Aplicar DocMDP es ahora una comparación contra la política
    if Level > Ord(mlFormFilling) then
      for I := 0 to Pdf.GetModificationFindingCount - 1 do
        Report.Add(Format('object %d, level %d: %s',
          [Pdf.GetModificationFindingObjNum(I),
           Pdf.GetModificationFindingLevel(I),
           Pdf.GetModificationFindingDetail(I)]));
  finally
    Pdf.Free;
  end;
end;

El nivel global es el máximo sobre todos los hallazgos, que es la única agregación defendible: un documento con noventa y nueve adiciones de archivo y un cambio sin clasificar es un cambio sin clasificar

Por debajo: fingerprints, no hashes criptográficos

El motor de comparación que expone CompareWith, y sobre el que se construye el análisis de modificaciones, identifica objetos por una fingerprint de su cuerpo normalizado usando un hash de 64 bits no criptográfico en lugar de SHA-256. Es una elección meditada. Lo que necesita la comparación estructural es determinismo: el mismo cuerpo de objeto debe producir siempre la misma fingerprint dentro de una ejecución. No necesita resistencia a colisiones, porque un atacante que controla ambos lados de la comparación ya ha ganado por otros medios, y pagar un hash criptográfico completo por cada objeto en un documento de un millón de objetos es un coste real sin beneficio

Dos reglas de normalización importan más que la elección del hash. Las referencias indirectas se pliegan a un token de placeholder en lugar de expandirse al contenido referenciado: expandirlas copiaría el cuerpo de un objeto compartido en cada quien lo referencia, así que una pequeña edición en un descriptor de fuente compartido invalidaría la fingerprint de cada objeto que llegue a él, y el informe sería ilegible. Y los números de objeto en sí quedan excluidos de la fingerprint, porque una reescritura puede renumerar objetos sin cambiar nada semántico

El emparejamiento corre luego en dos pasadas, alineando primero por fingerprint y emparejando el resto por número de objeto para identificar cambios en vez de una adición más una eliminación. Las comprobaciones baratas van primero en todo momento: una diferencia en el número de páginas se informa antes de que empiece cualquier recorrido de objetos

Diff de revisiones PDF en dos pasadas en PDFlibPas: comprobación de número de páginas primero, fingerprints de 64 bits, alineación por fingerprint y emparejamiento por número de objeto
El motor de comparación calcula fingerprints de cuerpos de objeto normalizados, informa primero de las diferencias de número de páginas y luego empareja por fingerprint y número de objeto

Una trampa: la autocomparación no garantiza idéntico

La primera prueba natural para un motor de diff es comparar un fichero consigo mismo y afirmar que el resultado es idéntico. Esa afirmación no se cumple aquí, y el motivo es instructivo. La ruta pública de carga y la ruta de carga de documento de nivel inferior no configuran la decodificación igual, así que el mismo fichero cargado por las dos rutas puede producir fingerprints que difieren en algunos objetos. El motor no está equivocado; las dos cargas produjeron genuinamente estados en memoria distintos

En lugar de forzar la unión de las dos rutas, la semántica de comparación se enuncia de forma estrecha: el análisis compara el estado actual del documento contra una revisión anterior, y solo informa idéntico cuando los dos conjuntos de fingerprints coinciden exactamente. Esa es la pregunta que los usuarios hacen de verdad, y no exige que los dos loaders sean intercambiables. Cuando diseñáis una función de comparación, definir qué significa «lo mismo» es más parte del trabajo que calcularlo

Dónde usarlo

Dos sitios. En un informe de validación, junto a la comprobación de firmas, para que quien revisa vea no solo si la firma está criptográficamente intacta sino qué le pasó al documento después; el lado de firma se cubre en firma y validación PAdES. Y en una puerta de entrada, donde un documento que llega de fuera se coteja con la copia que enviasteis, de modo que un contrato devuelto con una anotación añadida se trate de forma distinta a uno con una página editada

Una advertencia sobre el alcance. Este análisis os dice qué cambió entre dos revisiones de la misma línea de un documento. No os dice si el contenido visible es engañoso, si el appearance stream de un campo de formulario coincide con su valor, o si un texto oculto bajo un overlay sigue presente en el content stream. Eso necesita tratamiento aparte, y el lado de eliminación de contenido se cubre en el artículo sobre redaction verdadera. Los puntos de entrada de análisis y comparación están documentados en la página de producto de la losLab PDF Developer Library