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
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:
| Kind | Significado | Origen típico |
|---|---|---|
| 0 | Solo housekeeping: objetos que no cambian nada de lo que ve el lector | Un cross-reference stream, un nuevo contenedor de object stream |
| 1 | Material de validación | Document security store, VRI, certificados, CRLs, respuestas OCSP |
| 2 | Sello de tiempo de documento | Una firma /DocTimeStamp y el campo que la lleva |
| 3 | Firma | Un campo de firma existente que recibe su valor |
| 4 | Campo de formulario | El valor de un campo o su apariencia |
| 5 | Anotación | Cualquier anotación que no sea un widget |
| 6 | Contenido del documento | Todo lo que puede cambiar lo que muestra el documento |
| 7 | No se pudo determinar | Algo 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:
- 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
/Rootnombra 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
/Lengthindirecto 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
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