Artigo Técnico

Por que os Validadores Rejeitam Assinaturas PAdES: PDFium no Delphi

Um validador rejeita uma assinatura PAdES por um dos três motivos em quase todos os casos que depuramos com o Componente PDFium no Delphi: a matriz /ByteRange ainda contém seus marcadores de posição zerados, a hora de assinatura /M não é uma string de data PDF bem formatada, ou uma atualização incremental removeu a entrada /Encrypt de um documento criptografado. Todos os três produzem arquivos que abrem bem, renderizam bem e falham no momento em que um validador em conformidade lê o dicionário de assinatura

O cenário que motiva este artigo é dolorosamente específico. Você assina um contrato com o fluxo de trabalho do artigo de assinatura PAdES B-B, seu próprio código de inspeção relata que a assinatura está presente e estruturalmente íntegra, cada teste local está verde — e então a outra parte faz o upload do arquivo para a plataforma de validação dela e recebe uma cruz vermelha. Nada sobre a falha é visível em um visualizador, porque nenhum desses três defeitos afeta o conteúdo da página. Eles residem inteiramente no dicionário de assinatura e no trailer do arquivo, que é exatamente onde os validadores procuram e os visualizadores na maioria das vezes não

Por que a minha assinatura ByteRange de PDF é inválida?

Uma rejeição de ByteRange quase sempre significa que os quatro campos nunca foram preenchidos, não que os intervalos estejam sutilmente errados. A matriz /ByteRange [A B C D] declara dois intervalos, bytes A a A+B e bytes C a C+D, e a norma EN 319 142-1 §6.3 (requisito k) exige que juntos eles cubram o arquivo inteiro, exceto pela string /Contents codificada em hexadecimal. Em números: A é 0, C >= A+B, C+D é igual ao tamanho do arquivo, e o espaço entre B e C contém exatamente a string hexadecimal <...> — dois bytes de colchetes mais dois caracteres hexadecimais por byte de dados CMS. Um validador que lê [0 0 0 0] conclui que a assinatura não cobre nada e a rejeita, independentemente de quão correta seja a estrutura CMS dentro de /Contents

Vale a pena entender a mecânica de como isso acontece porque o mesmo padrão existe em todas as pilhas de assinatura. Um assinante não pode saber os deslocamentos finais até que o arquivo seja estruturado, então o gravador emite a matriz com marcadores de posição zerados de largura fixa e os preenche após a estruturação. Nas versões do Componente PDFium anteriores à 2.14.1, esse preenchimento procurava pelo padrão de marcador de posição começando a partir da posição de /Contents — mas /ByteRange fica antes de /Contents no dicionário, portanto a busca não encontrava nada e todas as três substituições falhavam silenciosamente. O resumo CMS foi computado sobre os intervalos corretos, então a criptografia estava íntegra; a declaração desses intervalos permaneceu em zero, por isso todo validador em conformidade recusava o arquivo. Os Carimbos de Data/Hora de Documento PAdES B-LTA, que usam o mesmo layout de dicionário, falhavam da mesma forma — relevante se você desenvolve com base em assinaturas de longo prazo B-LT e B-LTA. A versão 2.14.1 preenche a partir do início do objeto de assinatura, e a correção é coberta por um teste de regressão que analisa o arquivo produzido e afirma a aritmética abaixo

function SignedByteRangeCoversFile(const FileName: string): Boolean;
var
  Raw: TBytes;
  Text: AnsiString;
  P, N: Integer;
  F: array[0..3] of Int64;
begin
  Raw := TFile.ReadAllBytes(FileName);
  SetString(Text, PAnsiChar(@Raw[0]), Length(Raw));
  P := Pos('/ByteRange', Text);          // first signature only
  Result := P > 0;
  if not Result then Exit;
  Inc(P, Length('/ByteRange'));
  for N := 0 to 3 do
  begin
    while (P <= Length(Text)) and not (Text[P] in ['0'..'9']) do Inc(P);
    F[N] := 0;
    while (P <= Length(Text)) and (Text[P] in ['0'..'9']) do
    begin
      F[N] := F[N] * 10 + Ord(Text[P]) - Ord('0');
      Inc(P);
    end;
  end;
  // EN 319 142-1 §6.3 req k: spans cover everything except /Contents
  Result := (F[0] = 0) and (F[2] >= F[0] + F[1]) and
            (F[2] + F[3] = Int64(Length(Raw)));
end;

Vinte linhas de código RTL simples, sem chamadas de biblioteca, e ele captura toda a classe de falhas no ponto de produção. Se você guardar uma asserção deste artigo, guarde esta: analise sua própria saída assinada e verifique as três igualdades antes que o arquivo saia do seu processo

Por que os validadores sinalizam a hora de assinatura /M como malformada?

Validadores rígidos rejeitam uma hora de assinatura que não seja uma string de data PDF completa, e as duas partes que mais frequentemente faltam são o prefixo D: e o marcador de deslocamento UTC. A ISO 32000-1 §7.9.4 define o formato de data como D:YYYYMMDDHHmmSS seguido por um deslocamento — Z para UTC, ou uma relação assinada +HH'mm' com ele. Um simples 20260709143000 é analisado como uma data por um leitor tolerante, mas um validador que aplica a gramática literalmente vê uma string malformada em um campo de formato obrigatório e sinaliza a assinatura. As versões do Componente PDFium anteriores à 2.14.4 gravavam a entrada /M de TPdf.SignPades and SignPadesBytes exatamente nessa forma simples; desde a 2.14.4 a entrada traz o prefixo D: e o marcador Z, de modo que a hora de assinatura declarada é lida como D:20260709143000Z

Duas notas práticas se aplicam a este campo. Primeiro, grave em UTC e declare isso: um carimbo de data/hora sem um marcador de deslocamento força o validador a adivinhar a relação de fuso horário, e o §7.9.4 trata o deslocamento como parte do formato e não como um detalhe opcional. Segundo, lembre-se do que o /M é — a hora declarada pelo assinante, uma alegação e não uma prova. Um validador verifica seu formato aqui, não sua veracidade; a hora comprovável vem de um carimbo de data/hora RFC 3161 no PAdES B-T e superior. Formatar um campo corretamente e confiar nele são decisões separadas, e os validadores apenas impõem a primeira

Por que SignPades gera EPadesCrypto em um PDF criptografado?

O Componente PDFium se recusa a assinar um documento criptografado porque a alternativa seria produzir um arquivo que os leitores em conformidade destruiriam ao abrir. Uma assinatura PAdES é anexada como uma atualização incremental, e a ISO 32000-1 §7.5.6 exige que o novo trailer de uma seção de atualização traga cada entrada do trailer anterior, exceto por /Prev — em um documento criptografado isso inclui o /Encrypt. Remova-o e o trailer mais recente declarará o arquivo como não criptografado, fazendo com que um leitor em conformidade analise o corpo criptografado como texto simples e obtenha lixo. Pior, os fluxos e strings que o assinante anexa teriam que ser criptografados com a chave do documento para serem legais, o que um injetor de assinatura de texto simples não pode fazer. Não há como anexar uma assinatura de texto simples válida a um arquivo criptografado, portanto, desde a versão 2.14.2, TPdf.SignPades, SignPadesBytes e InjectPadesDssMarkers geram EPadesCrypto em vez de emitir um resultado corrompido ou não verificável

try
  if not Pdf.SignPades('contract-signed.pdf', AThumbprint) then
    Writeln('Signing reported failure');
except
  on E: EPadesCrypto do
  begin
    // e.g. 'SignPadesBytes: the source document is encrypted;
    //       remove encryption before signing'
    Writeln('Cannot sign: ', E.Message);
  end;
end;

A exceção é o resultado correto, portanto, projete o fluxo de trabalho em torno disso em vez de capturar e tentar novamente. Decripte primeiro com a autoridade do proprietário, assine a cópia em texto simples e, se o canal de distribuição exigir criptografia, aceite que criptografar-depois-assinar e assinar-depois-criptografar produzem artefatos diferentes com histórias de validação diferentes. Uma assinatura computada sobre bytes de texto simples não pode sobreviver à criptografia desses bytes, então as opções honestas são um arquivo de texto simples assinado ou uma decisão de política documentada ao lado do código

Como dois bugs podem certificar um ao outro

O defeito do ByteRange permaneceu oculto pelo tempo que ficou porque nosso próprio validador estava errado de uma maneira complementar, e essa é a lição mais transferível deste artigo. A verificação de cobertura em ValidatePadesCompliance exigia que o segundo intervalo começasse exatamente em A+B — um intervalo de tamanho zero — o que classifica incorretamente o layout padrão onde o espaço contém a string hexadecimal /Contents. Assim, o validador interno rejeitava precisamente o layout em conformidade e o gerador interno nunca produzia um, e os fluxos de ponta a ponta de assinatura-e-validação coincidentemente permaneciam verdes. Cada bug negou ao conjunto de testes o contraexemplo que teria exposto o outro. A versão 2.14.1 corrigiu ambos os lados na mesma versão: o gerador preenche todos os quatro campos, e o validador aceita C >= A+B com C+D igual ao tamanho do arquivo

A correção metodológica é a validação cruzada em relação a uma implementação que você não escreveu. Um gerador e um validador que compartilham uma base de código, um autor ou mesmo apenas um modelo mental do formato podem concordar com um mal-entendido compartilhado indefinidamente; um validador independente quebra a simetria. Execute sua saída assinada através de pelo menos um verificador de conformidade externo antes de um lançamento, e mantenha uma autoverificação estrutural na compilação como a primeira linha rápida — o TPdf.ValidatePades, descrito no artigo de inspeção de assinatura, relata a falha de cobertura como um problema nomeado

var
  R: TPadesValidationResult;
begin
  Pdf.FileName := 'contract-signed.pdf';
  Pdf.Active := True;
  R := Pdf.ValidatePades;
  if ppeiByteRangeNotCoveringFile in R.Issues then
    Writeln('ByteRange does not cover the file');
  if not R.IsCompliant then
    Writeln('Structural issues present: do not ship this file');
end;

Uma lista de verificação de rejeição para saídas assinadas

Quando uma plataforma rejeita sua assinatura PAdES, verifique as causas estruturais simples antes de suspeitar de certificados ou cadeias de confiança. Leia a matriz /ByteRange e verifique as três igualdades: primeiro campo zero, segundo intervalo começando no final do primeiro ou depois dele, segundo intervalo terminando exatamente no final do arquivo. Leia a entrada /M e verifique se ela é uma string de data §7.9.4 completa com o prefixo D: e um marcador de deslocamento. Confirme se o documento de origem não estava criptografado quando a assinatura foi anexada — e se a sua pilha o assinou de qualquer maneira sem reclamações, trate esse silêncio como um bug na pilha. Todas as três verificações rodam em bytes brutos em milissegundos e, em nossa experiência, explicam a rejeição com muito mais frequência do que qualquer causa criptográfica

As chamadas de assinatura, inspeção e validação usadas aqui — SignPades, ValidatePades e as verificações de conformidade PAdES com sua aritmética ByteRange corrigida, formatação de data e restrição de criptografia — acompanham o Componente PDFium para Delphi, C++Builder e Lazarus