O HotPDF, o componente PDF Delphi, agora rejeita signature wrapping: desde a v2.759.0 que tanto o VerifyLoadedSignatureEx como o validador de lotes exigem que o vão entre os dois segmentos do /ByteRange seja exatamente a string hexadecimal do /Contents, delimitadores incluídos, e a v2.761.0 acrescenta o AddLoadedSignedSignatureField para que uma segunda assinatura possa ser acrescentada a um PDF já assinado como uma revisão incremental limpa. As duas alterações pertencem uma à outra, porque uma segunda assinatura correta é precisamente o layout que o verificador mais estrito espera
A situação que expôs o problema é banal. Um contrato é assinado pelo fornecedor, depois circula até a um aprovador que tem de contrassinar sem perturbar a primeira assinatura. A segunda revisão é acrescentada depois da primeira, o seu próprio /ByteRange cobre o ficheiro todo crescido, e ambas as assinaturas deviam verificar. Chegar lá à mão significava escrever uma secção incremental por conta própria, e o fixture de testes que fazia exatamente isso revelou-se uma estrutura de signature wrapping de manual que o antigo verificador aceitava sem pestanejar. Se ainda não olhou para a API de verificação, o guia de verificação de assinaturas digitais PDF com o HotPDF cobre as bases sobre que este artigo constrói
O que pertence afinal ao vão do ByteRange?
O vão tem de conter o valor /Contents completo e mais nada: a ISO 32000-1 §12.8.3.3 diz que a string hexadecimal, com os seus delimitadores < e >, encaixa com precisão no espaço entre os dois intervalos de bytes, e a ISO 32000-2 §12.8.1 transporta a mesma regra para a frente. A Tabela 252 e os documentos PAdES só dizem que o digest exclui o valor de Contents, o que se lê facilmente como excluir só os dígitos hex. Lançamentos anteriores do HotPDF liam-no assim: o PreparePDFForSigning e a preparação de CMS em streaming faziam hash também nos parênteses angulares, com um comentário no código a insistir que os parênteses tinham de ficar cobertos. Os validadores que comparam o vão com o valor da assinatura marcam esse layout como um byte range inválido, por isso a v2.759.0 tira ambos os delimitadores dos intervalos assinados. Uma verificação independente rápida em qualquer ficheiro assinado é olhar dois bytes: o byte no offset ByteRange[1] tem de ser < e o byte no offset ByteRange[2] - 1 tem de ser >
Porque é que uma verificação de vão não vazio falha ao apanhar signature wrapping?
Uma verificação de vão não vazio só prova que alguma coisa ficou de fora do digest, não o que ficou de fora, e aí está toda a superfície de ataque. O placeholder de /Contents é reservado com milhares de dígitos zero, enquanto um contentor CMS real raramente o enche. Um atacante pode fechar a string hexadecimal cedo dentro desse padding de zeros com um >, escrever objetos novos ou uma revisão forjada no resto do espaço reservado, e deixar os intervalos de bytes intactos. A assinatura CMS continua a verificar porque todos os bytes assinados estão inalterados, os intervalos continuam a começar em 0 e a acabar no tamanho do ficheiro, e o antigo verificador do HotPDF reportava svValid com CoversWholeDocument posto a True. Um leitor de PDF, entretanto, interpreta o que quer que esteja naquele buraco sem assinatura
O HotPDF agora trata o vão como dados a validar byte a byte. O verificador lê o vão, tira os delimitadores, aceita só dígitos hex mais espaço em branco PDF (tab, line feed, form feed, carriage return, espaço), descodifica os dígitos e exige que o resultado seja igual ao /Contents do dicionário de assinatura exatamente. Qualquer outra coisa degrada o resultado para svInvalidByteRange. A verificação corre tanto no caminho de assinatura única como no ValidateLoadedSignatureBatch, que mantinha a sua própria lógica de cobertura e precisava da mesma correção. Ficheiros produzidos pelo HotPDF antes da v2.759.0, cujo vão tinha só dígitos com os parênteses justamente dentro dos intervalos, ainda verificam, por isso documentos arquivados não ficam subitamente vermelhos
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 ficheiro assinado com o BeginIncrementalUpdate, chame o AddLoadedSignedSignatureField, guarde com o SaveIncrementalUpdate, e depois assine o ficheiro preparado com a class function THotPDF.SignPDFWithPFX. Antes da v2.761.0 a receita documentada de chamar o THPDFPage.AddSignedSignatureField depois do BeginIncrementalUpdate não podia funcionar, porque o CurrentPage é nil em modo incremental e nada conseguia pendurar um placeholder de /V num campo de um documento carregado. O método novo cria o widget na página carregada e pendura sob o /V o mesmo dicionário de placeholder que o caminho de documento novo usa, por isso ambas as rotas de assinatura partilham uma serialização. Para a primeira assinatura em si, o artigo sobre criar assinaturas digitais PAdES no 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 pontos, 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 discreto do que os seus irmãos. Os outros criadores de campos AddLoaded* põem /NeedAppearances true no AcroForm, o que manda um visualizador regenerar aparências de campos; num documento assinado essa regeneração pode reescrever conteúdo assinado, por isso o método novo tira a flag de novo a menos que a origem já a trouxesse. O /SigFlags mantém o seu valor original OR 3 (SignaturesExist mais AppendOnly, ISO 32000-1 Tabela 219). Também não precisa de chamar MarkDirty na página: acrescentar a /Annots e a /Fields propaga a flag de sujidade ao objeto indireto dono, e uma marca explícita de página só arrastaria um dicionário de página inalterado para a nova revisão, que a análise de revisões depois reportaria como uma modificação de página. Por fim, o placeholder escreve /ByteRange antes do /Contents, porque o patcher localiza primeiro a sentinela /ByteRange e procura para a frente pela string hexadecimal correspondente
O que muda quando um signatário externo ou um HSM produz o CMS?
Nada muda no fluxo de trabalho, mas os offsets agora significam o que a especificação diz. O PreparePDFForSigning devolve dois intervalos base zero cujo vão é a string /Contents inteira, e o ContentsHexStart é o índice base um do primeiro dígito hex no AnsiString. Um CMS mais curto é acolchoado com 0 no fim, antes do > de fecho. Como o PreparePDFForSigning remenda a primeira sentinela não remendada que encontra, prepare exatamente um placeholder por revisão, e prefira o InsertSignatureHexAt com os offsets devolvidos ao InsertSignatureHex baseado em procura quando já existem assinaturas anteriores no ficheiro
var
Bytes, ToSign, CmsHex: AnsiString;
R1Start, R1Len, R2Start, R2Len, HexStart, HexLen: Integer;
begin
Bytes := LoadFileAsAnsiString('Prepared.pdf'); // o seu helper
if not THotPDF.PreparePDFForSigning(Bytes, R1Start, R1Len,
R2Start, R2Len, HexStart, HexLen) then
raise Exception.Create('No signature placeholder found');
// O vão é a string hex inteira: '<' acaba o intervalo 1, '>' precede o 2
Assert(Bytes[R1Start + R1Len + 1] = '<');
Assert(Bytes[R2Start] = '>');
ToSign := Copy(Bytes, R1Start + 1, R1Len) + Copy(Bytes, R2Start + 1, R2Len);
CmsHex := SignDetachedWithHsm(ToSign); // o seu signatário CMS, hex DER
if not THotPDF.InsertSignatureHexAt(Bytes, HexStart, HexLen, CmsHex) then
raise Exception.Create('CMS does not fit the reserved space');
SaveAnsiStringToFile(Bytes, 'SignedTwice.pdf'); // o seu helper
end;
Onde ficam os limites das novas verificações?
A verificação do vão fecha um buraco concreto e não convém vendê-la a mais. O svValid continua a significar integridade de bytes mais uma chave que casa com o certificado incorporado; a confiança nesse certificado é uma decisão separada. O vão só é validado quando o verificador tem os bytes de origem, que o VerifyLoadedSignatureEx lê do ficheiro carregado e os overloads de TStream recebem de si. Para a primeira assinatura num ficheiro contrassignado, o CoversWholeDocument é corretamente False, e saber se a revisão acrescentada só adicionou uma assinatura ou também mudou páginas é uma questão para a análise de DocMDP, FieldMDP e revisões no HotPDF. Note ainda que a verificação de PDF MAC anexado compara offsets com as posições de < e >, por isso aceita tanto o layout antigo como o novo; qualquer ferramenta sua que crave os offsets anteriores à v2.759.0 falha primeiro quando encontrar um ficheiro acabado de assinar
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 PDF Delphi nativo traz a validação de vão mais estrita, segundas assinaturas incrementais e os hooks de signatário externo mostrados acima num único componente