Para averiguar qué cambió en un PDF después de firmarlo, el PDFium Component para Delphi y Lazarus aporta TPdf.AnalyzeSignatureRevisions, un analizador de cambios de revisiones post-firma que reconstruye cada revisión incremental desde los bytes originales del archivo, califica cada cambio de objeto posterior contra las reglas DocMDP y FieldMDP de esa firma, y reporta las definiciones de objetos shadow como un riesgo aparte. La situación que ataca le resulta familiar a cualquiera que maneje contratos: un formulario certificado sale, vuelve con dos guardados incrementales más, y todas las firmas siguen verificando. Eso es lo esperado, porque una firma solo cubre los bytes de su propia revisión. La pregunta de verdad es si aquellos guardados posteriores estaban permitidos, y un check verde en la firma no responde a eso
¿Por qué la API de firmas de PDFium no puede mostrar qué cambió tras firmar?
La API de firmas de PDFium no puede mostrar cambios post-firma porque solo lee el diccionario de firma: /Contents, /ByteRange, /SubFilter y el valor de permiso DocMDP. PDFium no tiene grafo de revisiones incrementales, no parsea parámetros de transform FieldMDP y no ofrece ningún diff a nivel de objetos entre revisiones, así que el analizador de FPdfPades.pas trabaja directamente sobre bytes crudos. Eso tiene una consecuencia práctica con la que conviene diseñar. TPdf.AnalyzeSignatureRevisions lee los bytes retenidos cuando se cargó el documento, jamás una copia producida por SaveAs, porque un archivo reescrito ha perdido justo la estructura de revisiones que se está analizando. Si el documento vino de un origen progresivo que no ha terminado de descargarse, el report devuelve SourceStatus = pvssIncomplete y Status = prasIndeterminate en lugar de analizar un archivo truncado
Reconstruir los límites de revisión desde startxref, streams xref y /Prev
El analizador reconstruye los límites de revisión siguiendo cada startxref hacia atrás a través de tablas xref clásicas, cross-reference streams, entradas /XRefStm de referencia híbrida y la cadena /Prev, tal como se define para actualizaciones incrementales en ISO 32000-1 §7.5.6 y §7.5.8. La longitud cubierta de cada firma es el final de su segundo tramo ByteRange, y el analizador mapea esa longitud a la revisión cuya sección xref la contiene. Cuando ninguna revisión coincide, la firma recibe prrCoveredRevisionNotFound y un estado Indeterminate. El estado de cada objeto se reproduce después hasta la revisión cubierta, y cada entrada xref posterior se compara con ese estado. Esto importa más de lo que suena: algunos escritores redeclaran la tabla xref completa en cada guardado incremental, y una entrada que aún apunta al mismo objeto sin cambios se salta en lugar de reportarse como modificación. Sin esa comparación, un relleno de formulario perfectamente legal se ahogaría en cientos de cambios falsos
Las definiciones shadow son el caso que merece más atención. Un cuerpo de objeto que aparece dentro del rango de bytes de una revisión posterior pero que la xref de esa revisión no referencia es invisible para un visor normal, y sin embargo es justo la clase de preparación de la que dependen los shadow attacks: el contenido oculto se planta antes o después de firmar y luego se activa volteando una referencia. AnalyzePadesSignatureRevisionsBytes registra tal objeto como un cambio no autoritativo con IsAuthoritative = False, lo califica prdSuspicious al margen del nivel de permiso, y añade prrUnreferencedObjectDefinition al conjunto de riesgos. Dos riesgos relacionados cubren otros trucos estructurales: prrDuplicateObjectDefinition salta cuando una sección xref lista el mismo objeto más de una vez, y prrSignatureObjectRedefined salta cuando una revisión posterior redefine un objeto de firma existente
uses
SysUtils, TypInfo, PDFium, FPdfPades;
const
ShadowTag: array[Boolean] of string = ('', ' (shadow)');
var
Pdf: TPdf;
Report: TPadesRevisionAnalysisReport;
i, j: Integer;
begin
Pdf := TPdf.Create(nil);
try
Pdf.FileName := 'contract-returned.pdf';
Pdf.Active := True;
Report := Pdf.AnalyzeSignatureRevisions;
Writeln('Revisions: ', Report.RevisionCount,
' Signatures: ', Report.SignatureCount,
' Overall: ', GetEnumName(TypeInfo(TPadesRevisionAnalysisStatus),
Ord(Report.Status)));
for i := 0 to High(Report.Signatures) do
with Report.Signatures[i] do
begin
Writeln(Format('Signature %d covers revision %d, %d later, P=%d, FieldMDP=%s',
[SignatureIndex, CoveredRevisionIndex, LaterRevisionCount,
DocMdpPermission,
GetEnumName(TypeInfo(TPadesFieldMdpAction), Ord(FieldMdpAction))]));
for j := 0 to High(Changes) do
Writeln(Format(' rev %d obj %d %s -> %s%s',
[Changes[j].RevisionIndex, Changes[j].ObjectNumber,
GetEnumName(TypeInfo(TPadesRevisionChangeKind), Ord(Changes[j].Kind)),
GetEnumName(TypeInfo(TPadesRevisionDecision), Ord(Changes[j].Decision)),
ShadowTag[not Changes[j].IsAuthoritative]]));
end;
finally
Pdf.Free;
end;
end;
¿Cómo se aplican DocMDP y FieldMDP a cada firma?
DocMDP y FieldMDP se aplican por separado para cada firma, en la revisión cubierta propia de esa firma, así que una firma de certificación y una firma de aprobación posterior en el mismo archivo pueden llegar a veredictos distintos sobre la misma edición. Cada objeto posterior se clasifica primero en un TPadesRevisionChangeKind a partir de sus entradas /Type, /Subtype y /FT y del rol que juega en los grafos de página, formulario, anotación y DSS. Todo lo que lleve /JavaScript, /JS, /Launch, /OpenAction, /AA, /RichMedia o /EmbeddedFile pasa a ser prckActiveContent. La decisión sigue entonces ISO 32000-1 §12.8.2.2: con P=1 todo salvo datos de referencias cruzadas y material de validación queda desautorizado; P=2 permite rellenar formularios y firmas adicionales pero rechaza cambios de anotaciones; P=3 también permite anotaciones. El contenido de páginas, la estructura del documento, los metadatos, el contenido activo y los objetos borrados están desautorizados bajo cualquier nivel DocMDP, y se califican prdSuspicious cuando la firma no lleva DocMDP alguno, porque una firma de aprobación no prohíbe nada formalmente pero quien lee ya no ve lo que se firmó
FieldMDP, de ISO 32000-1 §12.8.2.4, estrecha aún más la decisión sobre campos de formulario. pfmaAll bloquea todos los campos, pfmaInclude bloquea solo los campos listados, y pfmaExclude bloquea todo salvo los campos listados. Para aplicar Include o Exclude, el analizador resuelve cada campo cambiado a su nombre completamente cualificado a través de la cadena /Parent y lo compara con la lista de bloqueo por coincidencia exacta, así que lista nombres de campos terminales en lugar de esperar que un nombre de padre cubra a sus hijos. Cuando un nombre no se puede resolver o la transform usa una acción que el parser no reconoce, el cambio pasa a prdIndeterminate y se levanta prrFieldMdpUnresolved. Las decisiones por cambio se agregan después peor primero, con Suspicious por encima de Disallowed, Disallowed por encima de Indeterminate, e Indeterminate por encima de Allowed, así que un solo objeto shadow pesa más que cualquier número de actualizaciones de campo legítimas
¿Por qué algunos cambios vuelven como Indeterminate en lugar de seguros?
Los cambios vuelven como Indeterminate siempre que el analizador no pueda probar que un cambio está permitido, porque en una comprobación de firmas un desconocido jamás debe reportarse como permitido. Un caso común se maneja en cambio con precisión: la validación a largo plazo añade un /DSS y reescribe el catálogo, lo que sin más contaría como un cambio estructural bajo P=1. El analizador quita /DSS y /Extensions de los diccionarios de catálogo viejo y nuevo y compara el resto; cuando nada más difiere, la reescritura se trata como una actualización de material de validación y se permite, así que la ampliación B-LT y B-LTA no rompe una firma de certificación. Otros huecos se dejan abiertos a propósito. Las entradas Type-2 de un cross-reference stream apuntan a object streams comprimidos, y el analizador no expande object streams dentro de esta frontera de seguridad, así que esos cambios afloran como prckCompressedObject con prrCompressedObjectUnresolved, desautorizados bajo P=1 e Indeterminate en los demás casos. Los presupuestos duros de 1024 revisiones, 1.000.000 de números de objeto y 2.000.000 de cambios reportados producen prrResourceLimitExceeded, y una cadena xref rota produce prrMalformedRevisionChain; ambos acaban en Indeterminate, jamás en un pase
const
StructuralRisks: TPadesRevisionRisks = [prrMalformedRevisionChain,
prrDuplicateObjectDefinition, prrUnreferencedObjectDefinition,
prrSignatureObjectRedefined, prrResourceLimitExceeded];
function RevisionVerdict(const R: TPadesRevisionAnalysisReport): string;
begin
// Algunos riesgos se registran sin cambiar Status, así que compruébalos primero
if R.Risks * StructuralRisks <> [] then
Exit('review: structural risk in the revision chain');
case R.Status of
prasNoLaterChanges: Result := 'accept: nothing was added after signing';
prasAllowed: Result := 'accept: every later change is permitted';
prasDisallowed: Result := 'reject: a change violates DocMDP or FieldMDP';
prasSuspicious: Result := 'reject: shadow or unconstrained content change';
prasIndeterminate: Result := 'review: the analyzer could not decide';
else
Result := 'not checked: no signatures or no original bytes';
end;
end;
El orden de esa puerta es deliberado. prrDuplicateObjectDefinition se añade al conjunto de riesgos sin degradar por sí mismo el Status, y una transform FieldMDP que no se puede parsear solo afecta al estado cuando un campo de formulario cambia de verdad, así que una puerta que mire solo al Status puede perder evidencia que el report ya contiene. Ten presente también lo que el report no afirma. TPadesRevisionAnalysisReport no dice nada sobre si la firma CMS es válida criptográficamente ni sobre si el certificado del firmante encadena a una raíz en la que confíes. El análisis de revisiones responde a la pregunta de qué pasó después de firmar, y va junto a la validación estructural y de confianza, no en su lugar
Escribir seed values y bloqueos MDP en el momento de firmar
Las mismas reglas se pueden redactar al firmar mediante TPadesSignatureFieldOptions, que es el miembro FieldOptions tanto de TPadesSignOptions como de TPadesRemoteSignOptions. PDFium puede crear un widget pero no sabe escribir /SV, /Lock, una transform FieldMDP o DocMDP, ni el diccionario /Perms del catálogo, así que el escritor PAdES incremental del propio componente produce estos objetos dentro de la misma actualización xref que la firma. FieldName fija el nombre del campo raíz, RequiredSeedValues se convierte en los bits /Ff del diccionario seed-value descrito en ISO 32000-1 §12.7.4.5, Reasons, LegalAttestations y AcceptableCertificates constriñen lo que un firmante posterior puede elegir, LockAction con LockFields escribe un /SigFieldLock indirecto, y CertificationPermission de 1 a 3 convierte la firma en firma de certificación. Las transforms DocMDP y FieldMDP van ambas a un único array /Reference en el valor de firma, cada una con /Data apuntando al catálogo
var
Options: TPadesSignOptions;
begin
Options := TPadesSignOptions.Default;
Options.CertificateThumbprint := 'A1B2C3D4E5F60718293A4B5C6D7E8F9012345678';
Options.Reason := 'Approved for release';
Options.FieldOptions.FieldName := 'Certification';
Options.FieldOptions.CertificationPermission := 2; // solo rellenar formularios y firmar
Options.FieldOptions.RequiredSeedValues := [psvcSubFilter, psvcDigestMethod];
Options.FieldOptions.LockAction := pfmaInclude; // bloquear solo estos campos
SetLength(Options.FieldOptions.LockFields, 2);
Options.FieldOptions.LockFields[0] := 'Total';
Options.FieldOptions.LockFields[1] := 'IBAN';
if not Pdf.SignPades('contract-certified.pdf', Options) then
Writeln('Signing failed');
end;
Unos cuantos detalles se hacen mal fácil si te montas esto a mano. El /Perms /DocMDP del catálogo debe referenciar al diccionario de valor de firma, no a la anotación widget, y el escritor mantiene el valor de firma como su propio objeto indirecto por esa razón. Un diccionario /Perms existente puede albergar ya derechos de uso /UR3, así que el escritor lo copia e inserta /DocMDP en vez de reemplazarlo, siguiendo el diccionario de permisos de ISO 32000-1 §12.8.4. Un documento que ya lleva /DocMDP se niega a una segunda firma de certificación con EPadesCrypto, y también lo hacen las opciones inconsistentes: un bloqueo Include o Exclude sin nombres de campo, un bloqueo All con lista de campos, una attestation legal en una firma sin certificación, o un punto en el nombre del campo raíz. La firma remota añade una regla más, porque el certificado de firma es desconocido cuando corre PreparePadesRemoteSignature: poner CertificateRequired ahí exige una lista explícita de AcceptableCertificates, mientras que la firma local puede caer al certificado de firmante ya resuelto
El análisis de revisiones completa la caja de herramientas de firmas en lugar de reemplazar ninguna parte de ella. Empieza por inspeccionar firmas PDF y niveles PAdES con el PDFium Component para leer el diccionario y el nivel de partida, mira por qué los validadores rechazan firmas PAdES para los fallos estructurales que llegan antes de cualquier pregunta sobre revisiones, y pliega el veredicto en una auditoría de riesgos de seguridad PDF más amplia junto a las comprobaciones de JavaScript y de archivos embebidos. TPdf.AnalyzeSignatureRevisions, TPadesSignatureFieldOptions y el escritor PAdES incremental mostrado aquí vienen con el PDFium Component para Delphi, C++Builder y Lazarus