Artigo Técnico

Análise de mudanças pós-assinatura em PDF no Delphi

Para descobrir o que mudou num PDF depois que ele foi assinado, o PDFium Component para Delphi e Lazarus oferece o TPdf.AnalyzeSignatureRevisions, um analisador de mudanças de revisão pós-assinatura que reconstrói toda revisão incremental a partir dos bytes originais do arquivo, avalia cada mudança de objeto posterior contra as regras DocMDP e FieldMDP daquela assinatura, e reporta definições de objetos shadow como um risco separado. A situação que ele mira é familiar para quem lida com contratos: um formulário certificado sai, volta com mais dois saves incrementais, e toda assinatura continua verificando. Isso é esperado, porque uma assinatura só cobre os bytes da própria revisão dela. A pergunta de verdade é se aqueles saves posteriores eram permitidos, e um check verde na assinatura não responde isso

Por que a API de assinaturas do PDFium não mostra o que mudou depois da assinatura?

A API de assinaturas do PDFium não consegue mostrar mudanças pós-assinatura porque ela só lê o dicionário de assinatura: /Contents, /ByteRange, /SubFilter e o valor de permissão DocMDP. O PDFium não tem grafo de revisões incrementais, não analisa parâmetros de transform FieldMDP, e não oferece diff nenhum de nível de objeto entre revisões, então o analisador em FPdfPades.pas trabalha direto sobre bytes crus. Isso tem uma consequência prática para a qual você deve desenhar. O TPdf.AnalyzeSignatureRevisions lê os bytes retidos quando o documento foi carregado, nunca uma cópia produzida por SaveAs, porque um arquivo reescrito perdeu justamente a estrutura de revisões que está sendo analisada. Se o documento veio de uma fonte progressiva que ainda não terminou de baixar, o relatório devolve SourceStatus = pvssIncomplete e Status = prasIndeterminate em vez de analisar um arquivo truncado

Reconstruindo fronteiras de revisão a partir de startxref, xref streams e /Prev

O analisador reconstrói as fronteiras de revisão seguindo cada startxref para trás por tabelas xref clássicas, cross-reference streams, entradas /XRefStm de referência híbrida e a cadeia /Prev, como definido para atualizações incrementais na ISO 32000-1 §7.5.6 e §7.5.8. O comprimento coberto de cada assinatura é o fim do segundo span ByteRange dela, e o analisador mapeia esse comprimento para a revisão em cuja seção xref ele cai. Quando nenhuma revisão casa, a assinatura ganha prrCoveredRevisionNotFound e um status Indeterminate. O estado de todo objeto é então reproduzido até a revisão coberta, e cada entrada xref posterior é comparada com esse estado. Isso importa mais do que parece: alguns writers redeclaram a tabela xref completa a cada save incremental, e uma entrada que ainda aponta para o mesmo objeto inalterado é pulada em vez de ser reportada como modificação. Sem essa comparação, um preenchimento de formulário perfeitamente legal afogaria em centenas de mudanças falsas

Como o AnalyzeSignatureRevisions reconstrói revisões incrementais a partir de bytes crus de PDF no Delphi: o ByteRange da assinatura termina dentro da revisão coberta, a cadeia Prev do xref caminha para trás por todo save posterior, o estado dos objetos é reproduzido até a revisão coberta, e entradas redeclaradas inalteradas são puladas em vez de reportadas como mudanças
Uma assinatura cobre só os bytes da própria revisão dela, então o analisador mapeia o segundo span ByteRange para uma revisão e avalia cada entrada xref posterior contra o estado de objetos reproduzido

Definições shadow são o caso que merece mais atenção. Um corpo de objeto que aparece dentro do intervalo de bytes de uma revisão posterior mas não é referenciado pelo xref dessa revisão é invisível para um viewer comum, ainda que seja exatamente o tipo de preparação de que ataques shadow dependem: conteúdo escondido é plantado antes ou depois da assinatura e depois ativado virando uma referência. O AnalyzePadesSignatureRevisionsBytes registra tal objeto como uma mudança não autoritativa com IsAuthoritative = False, dá a ele nota prdSuspicious não importa o nível de permissão, e adiciona prrUnreferencedObjectDefinition ao conjunto de riscos. Dois riscos relacionados cobrem outros truques estruturais: prrDuplicateObjectDefinition dispara quando uma seção xref lista o mesmo objeto mais de uma vez, e prrSignatureObjectRedefined dispara quando uma revisão posterior redefine um objeto de assinatura existente

Uma definição de objeto shadow dentro do intervalo de bytes de uma revisão PDF posterior: o corpo do objeto existe mas nenhuma entrada xref o referencia, então viewers nunca o mostram, e o AnalyzeSignatureRevisions no PDFium Component o registra como não autoritativo, dá nota prdSuspicious e levanta prrUnreferencedObjectDefinition junto dos riscos de duplicata e de assinatura redefinida
Conteúdo escondido é plantado antes ou depois da assinatura e ativado depois virando uma referência, e é por isso que um corpo não referenciado recebe nota suspicious não importa o nível de permissão DocMDP
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;

Como DocMDP e FieldMDP são aplicados para cada assinatura?

DocMDP e FieldMDP são aplicados separadamente para cada assinatura, na revisão coberta da própria assinatura, então uma assinatura de certificação e uma assinatura de aprovação posterior no mesmo arquivo podem chegar a vereditos diferentes sobre a mesma edição. Todo objeto posterior é primeiro classificado num TPadesRevisionChangeKind a partir das entradas /Type, /Subtype e /FT dele e do papel que desempenha nos grafos de página, formulário, anotação e DSS. Qualquer coisa carregando /JavaScript, /JS, /Launch, /OpenAction, /AA, /RichMedia ou /EmbeddedFile vira prckActiveContent. A decisão então segue a ISO 32000-1 §12.8.2.2: com P=1 tudo exceto dados de referência cruzada e material de validação é proibido; P=2 permite preenchimento de formulário e assinaturas adicionais mas rejeita mudanças de anotação; P=3 também permite anotações. Conteúdo de página, estrutura do documento, metadados, conteúdo ativo e objetos apagados são proibidos sob qualquer nível DocMDP, e recebem nota prdSuspicious quando a assinatura não carrega DocMDP nenhum, já que uma assinatura de aprovação não proíbe nada formalmente, mas o leitor não vê mais o que foi assinado

O FieldMDP, da ISO 32000-1 §12.8.2.4, estreita mais a decisão sobre campos de formulário. pfmaAll trava todo campo, pfmaInclude trava só os campos listados, e pfmaExclude trava tudo exceto os campos listados. Para aplicar Include ou Exclude, o analisador resolve cada campo mudado para o nome totalmente qualificado dele pela cadeia /Parent e compara com a lista de travamento por casamento exato, então liste nomes de campos terminais em vez de esperar que um nome de pai cubra os filhos. Quando um nome não pode ser resolvido ou a transform usa uma ação que o parser não reconhece, a mudança vira prdIndeterminate e prrFieldMdpUnresolved é levantado. As decisões por mudança então se consolidam pior-primeiro, com Suspicious acima de Disallowed, Disallowed acima de Indeterminate, e Indeterminate acima de Allowed, então um objeto shadow pesa mais que qualquer quantidade de atualizações legítimas de campos

O pipeline de notas que o AnalyzeSignatureRevisions aplica a cada mudança pós-assinatura no Delphi: um TPadesRevisionChangeKind a partir de entradas Type e Subtype, uma decisão DocMDP na revisão coberta de P=1 a P=3, uma checagem de trava FieldMDP sobre nomes de campos totalmente qualificados, e uma consolidação pior-primeiro de prdSuspicious até prdAllowed
Um objeto shadow pesa mais que qualquer quantidade de atualizações legítimas de campos porque Suspicious fica acima de Disallowed, Indeterminate e Allowed, enquanto alguns riscos são registrados ao lado do status sem rebaixá-lo

Por que algumas mudanças voltam Indeterminate em vez de seguras?

Mudanças voltam Indeterminate sempre que o analisador não consegue provar que uma mudança é permitida, porque numa checagem de assinatura um desconhecido jamais deve ser reportado como permitido. Um caso comum é tratado com precisão em vez disso: a validação de longo prazo adiciona um /DSS e reescreve o catálogo, o que de outro modo contaria como mudança estrutural sob P=1. O analisador remove /DSS e /Extensions dos dicionários de catálogo antigo e novo e compara o resto; quando nada mais difere, a reescrita é tratada como atualização de material de validação e permitida, então o aumento B-LT e B-LTA não quebra uma assinatura de certificação. Outras lacunas ficam abertas de propósito. Entradas Type-2 num cross-reference stream apontam para object streams comprimidos, e o analisador não expande object streams dentro dessa fronteira de segurança, então essas mudanças aparecem como prckCompressedObject com prrCompressedObjectUnresolved, proibidas sob P=1 e Indeterminate de outro modo. Orçamentos rígidos de 1024 revisões, 1.000.000 de números de objeto e 2.000.000 de mudanças reportadas produzem prrResourceLimitExceeded, e uma cadeia xref quebrada produz prrMalformedRevisionChain; ambos terminam como Indeterminate, nunca como pass

const
  StructuralRisks: TPadesRevisionRisks = [prrMalformedRevisionChain,
    prrDuplicateObjectDefinition, prrUnreferencedObjectDefinition,
    prrSignatureObjectRedefined, prrResourceLimitExceeded];

function RevisionVerdict(const R: TPadesRevisionAnalysisReport): string;
begin
  // Alguns riscos são registrados sem mudar o Status, então teste-os primeiro
  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;

A ordenação nesse gate é deliberada. O prrDuplicateObjectDefinition é adicionado ao conjunto de riscos sem rebaixar o Status por si só, e uma transform FieldMDP que não pode ser analisada só afeta o status quando um campo de formulário de fato muda, então um gate que olha só o Status pode perder evidência que o relatório já contém. Lembre também do que o relatório não afirma. O TPadesRevisionAnalysisReport não diz nada sobre se a assinatura CMS é criptograficamente válida ou se o certificado do signatário encadeia para uma raiz em que você confia. Análise de revisões responde à pergunta do que aconteceu depois da assinatura, e ela pertence ao lado da validação estrutural e de confiança, não no lugar delas

Escrevendo seed values e travas MDP na hora de assinar

As mesmas regras podem ser escritas na assinatura por meio do TPadesSignatureFieldOptions, que é o membro FieldOptions tanto de TPadesSignOptions quanto de TPadesRemoteSignOptions. O PDFium cria um widget mas não sabe gravar /SV, /Lock, uma transform FieldMDP ou DocMDP, ou o dicionário /Perms do catálogo, então o writer incremental PAdES do próprio componente produz esses objetos dentro da mesma atualização xref da assinatura. FieldName define o nome do campo raiz, RequiredSeedValues vira os bits /Ff do dicionário de seed values descrito na ISO 32000-1 §12.7.4.5, Reasons, LegalAttestations e AcceptableCertificates restringem o que um signatário posterior pode escolher, LockAction com LockFields grava um /SigFieldLock indireto, e CertificationPermission de 1 a 3 transforma a assinatura numa assinatura de certificação. As transforms DocMDP e FieldMDP vão ambas num único array /Reference no valor da assinatura, cada uma com /Data apontando para o 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;   // apenas preenchimento de formulário e assinatura
  Options.FieldOptions.RequiredSeedValues := [psvcSubFilter, psvcDigestMethod];
  Options.FieldOptions.LockAction := pfmaInclude;      // trava só estes 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;

Alguns detalhes dão errado com facilidade se você fizer isso na mão. No catálogo, o /Perms /DocMDP precisa referenciar o dicionário de valor da assinatura, não a anotação widget, e o writer mantém o valor da assinatura como objeto indireto próprio por essa razão. Um dicionário /Perms existente pode já carregar direitos de uso /UR3, então o writer o copia e insere o /DocMDP em vez de substituí-lo, seguindo o dicionário de permissões na ISO 32000-1 §12.8.4. Um documento que já carrega /DocMDP recusa uma segunda assinatura de certificação com EPadesCrypto, e opções inconsistentes também: uma trava Include ou Exclude sem nomes de campos, uma trava All com lista de campos, um attestation legal numa assinatura que não é de certificação, ou um ponto no nome do campo raiz. Assinatura remota adiciona mais uma regra, porque o certificado de assinatura é desconhecido quando o PreparePadesRemoteSignature roda: setar CertificateRequired ali exige uma lista explícita de AcceptableCertificates, enquanto a assinatura local pode cair no certificado do signatário resolvido

Análise de revisões completa o toolbox de assinaturas em vez de substituir qualquer parte dele. Comece por inspecionar assinaturas de PDF e níveis PAdES com o PDFium Component para ler o dicionário e o nível de base, olhe por que validadores rejeitam assinaturas PAdES para as falhas estruturais que vêm antes de qualquer pergunta de revisão, e junte o veredito a uma auditoria de riscos de segurança de PDF mais ampla ao lado das checagens de JavaScript e arquivos embutidos. O TPdf.AnalyzeSignatureRevisions, o TPadesSignatureFieldOptions e o writer incremental PAdES mostrado aqui vêm com o PDFium Component para Delphi, C++Builder e Lazarus