Artículo técnico

¿Dónde acaba una revisión PDF? Actualizaciones falsificadas

Un contrato se firma, luego gana una actualización más, y un script de auditoría funde esa actualización dentro de la revisión firmada o da por material de validación la página reescrita. Ninguna de las dos cosas ocurre cuando una revisión termina donde startxref nombra una sección que la cadena /Prev alcanzó y un objeto reescrito conserva su clase anterior — las dos reglas detrás del mapa de revisiones de PDFlibPas

Por qué %%EOF es el lugar equivocado para cortar un archivo

Cada actualización incremental termina con su propio marcador de fin de archivo (ISO 32000-1 §7.5.6), así que partir el archivo en cada %%EOF parece la manera obvia de encontrar revisiones. El marcador son solo cinco bytes, y nada impide que esos bytes aparezcan dentro de un content stream, un archivo incrustado o un programa de fuente. Los archivos linealizados añaden un segundo problema: el Anexo F (§F.3.4) permite que el trailer de la primera página termine con su propio startxref y %%EOF cerca del inicio del archivo, muy por delante de la tabla de referencias cruzadas principal. Partir en cada marcador inventa revisiones que ninguna sección de referencias cruzadas respalda. Adivinar qué marcadores son falsos es peor — esa adivinanza es justamente lo que un falsificador puede moldear. El mapa de revisiones de PDFlibPas funciona al revés: sigue la cadena /Prev, registra el offset en bytes de cada sección de referencias cruzadas que la cadena realmente leyó, y solo entonces busca secuencias startxref N … %%EOF, conservando un límite únicamente cuando N es uno de esos offsets registrados. Un marcador perdido dentro de un stream necesitaría un startxref delante que nombre una sección real para poder contar algo

PDFlibPas mapa de revisiones en Delphi siguiendo la cadena /Prev y emparejando offsets de startxref para hallar límites de revisión de actualizaciones incrementales, ignorando marcadores %%EOF perdidos dentro de streams y trailers de primera página linealizados
Solo los offsets de startxref que la cadena /Prev realmente leyó marcan el fin de una revisión, lo que deja los bytes %%EOF perdidos dentro de streams sin voz en dónde corta PDFlibPas el archivo

Los bytes entre esos tres tokens son menos prolijos de lo que sugiere la §7.5.5. La norma dibuja startxref, el offset y el marcador en líneas consecutivas, pero los marcadores de fin de línea y los demás caracteres de espacio en blanco de la §7.2.2 (Tabla 1) pueden repetirse, y ninguna regla pone tope a cuántos. Perder un marcador con relleno funde dos revisiones: la firmada ahora parece terminar después de bytes que nadie firmó. El mapa de revisiones de PDFlibPas sigue la secuencia de palabra clave a operando a marcador con todo el espacio en blanco que los separe y dondequiera que ese relleno caiga en el archivo, y cuenta cada palabra clave una sola vez. La palabra clave trailer tiene una articulación igual de floja: << es un delimitador (§7.2.2, Tabla 2) y la §7.5.5 nunca exige un salto de línea después de trailer, así que trailer<< en una sola línea es sintaxis conforme. Leída línea por línea, esa línea parece un header de subsección de referencias cruzadas — y la cadena se detiene a medio camino

Recorrer el mapa de revisiones con PDFlibPas

Los accessors de revisión están declarados en TPDFlib e implementados en la edición Small Business: cree un TPDFlibSmallBusiness desde la unidad PDFlibSmallBusiness (Enterprise desciende de ella y trae las mismas llamadas). Sobre un TPDFlib simple devuelven su valor de fallo (0, 7 o -1) y fijan LastErrorCode en PDFLIB_ERROR_SMALLBUSINESS_REQUIRED. Las revisiones se numeran desde 0, el documento original. Cada llamada toma un nombre de archivo y una contraseña y construye el mapa desde los bytes en disco, así que las respuestas describen el archivo tal como está almacenado, nunca un documento que usted haya cargado y editado. GetDocumentRevisionKind reporta el cambio de mayor alcance de una revisión: una actualización que añade una respuesta OCSP y además reescribe una página se reporta como la reescritura de página:

KindSignificadoFuente típica
0Solo housekeeping: objetos que no cambian nada de lo que un lector veCross-reference stream, un contenedor de object stream nuevo
1Material de validaciónDocument security store, VRI, certificados, CRLs, respuestas OCSP
2Sello de tiempo de documentoUna firma /DocTimeStamp y el campo que la carga
3FirmaUn campo de firma existente que recibe su valor
4Campo de formularioEl valor de un campo o su apariencia
5AnotaciónCualquier anotación que no sea un widget
6Contenido del documentoTodo lo que puede cambiar lo que el documento muestra
7No se pudo determinarAlgo de la revisión no se pudo leer; trátela como insegura
uses
  System.SysUtils, PDFlibSmallBusiness;

procedure ListRevisions(const FileName, Password: WideString);
const
  KindNames: array[0..7] of string = ('housekeeping', 'validation material',
    'document time stamp', 'signature', 'form field', 'annotation',
    'content', 'undetermined');
var
  Pdf: TPDFlibSmallBusiness;
  Count, I, J, Kind, Changes: Integer;
begin
  Pdf := TPDFlibSmallBusiness.Create;
  try
    Count := Pdf.GetDocumentRevisionCount(FileName, Password);
    if Count = 0 then
      raise Exception.Create('the cross-reference chain could not be followed');
    if Pdf.GetDocumentRevisionsComplete(FileName, Password) <> 1 then
      Writeln('WARNING: history incomplete, every figure below is a lower bound');
    for I := 0 to Count - 1 do
    begin
      Kind := Pdf.GetDocumentRevisionKind(FileName, Password, I);
      if (Kind < 0) or (Kind > 7) then
        Kind := 7;
      Writeln(Format('revision %d: bytes %d..%d, %s', [I,
        Pdf.GetDocumentRevisionStart(FileName, Password, I),
        Pdf.GetDocumentRevisionEnd(FileName, Password, I),
        KindNames[Kind]]));
      // una línea 'objnum,change,class,detail' por objeto
      Changes := Pdf.GetDocumentRevisionChanges(FileName, Password, I);
      if Changes <> 0 then
      try
        for J := 1 to Pdf.GetStringListCount(Changes) do  // los items cuentan desde 1
          Writeln('    ' + Pdf.GetStringListItem(Changes, J));
      finally
        Pdf.ReleaseStringList(Changes);
      end;
    end;
  finally
    Pdf.Free;
  end;
end;

GetDocumentRevisionsComplete es la llamada que hay que leer antes de confiar en cualquiera de las demás. Devuelve 1 solo cuando la cadena se recorrió de regreso hasta la sección original; un /Prev roto o cíclico, o una cadena más profunda que 256 eslabones, deja las actualizaciones más viejas sin ver, y todo conteo e índice pasa a ser una cota inferior. Una profundidad de 256 da margen para la historia de guardar-y-firmar de archivos reales, y una cadena que se apunta a sí misma igual termina casi de inmediato. Ojo con la indexación del ciclo: GetStringListItem cuenta desde 1 mientras las revisiones cuentan desde 0, y arrancar el ciclo interno en 0 descarta el primer objeto cambiado de cada revisión sin ningún error

Cómo una actualización falsificada se hace pasar por material de validación

La validación de largo plazo PAdES añade legítimamente actualizaciones después de la última firma: un document security store (/Type /DSS), diccionarios /VRI por firma, streams de certificados y revocaciones, sellos de tiempo de documento. Los niveles de certificación las dejan pasar, lo que convierte al material de validación en la etiqueta más valiosa que puede llevar una actualización. También es la más barata de falsificar. El /Type es lo que la actualización quiso escribir. Un visor que dibuja una página nunca mira el /Type de un content stream, un diccionario de recursos o una fuente, así que una actualización puede reescribir el content stream de la página 3, añadir /Type /DSS a su diccionario, y todo renderizador seguirá pintando el texto nuevo mientras un clasificador que se cree el /Type ve housekeeping. /VRI, /Sig y /DocTimeStamp funcionan igual

El mapa de revisiones de PDFlibPas ignora la etiqueta que una actualización escribe sobre un objeto que reescribe. Un objeto reescrito cuenta como material de validación, sello de tiempo o firma solo cuando la versión anterior de ese mismo objeto ya lo era. Los streams de certificados, CRL y respuestas OCSP no llevan tipo propio, así que se reconocen por el document security store que apunta a ellos — y solo cuenta un store escrito en la revisión que se está juzgando o en una más vieja. Un store añadido después no puede alcanzar hacia atrás y avalar un objeto que escribió la revisión firmada. Los cambios a la estructura del documento cuentan como contenido siempre que cambien lo que un lector resolvería:

PDFlibPas GetDocumentRevisionKind reportando como contenido de documento una actualización falsificada con /Type /DSS que reescribe el content stream de una página, porque el mapa de revisiones clasifica un objeto reescrito por su versión anterior
Etiquetar un content stream de página reescrito con /Type /DSS no cambia nada para el renderizador, y PDFlibPas sigue reportando la actualización como contenido de documento porque el objeto era contenido de página antes
  • reemplazar un object stream existente, porque cada objeto comprimido dentro de él ahora resuelve al reemplazo (un contenedor totalmente nuevo se queda en housekeeping, ya que cada miembro tiene su propia entrada y se juzga ahí)
  • añadir un catálogo o una página
  • un trailer cuyo /Root nombra un catálogo distinto, aun cuando la actualización no redefina nada de lo que cubría la firma, porque cambiar la raíz reemplaza el documento entero sin tocar un solo objeto firmado
  • un stream reescrito cuyo diccionario es idéntico byte a byte al anterior: un /Length indirecto o un cuerpo de reemplazo rellenado al mismo tamaño mantiene el diccionario sin cambios mientras el dibujo cambia, así que solo los objetos sin cuerpo de stream se tratan como sin cambios con esa evidencia

Los miembros de un object stream no tienen header que quitar

Desde PDF 1.5 una actualización puede almacenar objetos reescritos dentro de un object stream, y la §7.5.7 es tajante sobre el formato: solo se almacenan los valores de los objetos, y las palabras clave obj y endobj no deben usarse. Un código que lee un objeto en su ubicación y quita el header buscando la cadena obj no encuentra nada al frente de un miembro y sigue buscando dentro de su cuerpo. Dele a un diccionario de página reescrito después de firmar una cadena de apariencia inocente como (obj<</Type/DSS>>) y esa búsqueda se detiene dentro de la cadena. El decodificador recibe <</Type/DSS>> — un diccionario de store que la revisión nunca escribió. El mapa de revisiones de PDFlibPas parsea N G obj del frente de un objeto sin comprimir, verifica que los números nombren el objeto esperado y trata un header faltante o inconsistente como ilegible en vez de seguir cazando

Los cross-reference streams cargan una ambigüedad a nivel de campo. Bajo la §7.5.8.3 (Tabla 18), solo una entrada de tipo 1 guarda un offset en bytes. El segundo campo de una entrada de tipo 2 es el número de objeto del object stream que la contiene y el tercero es el índice dentro de él, mientras que el segundo campo de una entrada de tipo 0 es el siguiente número de objeto libre. Lea ese número de objeto como offset y una entrada comprimida cae en la revisión que abarque esas posiciones de byte pequeñas — casi siempre el documento original, que esconde la reescritura dentro de los bytes firmados. El mapa de revisiones coloca cada entrada por la sección de referencias cruzadas que la declaró. La misma cláusula dice que un tipo de entrada distinto de 0, 1 o 2 es una referencia al objeto nulo, así que tales entradas no añaden ni liberan nada y se quedan fuera del mapa

¿Cuándo debe una auditoría de firmas negarse a responder?

Siempre que el archivo esconda parte de su propia historia. El mapa de revisiones lo reporta de tres maneras: GetDocumentRevisionsComplete devuelve 0, GetDocumentRevisionKind devuelve 7 para alguna revisión posterior a la firma, o GetSignatureRevisionIndex devuelve -1. Ese último valor cubre un ByteRange que no termina en un límite de revisión — hay bytes dentro del rango que la firma dice cubrir — y por igual un campo que no existe, no lleva valor de firma o vive en un archivo que no se pudo leer. Mande los tres a revisión manual con el índice de revisión y las líneas de GetDocumentRevisionChanges adjuntas, para que quien abra el archivo parta de números de objeto

// Verifica las actualizaciones posteriores a una firma contra una política tipo DocMDP.
// Los kinds están ordenados por severidad, así que cada nivel de política es un techo:
// P 1 tolera housekeeping, material de validación y sellos de tiempo (0..2),
// P 2 suma firma y llenado de formularios (..4), P 3 suma anotaciones (..5).
function UpdatesWithinPolicy(Pdf: TPDFlibSmallBusiness;
  const FileName, Password, FieldName: WideString; Level: Integer): Boolean;
const
  Ceiling: array[1..3] of Integer = (2, 4, 5);
var
  Signed, Count, I, Kind: Integer;
begin
  Result := False;
  if (Level < 1) or (Level > 3) then
    Exit;
  if Pdf.GetDocumentRevisionsComplete(FileName, Password) <> 1 then
    Exit;                        // parte de la historia nunca se vio
  Signed := Pdf.GetSignatureRevisionIndex(FileName, Password, FieldName);
  if Signed < 0 then
    Exit;                        // fuera de un límite de revisión, sin ese campo
                                 // firmado, o el archivo no se pudo leer
  Count := Pdf.GetDocumentRevisionCount(FileName, Password);
  for I := Signed + 1 to Count - 1 do
  begin
    Kind := Pdf.GetDocumentRevisionKind(FileName, Password, I);
    if (Kind = 7) or (Kind > Ceiling[Level]) then
      Exit;                      // 7 supera cualquier techo; se indica para el lector
  end;
  Result := True;
end;

Los techos siguen ISO 32000-2 §12.8.2.2 (Tabla 257): P 1 no permite cambio alguno, 2 permite llenar formularios y firmar, 3 suma anotaciones, y las actualizaciones incrementales que solo añaden datos DSS o sellos de tiempo de documento no cuentan como cambios en ningún nivel. ISO 32000-1 §12.8.2.2 define los mismos tres valores sin esa exención. Escriba el ciclo usted mismo cuando la política es suya y no del certificador, por ejemplo una regla de recepción que rechaza contratos anotados sin importar la P que traiga el archivo. Cuando la política debe salir del archivo, GetSignatureModificationVerdict sobre la misma clase lee el nivel de certificación, aplica los bloqueos por campo descritos en detectar ediciones en campos de formulario bloqueados con FieldMDP, y devuelve 5 para el caso fuera de límite. Ambos responden una pregunta distinta de la de clasificar cambios contra una copia del documento que usted guardó: el mapa de revisiones no necesita nada más que el archivo que tiene delante

PDFlibPas auditoría de firmas tipo DocMDP en Delphi usando techos de GetDocumentRevisionKind para P 1, P 2 y P 3, con GetDocumentRevisionsComplete y GetSignatureRevisionIndex verificados primero
Cada nivel DocMDP se vuelve un techo sobre el kind de revisión: P 1 tolera los kinds 0 a 2, P 2 hasta 4, P 3 hasta 5, y una revisión indeterminada falla en todos los niveles

Añadir sigue la misma cadena. El trailer de una actualización incremental debe llevar una entrada /Prev que dé la ubicación de la sección de referencias cruzadas anterior (§7.5.6), y un documento que solo pudo abrirse reconstruyendo la tabla de referencias cruzadas desde un barrido completo no tiene esa ubicación que dar. AppendToFile devuelve 0 para él y deja el archivo destino intacto. Guárdelo completo y firme la copia limpia en su lugar — una reescritura total no arrastra las firmas existentes

Para un archivo de contratos firmados, corra UpdatesWithinPolicy una vez por campo de firma antes de que se añada algo nuevo. Un archivo cuya revisión posterior lee como kind 7 debería llegar primero a una persona, antes que cualquier paso de firma o validación de largo plazo de PDFlibPas que entierre la revisión ilegible bajo otra actualización