Artigo Técnico

Signature wrapping em PDF: ByteRange e segunda assinatura

O HotPDF, o componente Delphi PDF, agora rejeita signature wrapping: desde a v2.759.0 tanto o VerifyLoadedSignatureEx quanto o validador em lote exigem que o vão entre os dois segmentos de /ByteRange seja exatamente a hex string de /Contents, delimitadores incluídos, e a v2.761.0 acrescenta o AddLoadedSignedSignatureField para que uma segunda assinatura possa ser anexada a um PDF já assinado como uma incremental revision limpa. As duas mudanças pertencem uma à outra, porque uma segunda assinatura correta é precisamente o layout que o verificador mais rigoroso espera

A situação que expôs o problema é banal. Um contrato é assinado pelo fornecedor, depois roteado a um aprovador que precisa contrassignar sem perturbar a primeira assinatura. A segunda revisão é anexada depois da primeira, o /ByteRange dela cobre o arquivo inteiro já crescido, e as duas assinaturas deveriam verificar. Chegar lá à mão significava escrever uma seção incremental você mesmo, e o fixture de teste que fazia exatamente isso acabou sendo uma estrutura de signature wrapping de livro didático que o verificador antigo aceitava feliz. Se você nunca olhou a API de verificação antes, o guia de verificação de assinaturas digitais de PDF com o HotPDF cobre o básico sobre o qual este artigo constrói

O que exatamente pertence ao gap do ByteRange?

O gap precisa conter o valor completo de /Contents e nada mais: a ISO 32000-1 §12.8.3.3 diz que a hexadecimal string, com os delimitadores < e >, cabe precisamente no espaço entre as duas faixas de bytes, e a ISO 32000-2 §12.8.1 carrega a mesma regra adiante. A Tabela 252 e os documentos PAdES só dizem que o digest exclui o valor de Contents, o que é fácil de ler como excluir só os dígitos hex. Releases anteriores do HotPDF liam assim: o PreparePDFForSigning e a preparação de CMS em streaming hasheavam os colchetes angulares também, com um comentário no fonte insistindo que os colchetes precisavam estar cobertos. Validadores que comparam o gap com o valor da assinatura sinalizam esse layout como um byte range inválido, então a v2.759.0 tira os dois delimitadores das faixas assinadas. Uma checagem independente rápida em qualquer arquivo assinado é olhar dois bytes: o byte no offset ByteRange[1] precisa ser < e o byte no offset ByteRange[2] - 1 precisa ser >

Anatomia de um ByteRange de assinatura de PDF corretamente preenchido no HotPDF: a primeira faixa cobre o arquivo desde o byte zero, o gap segura a hex string completa de /Contents incluindo os delimitadores de menor e maior, a segunda faixa cobre o trailer até o fim, e duas checagens de um byte em ByteRange[1] e ByteRange[2] - 1 confirmam o layout em qualquer arquivo assinado
Desde a v2.759.0 os delimitadores sentam fora das faixas assinadas, então o digest cobre só os dígitos e o gap pode ser validado byte a byte

Por que uma checagem de gap não vazio não vê o signature wrapping?

Uma checagem de gap não vazio só prova que algo ficou fora do digest, não o que ficou, e essa é a superfície de ataque inteira. O placeholder de /Contents é reservado com milhares de dígitos zero, enquanto um contêiner CMS real raramente o preenche. Um atacante pode fechar a hex string cedo dentro desse padding de zeros com um >, gravar objetos novos ou uma revisão forjada no resto do espaço reservado, e deixar as faixas de bytes intocadas. A assinatura CMS ainda verifica porque cada byte assinado está inalterado, as faixas ainda começam em 0 e terminam no tamanho do arquivo, e o antigo verificador do HotPDF reportava svValid com CoversWholeDocument definido como True. Um leitor de PDF, enquanto isso, interpreta o que quer que sente naquele buraco sem assinatura

O HotPDF agora trata o gap como dados a validar byte a byte. O verificador lê o gap, tira os delimitadores, aceita só dígitos hex mais whitespace do PDF (tab, line feed, form feed, carriage return, espaço), decodifica os dígitos e exige que o resultado iguale exatamente o /Contents do dicionário de assinatura. Qualquer outra coisa degrada o resultado para svInvalidByteRange. A checagem roda tanto no caminho de assinatura única quanto no ValidateLoadedSignatureBatch, que mantinha lógica de cobertura própria e precisava do mesmo fix. Arquivos produzidos pelo HotPDF antes da v2.759.0, cujo gap segurava só dígitos com os colchetes sentando logo dentro das faixas, ainda verificam, então documentos arquivados não ficam vermelhos de repente

Como o signature wrapping explora um ByteRange de PDF pouco checado em Delphi: o atacante fecha a hex string cedo dentro de milhares de dígitos zero reservados, grava uma revisão forjada no gap sem assinatura sem tocar byte coberto nenhum, e a checagem antiga do HotPDF reportava svValid com CoversWholeDocument true até a v2.759.0 começar a validar o gap byte a byte
Um gap não vazio só prova que algo ficou fora do digest, não o quê — o buraco com padding é a superfície de ataque inteira
var
  Pdf: THotPDF;
  Info: THPDFSignatureInfo;
  Status: THPDFSignatureVerifyStatus;
  I: Integer;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.LoadFromFile('SignedTwice.pdf');
    for I := 0 to Pdf.GetLoadedSignatureFieldCount - 1 do
    begin
      Status := Pdf.VerifyLoadedSignatureEx(I, Info);
      case Status of
        svValid:
          if Info.CoversWholeDocument then
            Writeln(Info.FieldName, ': valid, covers the whole file')
          else
            Writeln(Info.FieldName, ': valid, ',
              Info.UnsignedTrailingBytes, ' bytes appended later');
        svInvalidByteRange:
          Writeln(Info.FieldName, ': ByteRange gap rejected (wrapping?)');
      else
        Writeln(Info.FieldName, ': failed, status ', Ord(Status));
      end;
    end;
  finally
    Pdf.Free;
  end;
end;

Como acrescentar uma segunda assinatura a um PDF já assinado?

Abra o arquivo assinado com BeginIncrementalUpdate, chame AddLoadedSignedSignatureField, salve com SaveIncrementalUpdate, depois assine o arquivo preparado com a class function THotPDF.SignPDFWithPFX. Antes da v2.761.0 a receita documentada de chamar THPDFPage.AddSignedSignatureField depois do BeginIncrementalUpdate não podia funcionar, porque o CurrentPage é nil em modo incremental e nada podia anexar um placeholder de /V a um field num documento carregado. O método novo cria o widget na página carregada e pendura o mesmo dicionário de placeholder que o caminho de documento novo usa sob o /V, então as duas rotas de assinatura compartilham uma serialização. Para a primeira assinatura em si, o artigo sobre criar assinaturas digitais PAdES em Delphi percorre a pipeline de PFX

var
  Pdf: THotPDF;
  FieldIndex: Integer;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.BeginIncrementalUpdate('Signed.pdf');
    // Página 0, retângulo do widget em points, 8192 bytes reservados para o CMS
    FieldIndex := Pdf.AddLoadedSignedSignatureField(0, 320, 60, 520, 120,
      'ApproverSignature', 8192);
    if FieldIndex < 0 then
      raise Exception.Create('Page index out of range');
    Pdf.SaveIncrementalUpdate('Prepared.pdf');
  finally
    Pdf.Free;
  end;

  if not THotPDF.SignPDFWithPFX('Prepared.pdf', 'SignedTwice.pdf',
    'approver.pfx', 'pfx-password') then
    raise Exception.Create('Second signature failed');
end;

O AddLoadedSignedSignatureField é deliberadamente mais quieto que os irmãos dele. Os outros criadores de field AddLoaded* definem /NeedAppearances true no AcroForm, o que manda um viewer regenerar appearances de fields; num documento assinado essa regeneração pode reescrever conteúdo assinado, então o método novo remove a flag de novo a menos que a fonte já a carregasse. O /SigFlags mantém o valor original dele OR 3 (SignaturesExist mais AppendOnly, ISO 32000-1 Tabela 219). Você também não precisa chamar MarkDirty na página: acrescentar a /Annots e a /Fields propaga a flag de dirty para o objeto indireto dono, e uma marca explícita de página só arrastaria um dicionário de página inalterado para a revisão nova, que a análise de revisão então reportaria como uma modificação de página. Por fim, o placeholder grava /ByteRange antes do /Contents, porque o patcher localiza o sentinel de /ByteRange primeiro e busca adiante pela hex string correspondente

O fluxo de trabalho do HotPDF Delphi para contrassinar um PDF já assinado: o BeginIncrementalUpdate abre o arquivo, o AddLoadedSignedSignatureField cria o widget e reserva o placeholder de /Contents, o SaveIncrementalUpdate anexa uma segunda revisão, e o SignPDFWithPFX a preenche, deixando a primeira assinatura válida com UnsignedTrailingBytes enquanto o ByteRange novo cobre o arquivo inteiro já crescido
Um placeholder por revisão, preparado e remendado pela mesma serialização nas duas rotas de assinatura — o layout limpo que o verificador mais rigoroso espera

O que muda quando um assinador externo ou HSM produz o CMS?

Nada muda no fluxo de trabalho, mas os offsets agora significam o que a especificação diz. O PreparePDFForSigning retorna duas faixas 0-based cujo gap é a string de /Contents inteira, e o ContentsHexStart é o índice 1-based do primeiro dígito hex no AnsiString. Um CMS mais curto recebe padding de 0 no fim, antes do > de fechamento. Como o PreparePDFForSigning remenda o primeiro sentinel não remendado que encontra, prepare exatamente um placeholder por revisão, e prefira o InsertSignatureHexAt com os offsets retornados ao InsertSignatureHex baseado em busca quando assinaturas anteriores já existem no arquivo

var
  Bytes, ToSign, CmsHex: AnsiString;
  R1Start, R1Len, R2Start, R2Len, HexStart, HexLen: Integer;
begin
  Bytes := LoadFileAsAnsiString('Prepared.pdf');   // seu helper
  if not THotPDF.PreparePDFForSigning(Bytes, R1Start, R1Len,
    R2Start, R2Len, HexStart, HexLen) then
    raise Exception.Create('No signature placeholder found');

  // O gap é a hex string inteira: '<' encerra a faixa 1, '>' precede a faixa 2
  Assert(Bytes[R1Start + R1Len + 1] = '<');
  Assert(Bytes[R2Start] = '>');

  ToSign := Copy(Bytes, R1Start + 1, R1Len) + Copy(Bytes, R2Start + 1, R2Len);
  CmsHex := SignDetachedWithHsm(ToSign);           // seu assinador CMS, DER em hex
  if not THotPDF.InsertSignatureHexAt(Bytes, HexStart, HexLen, CmsHex) then
    raise Exception.Create('CMS does not fit the reserved space');
  SaveAnsiStringToFile(Bytes, 'SignedTwice.pdf');  // seu helper
end;

Onde ficam os limites das checagens novas?

A checagem de gap fecha um buraco específico e não deve ser vendida além da conta. svValid ainda significa integridade de bytes mais uma chave que casa com o certificado embutido; confiança nesse certificado é uma decisão separada. O gap é validado só quando o verificador tem os bytes de origem, que o VerifyLoadedSignatureEx lê do arquivo carregado e as sobrecargas de TStream recebem de você. Para a primeira assinatura num arquivo contrassignado, o CoversWholeDocument é corretamente False, e saber se a revisão anexada só acrescentou uma assinatura ou também mudou páginas é uma pergunta para DocMDP, FieldMDP e análise de revisão no HotPDF. Note também que a checagem de PDF MAC anexado compara offsets com as posições de < e >, então ele aceita tanto o layout antigo quanto o novo; qualquer ferramenta sua que hard-code os offsets pré-v2.759.0 vai falhar primeiro quando encontrar um arquivo recém-assinado

Se a sua aplicação Delphi ou C++Builder assina, contrassigna ou audita PDFs, o caminho mais seguro é deixar uma biblioteca produzir e verificar o mesmo layout. O HotPDF, o componente Delphi PDF nativo traz a validação de gap mais rigorosa, segundas assinaturas incrementais e os hooks de assinador externo mostrados acima num único componente