Artículo técnico

¿Dónde acaba una revisión PDF? Updates forjados tras firmar

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

Por qué %%EOF es el sitio equivocado para cortar un fichero

Cada actualización incremental termina con su propio marcador de fin de fichero (ISO 32000-1 §7.5.6), así que partir el fichero en cada %%EOF parece la forma obvia de encontrar revisiones. Pero el marcador son solo cinco bytes, y nada impide que esos bytes aparezcan dentro de un content stream, un fichero incrustado o un programa de fuente. Los ficheros linearizados añaden un segundo problema: el Anexo F (§F.3.4) permite que el trailer de la primera página acabe con su propio startxref y %%EOF cerca del principio del fichero, 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 justo lo que un falsificador puede moldear. El mapa de revisiones de PDFlibPas funciona al revés: sigue la cadena /Prev, anota el offset en bytes de cada sección de referencias cruzadas que la cadena leyó de verdad, y solo entonces busca secuencias startxref N … %%EOF, conservando un límite solo cuando N es uno de esos offsets anotados. Un marcador perdido dentro de un stream necesitaría un startxref delante que nombre una sección real para que pudiera contar algo

Mapa de revisiones de PDFlibPas en Delphi siguiendo la cadena /Prev y casando offsets de startxref para hallar los límites de revisión de las actualizaciones incrementales, ignorando marcadores %%EOF perdidos dentro de streams y trailers de primera página linearizados
Solo los offsets de startxref que la cadena /Prev leyó de verdad marcan el final de una revisión, lo que deja los bytes %%EOF perdidos dentro de los streams sin voz sobre dónde corta PDFlibPas el fichero

Los bytes entre esos tres tokens son menos ordenados de lo que §7.5.5 sugiere. 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 §7.2.2 (Tabla 1) pueden repetirse, y ninguna regla pone techo. Pérdete un marcador relleno de espacios y dos revisiones se fusionan: la firmada ahora parece acabar 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 caiga ese relleno en el fichero, 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 §7.5.5 nunca exige salto de línea tras trailer, así que trailer<< en una sola línea es sintaxis conforme. Leído línea a línea, esa línea parece una cabecera de subsección de referencias cruzadas — y la cadena se para a mitad

Recorrer el mapa de revisiones con PDFlibPas

Los accessors de revisiones están declarados en TPDFlib e implementados en la edición Small Business: crea un TPDFlibSmallBusiness desde la unidad PDFlibSmallBusiness (Enterprise desciende de ella y lleva las mismas llamadas). Sobre un TPDFlib plano devuelven su valor de fallo (0, 7 o -1) y ponen LastErrorCode a PDFLIB_ERROR_SMALLBUSINESS_REQUIRED. Las revisiones se numeran desde 0, el documento original. Cada llamada toma un nombre de fichero y una contraseña y construye el mapa desde los bytes en disco, así que las respuestas describen el fichero tal y como está almacenado, nunca un documento que tengas cargado y editado de casualidad. GetDocumentRevisionKind informa del cambio más profundo 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:

KindSignificadoOrigen típico
0Solo housekeeping: objetos que no cambian nada de lo que ve el lectorUn cross-reference stream, un nuevo contenedor de object stream
1Material de validaciónDocument security store, VRI, certificados, CRLs, respuestas OCSP
2Sello de tiempo de documentoUna firma /DocTimeStamp y el campo que la lleva
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 muestra el documento
7No se pudo determinarAlgo de la revisión no se pudo leer; trátala como no segura
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 elementos 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 fiarse de las demás. Devuelve 1 solo cuando la cadena se siguió hasta la sección original; un /Prev roto o cíclico, o una cadena de más de 256 eslabones, deja sin ver las actualizaciones más viejas, y cada contador e índice pasa a ser una cota inferior. Una profundidad de 256 deja sitio para la historia de guardado y firma de ficheros reales, y una cadena que se apunta a sí misma acaba casi enseguida. Ojo con la indexación del bucle: GetStringListItem cuenta desde 1 mientras las revisiones cuentan desde 0, y arrancar el bucle interno en 0 descarta el primer objeto cambiado de cada revisión sin ningún error

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

La validación de largo plazo PAdES añade actualizaciones legítimamente después de la última firma: un document security store (/Type /DSS), diccionarios /VRI por firma, streams de certificados y revocación, sellos de tiempo de documento. Los niveles de certificación los dejan pasar, lo que convierte el 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: /Type es lo que la actualización haya querido escribir. Un viewer 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 todos los renderers seguirán pintando el texto nuevo mientras un clasificador que se crea el /Type ve housekeeping. /VRI, /Sig y /DocTimeStamp funcionan igual

El mapa de revisiones de PDFlibPas ignora la etiqueta que una actualización escribe en 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 era una de esas cosas. 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 volver atrás y avalar un objeto que escribió la revisión firmada. Los cambios en la estructura del documento cuentan como contenido siempre que cambien lo que un lector resolvería:

PDFlibPas GetDocumentRevisionKind reportando como contenido del documento una actualización forjada 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 renderer, y PDFlibPas sigue reportando la actualización como contenido del documento porque el objeto era contenido de página antes
  • sustituir un object stream existente, porque cada objeto comprimido dentro de él pasa a resolverse al sustituto (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, incluso cuando la actualización no redefinió nada de lo que la firma cubría, porque cambiar la raíz sustituye el documento entero sin tocar un solo objeto firmado
  • un stream reescrito cuyo diccionario es byte a byte idéntico al viejo: un /Length indirecto o un cuerpo sustituto relleno hasta el mismo tamaño dejan el diccionario igual 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 llevan cabecera que quitar

Desde PDF 1.5 una actualización puede guardar objetos reescritos dentro de un object stream, y §7.5.7 es tajante con el formato: solo se guardan 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 la cabecera buscando la cadena obj no encuentra nada al frente de un miembro y sigue buscando dentro de su cuerpo. Dale a un diccionario de página reescrito después de firmar una cadena de aspecto inocente como (obj<</Type/DSS>>) y esa búsqueda se para 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 desde el frente de un objeto sin comprimir, comprueba que los números nombran el objeto esperado y trata una cabecera ausente o que no cuadra como ilegible en lugar de seguir cazando

Los cross-reference streams traen una ambigüedad de campo propia. Bajo §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 contenedor 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. Lee ese número de objeto como si fuera un offset y una entrada comprimida aterriza en la revisión que abarque esas posiciones pequeñas en bytes — casi siempre el documento original, lo 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 null, así que esas entradas ni añaden ni liberan nada y se quedan fuera del mapa

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

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

// Comprueba las actualizaciones posteriores a una firma contra una política estilo 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 relleno 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 fichero 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;                      // el 7 está por encima de 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, el 2 permite rellenar formularios y firmar, el 3 añade 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. Escribe el bucle tú mismo cuando la política es tuya y no del certificador, por ejemplo una regla de admisión que rechaza contratos anotados lleve el P que lleve el fichero. Cuando la política debe venir del fichero, GetSignatureModificationVerdict de 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 a una pregunta distinta de clasificar cambios contra una copia del documento que conservaste: al mapa de revisiones no le hace falta nada más que el fichero que tiene delante

Auditoría de firmas estilo DocMDP en Delphi con PDFlibPas usando techos de GetDocumentRevisionKind para P 1, P 2 y P 3, comprobando antes GetDocumentRevisionsComplete y GetSignatureRevisionIndex
Cada nivel DocMDP se convierte en un techo del kind de revisión: P 1 tolera los kinds 0 a 2, P 2 hasta 4, P 3 hasta 5, y una revisión sin determinar suspende todos los niveles

Añadir sigue la misma cadena. El trailer de una actualización incremental debe llevar una entrada /Prev con la ubicación de la sección de referencias cruzadas anterior (§7.5.6), y un documento que solo se podía abrir reconstruyendo la tabla de referencias cruzadas desde un escaneo completo no tiene esa ubicación que dar. AppendToFile devuelve 0 para él y deja el fichero destino intacto. Guárdalo por completo y firma la copia limpia — una reescritura completa no arrastra las firmas existentes

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