A PDF Library for Delphi (PDFlibPas) extrai os certificados dentro de uma assinatura de PDF com uma caminhada DER pura sobre o CMS SignedData armazenado em /Contents, sem envolver CryptoAPI. Desde a v3.539.10 toda leitura aninhada é limitada pelo elemento pai dela, o padding de zeros depois do CMS é cortado no comprimento que o próprio CMS declara, e os object identifiers codificam o primeiro subidentifier combinado deles em base-128. A regra de limites e o fix de OID substituíram código que produzia respostas erradas sem levantar erro nenhum, e a regra de padding evita que o leitor mais rigoroso rejeite assinaturas reais
O lado de leitura importa mais do que parece. Ferramentas de validação de longo prazo precisam puxar o certificado do signer e os emissores dele de uma assinatura existente antes de buscar dados de revogação, um relatório de auditoria precisa dizer quem assinou, e um build Lazarus no Linux não tem funções de mensagem do Windows para se apoiar. Um parser nessa posição raramente quebra com entrada ruim. O modo de falha que machuca é uma contagem de certificados que inclui bytes de um vizinho, um match de signer feito contra o campo errado, ou um OID que silenciosamente vira um OID diferente. Uma pipeline de assinatura construída em cima disso relata nonsense com toda a confiança
Lendo os certificados do signer de um PDF assinado
Cinco métodos do TPDFlib cobrem o lado de leitura, e todos recebem InputFile, Password, FieldName: cada chamada abre o arquivo em modo somente leitura, responde e fecha de novo. GetSignatureEmbeddedCertificateCount e GetSignatureEmbeddedCertificateDER enumeram o conjunto de certificados na ordem de encoding, o GetSignatureSignerCertificateDER retorna o certificado que produziu um dado SignerInfo, e GetSignatureCertificateChainLength / GetSignatureCertificateChainDER caminham desse signer até o emissor mais distante que a própria assinatura carrega. Os índices são zero-based. Guarde os resultados em AnsiString, e é justamente por isso que a biblioteca os retorna assim: um blob DER que passa por string ou por um TStrings atravessa uma conversão de conjunto de caracteres e volta corrompido
uses
SysUtils, Classes, PDFlibrary;
procedure SaveDer(const FileName: string; const Der: AnsiString);
var
Fs: TFileStream;
begin
Fs := TFileStream.Create(FileName, fmCreate);
try
if Der <> '' then
Fs.WriteBuffer(Der[1], Length(Der));
finally
Fs.Free;
end;
end;
const
Src = 'contract-signed.pdf';
Field = 'Signature1';
var
Pdf: TPDFlib;
ChainLen, I: Integer;
SignerDer, LastDer: AnsiString;
begin
Pdf := TPDFlib.Create;
try
WriteLn('Certificates in the CMS: ',
Pdf.GetSignatureEmbeddedCertificateCount(Src, '', Field));
SignerDer := Pdf.GetSignatureSignerCertificateDER(Src, '', Field, 0);
if SignerDer = '' then
raise Exception.Create('signer certificate missing or not matched');
SaveDer('signer.cer', SignerDer);
ChainLen := Pdf.GetSignatureCertificateChainLength(Src, '', Field, 0);
for I := 0 to ChainLen - 1 do
SaveDer(Format('chain-%d.cer', [I]),
Pdf.GetSignatureCertificateChainDER(Src, '', Field, 0, I));
if ChainLen > 0 then
begin
LastDer := Pdf.GetSignatureCertificateChainDER(Src, '', Field, 0, ChainLen - 1);
WriteLn('Next issuer, if any: ', Pdf.GetCertificateIssuerURLs(LastDer));
end;
finally
Pdf.Free;
end;
end;
Dois pontos dessa saída pedem cuidado. Uma contagem de 0 não é um diagnóstico: campo ausente, senha errada, um blob que não é DER e um SignedData que simplesmente omite o conjunto opcional de certificados todos retornam 0 ou string vazia, então registre o nome do campo junto do número. E uma cadeia que termina antes de um certificado autoemitido também não é erro. O construtor de cadeia usa só os certificados embutidos na assinatura, então os emissores restantes precisam ser buscados nos endereços que o GetCertificateIssuerURLs reporta
Quanto de /Contents é realmente o CMS?
Só o prefixo que o SEQUENCE externo declara pertence ao CMS, e o PLTrimCMSPadding corta tudo que vem depois. O signer reserva a hex string de /Contents antes de o CMS existir, porque a /ByteRange descrita na ISO 32000-1 §12.8.1 precisa ficar fixa primeiro, então o slot é dimensionado com folga e a cauda não usada é zeros. O PLTrimCMSPadding lê o primeiro TLV, exige tag $30 e retorna os bytes até o fim desse elemento; qualquer coisa que não comece com um SEQUENCE bem formado volta vazia. Esse nível de topo é o único lugar onde bytes no fim são legais, e a distinção importa para a próxima seção: uma regra rigorosa de "o elemento precisa consumir o buffer inteiro" rejeitaria toda assinatura real, enquanto uma regra frouxa aplicada em toda profundidade deixa campos aninhados lerem bytes que não são deles
Por que um leitor DER precisa do offset de fim do pai?
Um elemento aninhado só é válido se terminar dentro do pai dele, e checar contra o fim do buffer não prova isso. O DERReadTLV de baixo nível no PDFlibASN1 limita cada elemento pela string inteira, que é a checagem certa para o objeto mais externo e a errada para tudo abaixo dele. Imagine um SignerInfo cujo issuerAndSerialNumber declara 40 bytes enquanto o issuer Name dentro dele alega 60. Cada byte continua no buffer, então um leitor limitado pelo buffer aceita o Name, lê o número de série do algoritmo de digest que vem depois e compara esse par com os certificados embutidos. Antes da v3.539.10 o caminhador do CMS lia exatamente assim. O fix é um wrapper pequeno que carrega a posição de fim do pai para dentro de cada leitura
function ReadTLVWithin(const Data: AnsiString; ParentEnd: Integer;
var Offset: Integer; out Tag: Byte; out Start, Len: Integer): Boolean;
begin
Result := False;
// nada sobrou dentro do pai: recusa iniciar uma leitura
if (Offset < 1) or (Offset >= ParentEnd) then
Exit;
if not DERReadTLV(Data, Offset, Tag, Start, Len) then
Exit;
// Offset agora fica um além do elemento; não pode passar do pai
Result := Offset <= ParentEnd;
end;
// cada nível registra o próprio fim dele e o passa adiante:
// OuterEnd := fim do ContentInfo (RFC 5652 seção 3)
// ExplicitEnd := fim do content [0] EXPLICIT
// ContentEnd := fim do SignedData (RFC 5652 seção 5.1)
// SignerEnd / InnerEnd para SignerInfo e issuerAndSerialNumber
A unit PDFlibCMSRead agora costura esses fins pelo ContentInfo, o wrapper [0] EXPLICIT, os campos do SignedData até signerInfos, o SignerIdentifier nas duas formas dele, issuerAndSerialNumber e [0] subjectKeyIdentifier (RFC 5652 §5.3), e os campos do tbsCertificate lidos de cada certificado embutido na hora de casar o signer. Dentro do conjunto de certificados e do conjunto de signerInfos, um elemento que passa do fim do conjunto interrompe o loop: o PLExtractCMSCertificates retorna os certificados que já tinha aceitado e nunca cola os bytes de crls ou signerInfos seguintes no último. O match de issuer e serial também exige as duas metades, porque um número de série só é único dentro de um emissor
Por que 2.999.3 saiu como 1.15.3?
Os dois primeiros arcos de um OID se combinam num único subidentifier, não num único byte, e esse subidentifier é codificado em base-128 como qualquer outro arco. A X.690 §8.19.4 define esse valor como 40 * arc1 + arc2; o DER_OID antigo gravava esse valor com Byte(...), que só é correto até 127, o valor de 2.47. Para 2.999 a soma é 1079, o cast para byte guarda 55, e 55 decodifica como 1.15, então o identifier silenciosamente nomeia outro ramo da árvore. Valores de 128 a 255 falham de outro jeito, emitindo um byte com o bit de continuação ligado que engole o próximo arco. A maioria dos identifiers de PKI (1.2.840..., 2.5.29..., 0.4.0...) nunca chega na fronteira, e é por isso que o bug sobreviveu; os arcos joint-iso-itu-t a partir de 2.48 chegam. O DER_OID serve tanto ao encoder de signed attributes quanto ao matcher do DERFindExtensionByOID e da checagem de content-type do SignedData, então um encoding errado quebrava escrita e busca igualmente
uses
SysUtils, PDFlibASN1;
function Hex(const S: AnsiString): string;
var
I: Integer;
begin
Result := '';
for I := 1 to Length(S) do
Result := Result + IntToHex(Byte(S[I]), 2) + ' ';
Result := Trim(Result);
end;
begin
WriteLn(Hex(DER_OID('2.999.3'))); // 06 03 88 37 03
WriteLn(Hex(DER_OID('2.47.1'))); // 06 02 7F 01
WriteLn(Hex(DER_OID('2.48.1'))); // 06 03 81 00 01
WriteLn(Hex(DER_OID('2.5.29.14'))); // 06 03 55 1D 0E
WriteLn(Hex(DER_OID('1.2.840.113549.1.7.2'))); // 06 09 2A 86 48 86 F7 0D 01 07 02
end.
O valor combinado fica num UInt64 de propósito. O DER_OID converte os arcos para Int64, então um segundo arco legal pode ser tão grande quanto Int64.MaxValue, e somar 80 para arc1 = 2 faz overflow num inteiro de 64 bits com sinal. O UInt64 carrega Int64.MaxValue + 80 sem dar wrap, e o buffer temporário de dez bytes guarda os dez grupos de 7 bits que um valor de 64 bits precisa. Os vetores de teste que valem guardar são os dos dois lados da fronteira: 2.47 precisa continuar um byte, e 2.48 precisa virar dois
O que o caminhador CMS do lado de leitura garante?
O PDFlibCMSRead garante estrutura e nada mais: ele retorna os bytes que estão onde o RFC 5652 diz que devem estar e não verifica assinatura, digest nem período de validade. O caminhador aceita só DER, então o DERReadTLV rejeita comprimentos indefinidos e números de tag multibyte, e um CMS em BER vindo de um signer fora do padrão reporta zero certificados em vez de um chute parcial. Attribute certificates e as outras alternativas de CertificateChoices são puladas porque nada downstream consegue usá-las. A verificação criptográfica fica com o código que é dono dela, que começa pelas checagens de cobertura de bytes descritas em assinatura PAdES e validação de ByteRange no Delphi e continua com classificar o que mudou depois que um PDF foi assinado
A lição mais ampla vale para qualquer formato binário: "dentro do buffer" é uma propriedade de memory safety, "dentro do pai" é uma propriedade de correção, e um parser precisa das duas. O mesmo raciocínio sobre comprimentos hostis atravessa endurecer um parser de PDF em Pascal contra arquivos maliciosos. A extração de certificados, a construção de cadeia e as APIs de validação de longo prazo discutidas aqui vêm com a PDF Library for Delphi da losLab, para Delphi, C++Builder e Lazarus