Para saber qué cambió en un PDF después de firmarlo, el PDFium Component para Delphi y Lazarus provee TPdf.AnalyzeSignatureRevisions, un analizador de cambios de revisión 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 sombra como un riesgo separado. 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 esos guardados posteriores estaban permitidos, y una marca verde en la firma no lo responde
¿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 los parámetros de transform FieldMDP, y no ofrece diff a nivel de objeto entre revisiones, así que el analizador en FPdfPades.pas trabaja directamente sobre bytes crudos. Eso tiene una consecuencia práctica sobre la que conviene diseñar. TPdf.AnalyzeSignatureRevisions lee los bytes retenidos cuando el documento se cargó, jamás una copia producida por SaveAs, porque un archivo reescrito perdió justamente la estructura de revisiones que se analiza. Si el documento vino de una fuente progresiva que no terminó de descargar, el reporte devuelve SourceStatus = pvssIncomplete y Status = prasIndeterminate en lugar de analizar un archivo truncado
Reconstruir fronteras de revisión desde startxref, streams xref y /Prev
El analizador reconstruye las fronteras de revisión siguiendo cada startxref hacia atrás por tablas xref clásicas, streams de referencias cruzadas, entradas /XRefStm de referencia híbrida y la cadena /Prev, como se define para actualizaciones incrementales en ISO 32000-1 §7.5.6 y §7.5.8. El largo cubierto de cada firma es el final de su segundo tramo ByteRange, y el analizador mapea ese largo a la revisión cuya sección xref lo contiene. Cuando ninguna revisión coincide, la firma recibe prrCoveredRevisionNotFound y un estado Indeterminate. El estado de cada objeto se repite luego 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 re-declaran la tabla xref completa en cada guardado incremental, y una entrada que sigue apuntando al mismo objeto sin cambios se salta en lugar de reportarse como modificación. Sin esa comparación, un llenado de formulario perfectamente legal se ahogaría en cientos de cambios falsos
Las definiciones sombra 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 el xref de esa revisión no referencia es invisible para un visor normal, y sin embargo es justo el tipo de preparación del que dependen los shadow attacks: contenido oculto se planta antes o después de firmar y luego se activa volteando una referencia. AnalyzePadesSignatureRevisionsBytes registra ese objeto como un cambio no autoritativo con IsAuthoritative = False, lo califica prdSuspicious sin importar el nivel de permiso, y agrega prrUnreferencedObjectDefinition al set de riesgos. Dos riesgos relacionados cubren otros trucos estructurales: prrDuplicateObjectDefinition dispara cuando una sección xref lista el mismo objeto más de una vez, y prrSignatureObjectRedefined dispara 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 para cada firma?
DocMDP y FieldMDP se aplican por separado para cada firma, en la revisión cubierta de esa misma 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 primero se clasifica 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 cargue /JavaScript, /JS, /Launch, /OpenAction, /AA, /RichMedia o /EmbeddedFile se vuelve prckActiveContent. La decisión sigue luego ISO 32000-1 §12.8.2.2: con P=1 todo salvo datos de referencias cruzadas y material de validación queda despermitido; P=2 permite llenar formularios y firmar más veces pero rechaza cambios de anotación; P=3 también permite anotaciones. El contenido de página, la estructura del documento, los metadatos, el contenido activo y los objetos borrados quedan despermitidos bajo cualquier nivel DocMDP, y se califican prdSuspicious cuando la firma no carga DocMDP alguno, ya que una firma de aprobación no prohíbe nada formalmente pero el lector ya no ve lo que se firmó
FieldMDP, de ISO 32000-1 §12.8.2.4, achica 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 calificado por la cadena /Parent y lo compara con la lista de bloqueo por coincidencia exacta, así que liste 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 se vuelve prdIndeterminate y se levanta prrFieldMdpUnresolved. Las decisiones por cambio luego se agregan de lo peor primero, con Suspicious por encima de Disallowed, Disallowed por encima de Indeterminate, e Indeterminate por encima de Allowed, así que un solo objeto sombra pesa más que cualquier cantidad de actualizaciones de campo legítimas
¿Por qué algunos cambios vuelven Indeterminate en lugar de seguros?
Los cambios vuelven Indeterminate siempre que el analizador no pueda probar que un cambio está permitido, porque en una verificación de firmas lo desconocido jamás debe reportarse como permitido. Un caso común se maneja en cambio con precisión: la validación de largo plazo agrega un /DSS y reescribe el catálogo, lo que de otro modo contaría como 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 stream de referencias cruzadas apuntan hacia object streams comprimidos, y el analizador no expande object streams dentro de esta frontera de seguridad, así que esos cambios aparecen como prckCompressedObject con prrCompressedObjectUnresolved, despermitidos bajo P=1 e Indeterminate en los demás casos. 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 terminan como Indeterminate, jamás como 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 pruébelos 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 en ese gate es deliberado. prrDuplicateObjectDefinition se agrega al set de riesgos sin degradar Status por sí mismo, y una transform FieldMDP que no se puede parsear solo afecta el estado una vez que un campo de formulario realmente cambia, así que un gate que mira solo Status puede perder evidencia que el reporte ya contiene. Tenga presente también lo que el reporte no afirma. TPadesRevisionAnalysisReport no dice nada sobre si la firma CMS es criptográficamente válida ni sobre si el certificado del firmante encadena a una raíz de su confianza. El análisis de revisiones responde 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 al momento de firmar
Las mismas reglas se pueden redactar al momento de firmar a través de 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 vuelve los bits /Ff del diccionario seed-value descrito en ISO 32000-1 §12.7.4.5, Reasons, LegalAttestations y AcceptableCertificates restringen lo que un firmante posterior puede elegir, LockAction con LockFields escribe un /SigFieldLock indirecto, y CertificationPermission de 1 a 3 convierte la firma en una firma de certificación. Las transform DocMDP y FieldMDP van ambas a un único arreglo /Reference sobre 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 llenado de formulario y firma
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 son fáciles de errar si arma esto a mano. El /Perms /DocMDP del catálogo debe referenciar el diccionario de valor de firma, no la anotación widget, y el escritor conserva el valor de firma como su propio objeto indirecto por esa razón. Un diccionario /Perms existente puede ya cargar derechos de uso /UR3, así que el escritor lo copia e inserta /DocMDP en lugar de reemplazarlo, siguiendo el diccionario de permisos de ISO 32000-1 §12.8.4. Un documento que ya carga /DocMDP se niega a una segunda firma de certificación con EPadesCrypto, y lo mismo las opciones inconsistentes: un bloqueo Include o Exclude sin nombres de campo, un bloqueo All con lista de campos, una attestación legal en una firma no certificada, o un punto en el nombre del campo raíz. La firma remota agrega una regla más, porque el certificado de firma es desconocido cuando PreparePadesRemoteSignature corre: fijar CertificateRequired ahí exige una lista AcceptableCertificates explícita, mientras la firma local puede caer de vuelta al certificado del firmante ya resuelto
El análisis de revisiones completa la caja de herramientas de firmas en lugar de reemplazar cualquier parte de ella. Arranque con inspeccionar firmas PDF y niveles PAdES con el PDFium Component para leer el diccionario y el nivel base, mire por qué los validadores rechazan firmas PAdES para los fallos estructurales que vienen antes de cualquier pregunta de revisión, y doble el veredicto dentro de una auditoría de riesgos de seguridad PDF más amplia junto a los chequeos de JavaScript y archivos incrustados. TPdf.AnalyzeSignatureRevisions, TPadesSignatureFieldOptions y el escritor PAdES incremental mostrado acá vienen con el PDFium Component para Delphi, C++Builder y Lazarus