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 >
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
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 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