Artigo Técnico

Assinatura CMS no Delphi: limites DER e arcos de OID

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

O PLTrimCMSPadding do PDFlibPas lê o primeiro TLV da hex string reservada de /Contents, exige a tag $30 e corta o padding de zeros no comprimento que o SEQUENCE externo declara, e retorna resultado vazio quando o buffer não começa com um SEQUENCE bem formado
Bytes no fim são legais só no nível de topo, onde o slot reservado precisa ficar fixo por causa da /ByteRange — leituras mais fundas recebem a regra limitada pelo pai

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

O PDFlibPas limita toda leitura DER aninhada pelo elemento pai dela: um issuer Name de 60 bytes dentro de um issuerAndSerialNumber de 40 bytes é aceito pelo antigo DERReadTLV limitado pelo buffer, que então lê o número de série do digestAlgorithm, enquanto o ReadTLVWithin recusa qualquer elemento que termine depois do ParentEnd
Dentro do buffer é memory safety, dentro do pai é correção — o PDFlibPas costura o offset de fim do pai por todo nível do CMS para que um comprimento hostil não pegue bytes emprestados de um vizinho
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 DER_OID do PDFlibPas combina os dois primeiros arcos do OID como 40 * arc1 + arc2 e codifica a soma em base-128 num UInt64, então 2.999.3 vira 06 03 88 37 03, enquanto o antigo cast para Byte guardava 55 e decodificava silenciosamente o identifier como 1.15.3
A maioria dos arcos de PKI nunca chega na fronteira, e é por isso que o bug sobreviveu — os arcos joint-iso-itu-t de 2.48 para cima precisam de dois bytes, e o teste mantém 2.47 e 2.48 dos dois lados

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