Artigo Técnico

Octetos de tamanho DER não se leem para trás no Delphi

O PDFium Component localiza o começo de uma estrutura CMS aninhada pelo comprimento do conteúdo, nunca andando para trás por cima dos octetos de tamanho, porque o byte imediatamente antes do conteúdo é o último octeto de tamanho e não diz nada sobre quantos vêm antes dele. CmsHeaderStart, em FPdfCms.pas, deriva o tamanho do cabeçalho a partir de ContentLen, que o DER torna exato, e é isso que impede AddSignatureTimestampToCms de corromper todo CMS cujo conjunto de certificados passe de 127 bytes

O cenário é o upgrade PAdES B-T. Um atributo de carimbo de tempo de assinatura, o que a ETSI EN 319 122-1 cláusula 5.3 define sob o OID 1.2.840.113549.1.9.16.2.14, precisa cair no unsignedAttrs do SignerInfo descrito na RFC 5652 cláusula 5.3, e por definição ele só pode ser adicionado depois que o valor da assinatura existe, porque o token de carimbo de tempo é calculado sobre esse valor. Então o CMS já está montado e já assinado quando o token chega. Adicionar um atributo muda o comprimento do SignerInfo, o que muda o comprimento do SET signerInfos, depois o do SignedData, depois o do invólucro EXPLICIT [0], depois o do ContentInfo externo. Todo cabeçalho que envolve isso precisa ser reemitido, e tudo que não está nesse caminho precisa ser transportado byte a byte. O passo a passo de B-LT e B-LTA cobre o que o token entrega; este artigo é sobre os quatro bytes na frente do conjunto de certificados que a reconstrução insistia em errar

Por que adicionar um carimbo de tempo precisa do offset da tag de um irmão?

Porque a reconstrução reutiliza verbatim quatro irmãos do SET signerInfos, e o reader informa onde está o conteúdo deles, não onde está a tag. TDerReader.ReadTlv devolve o byte de tag, o offset do conteúdo, o comprimento do conteúdo e o offset do próximo TLV. Essa é a superfície certa para descer numa estrutura, mas para copiar um elemento inteiro você precisa do octeto onde a tag dele está, e a única coisa que o chamador tem em mãos é ContentOffs. CmsSliceTlv existe para preencher essa lacuna: dado um offset e um comprimento de conteúdo, ela devolve tag, octetos de tamanho e conteúdo como um único buffer, e AddSignatureTimestampToCms a chama para o OID contentType, o INTEGER version, o SET digestAlgorithms, a SEQUENCE encapContentInfo e, quando presente, o conjunto certificates [0]

// Dentro de AddSignatureTimestampToCms: desce, fatia os irmãos verbatim
if not R.ReadTlv(Tag, CO, CL, CN) or (Tag<> Byte(asnSequence)) then
  raise Exception.Create('CMS: encapContentInfo SEQUENCE expected');
SdEncapTlv:= CmsSliceTlv(CmsDer, CO, CL);
R.Position:= CN;
// optional certificates [0]
HasCerts:= (R.Position< SdEnd) and (CmsDer[R.Position]= $A0);
if HasCerts then
begin
  if not R.ReadTlv(Tag, CO, CL, CN) or (Tag<> $A0) then
    raise Exception.Create('CMS: certificates [0] malformed');
  SdCertsTlv:= CmsSliceTlv(CmsDer, CO, CL);   // tag + octetos de tamanho + conteúdo
  R.Position:= CN;
end;

Dessas cinco fatias, quatro são minúsculas: um OID de onze bytes, um INTEGER de três bytes, um conjunto de algoritmos de digest de dezessete bytes, um encapContentInfo desanexado de treze bytes. O conjunto de certificados é o que carrega o certificado do assinante e sua cadeia, e um certificado X.509 de verdade ocupa várias centenas de bytes no mínimo. O conjunto de certificados é portanto a única fatia cujos octetos de tamanho ficam na forma longa, e é a fatia que o helper antigo não conseguia localizar

O que adicionar um carimbo de tempo PAdES B-T reconstrói num CMS: CmsSliceTlv copia contentType, version, digestAlgorithms, encapContentInfo e o conjunto de certificados byte a byte, o conjunto de certificados é a única fatia longa o bastante para sair da forma curta de tamanho, e todo cabeçalho que envolve, do SignerInfo até o ContentInfo, é reemitido
A parte assinada fica intacta por construção porque o prefixo do SignerInfo até o OCTET STRING da assinatura é copiado verbatim, então um validador que refaz o digest de signedAttrs vê bytes idênticos antes e depois de o carimbo de tempo entrar

Por que octetos de tamanho DER não podem ser percorridos para trás?

Porque a contagem de octetos de tamanho fica guardada no primeiro deles, e lendo do conteúdo para trás você encontra o último primeiro. A X.690 cláusula 8.1.3.4 define a forma curta: um octeto, bit 8 em zero, bits 7 a 1 carregando um tamanho de 0 a 127. A cláusula 8.1.3.5 define a forma longa: um octeto inicial com o bit 8 ligado cujos bits 7 a 1 dão o número de octetos subsequentes, seguido por esses octetos carregando o tamanho como inteiro big-endian sem sinal. Nada na regra marca um octeto subsequente como subsequente. O bit 8 dele é um bit de magnitude como qualquer outro, então uma caminhada para trás que testa o bit mais alto de Buf[ContentOffs- 1] está testando um bit de dado e depois lendo os sete bits baixos como uma contagem

// O helper antigo, recebendo apenas o offset do conteúdo
function CmsHeaderStart(const Buf: TBytes; ContentOffs: Integer): Integer;
var
  P, LenByte, LongLen: Integer;
begin
  P:= ContentOffs- 1;            // cai no ÚLTIMO octeto de tamanho
  if P< 0 then
    Exit(ContentOffs);
  LenByte:= Buf[P];
  if (LenByte and $80)= 0 then   // só faz sentido para o PRIMEIRO dele
    Result:= P- 1
  else
  begin
    LongLen:= LenByte and $7F;
    Result:= P- LongLen- 1;
  end;
end;

// Cabeçalho de um conjunto de certificados de 1500 bytes:  A0 82 05 DC
//   Buf[ContentOffs- 1]= $DC  -> bit 8 ligado, $DC and $7F= 92
//   Result= ContentOffs- 94     (a tag está em ContentOffs- 4)

Pegue o cabeçalho de um conjunto de certificados com 1500 bytes de certificados, A0 82 05 DC. A caminhada cai em DC, vê o bit mais alto ligado, extrai 92 dos sete bits baixos e reporta a tag 94 bytes antes do conteúdo, quando ela está 4 bytes antes dele. Num SignedData montado por BuildSignedData, o conteúdo do conjunto de certificados fica a apenas algumas dezenas de bytes do começo do CMS, então o offset calculado não era só adiantado demais, era negativo, e o código antigo protegia ContentOffs- 1 de cair abaixo de zero, e não o resultado final. CmsSliceTlv tomava então uma fatia noventa e tantos bytes maior que o elemento, começando antes do buffer, e o SignedData reconstruído carregava essa fatia onde deveria estar seu conjunto de certificados. Um tamanho de três octetos cujo último octeto por acaso ficasse abaixo de $80, digamos A0 82 05 10, falhava pelo outro lado: a caminhada o tomava por um octeto de forma curta e começava a fatia em 05, dois bytes atrasada e dentro dos octetos de tamanho, sem tag nenhuma. O resultado estava errado nos dois casos, só a direção variava

Por que octetos de tamanho DER não podem ser percorridos para trás: ler A0 82 05 DC pelo fim cai no último octeto DC, cujo bit mais alto ligado dá uma contagem falsa de 92 e coloca a tag 94 octetos adiantada, enquanto A0 82 05 10 falha pelo outro lado e começa a fatia dois octetos atrasada, dentro dos octetos de tamanho
O antigo CmsHeaderStart protegia a subtração intermediária em vez do resultado final, então uma fatia podia até começar antes do buffer, e o SignedData reconstruído carregava essa fatia onde seu conjunto de certificados deveria estar

O que o DER garante que torna a derivação para frente exata?

O DER garante que a codificação do tamanho é uma função pura do tamanho. A X.690 cláusula 10.1 restringe o DER à forma definida e exige o número mínimo de octetos, o que remove as duas liberdades que o BER permite: a forma indefinida e preencher um tamanho de forma longa com octetos zero à esquerda. Sob essa regra, um comprimento de conteúdo abaixo de 128 tem exatamente um octeto de tamanho, e qualquer outro comprimento tem um octeto inicial mais exatamente tantos octetos subsequentes quantos os bytes significativos do tamanho exigirem. O chamador de CmsHeaderStart já tem ContentLen, porque ReadTlv acabou de devolvê-lo, então o tamanho do cabeçalho é calculável sem olhar para um único byte do buffer

A derivação para frente que o DER garante: um comprimento de conteúdo de 127 codifica como A0 7F, 128 como A0 81 80, 255 como A0 81 FF, 256 como A0 82 01 00 e 1500 como A0 82 05 DC, então o tamanho do cabeçalho sai só de ContentLen e o TryReadTlvAt já rejeitou toda forma BER não mínima
Fixtures montadas com certificados de 32 e 64 bytes ficavam dentro da forma curta, onde a caminhada para trás responde certo pelo motivo errado, e é por isso que a suíte de fronteiras agora cruza 127, 128, 255 e 256 bytes
// O helper entregue: deriva o cabeçalho a partir do comprimento do conteúdo.
// Sob a X.690 10.1 os octetos de tamanho são função de ContentLen
function CmsHeaderStart(const Buf: TBytes; ContentOffs, ContentLen: Integer): Integer;
var
  LengthOctets, Remaining: Integer;
begin
  if ContentLen< 128 then
    LengthOctets:= 1                 // forma curta, X.690 8.1.3.4
  else
  begin
    LengthOctets:= 1;                // o octeto inicial, X.690 8.1.3.5
    Remaining:= ContentLen;
    while Remaining> 0 do
    begin
      Inc(LengthOctets);             // um por byte significativo
      Remaining:= Remaining shr 8;
    end;
  end;
  Result:= ContentOffs- LengthOctets- 1;
  if Result< 0 then
    Result:= ContentOffs;
end;

Dois detalhes tornam isso seguro, e não apenas plausível. Primeiro, a suposição de que a entrada é DER é garantida em cima: TDerReader.TryReadTlvAt, sobre o qual ReadTlv é construído, rejeita a forma indefinida, rejeita um tamanho de forma longa cujo primeiro octeto subsequente seja zero, e rejeita um único octeto subsequente abaixo de $80. Um TLV que chega a CmsSliceTlv já passou por essas checagens, então um tamanho não mínimo ao estilo BER não consegue chegar à derivação e fazê-la mentir. Segundo, o fallback para um resultado negativo agora protege a resposta real, e não um valor intermediário. Vale dizer que o reader sabia do offset da tag o tempo todo: TDerTlv carrega tanto Offset quanto HeaderLength, e só a superfície de ReadTlv com quatro parâmetros de saída os descarta. Devolvê-los seria a interface mais limpa a longo prazo; a correção entregue mantém essa superfície intacta e torna o helper correto nos próprios termos

Por que os testes de carimbo de tempo passavam com o bug no lugar?

Porque todo certificado de fixture era curto o bastante para usar a forma curta, e a caminhada para trás está correta exatamente nesse caso. Tests.PadesTimestamp.pas monta o certificado do assinante com SetLength(SignerCertDer, 32) num teste e 64 em outro, preenchido com uma rampa de bytes. Um conjunto de certificados de 32 bytes codifica como A0 20 e um de 64 bytes como A0 40, um único octeto de tamanho cada. Caminhar do conteúdo para trás cai naquele único octeto, o bit mais alto dele está em zero porque é o primeiro e único octeto de tamanho, e o helper responde certo pelo motivo errado. A suíte de 1414 casos estava verde, o CMS com carimbo de tempo era analisado, o validador de estágio 1 reportava B-T, e cada uma dessas checagens rodava contra um conjunto de certificados que nenhum documento de verdade jamais conteve

A regra geral é a parte útil. Sempre que um caminho de código depende de como um tamanho é codificado, a fixture precisa cruzar a fronteira de codificação, e para DER isso significa conteúdo maior que 127 bytes, o que força a forma longa, e de preferência maior que 255 bytes também, o que força um segundo octeto subsequente. A mesma disciplina vale para o outro caso daquela revisão em que a autoverificação não conseguiu enxergar um desvio de DER: o SET OF fora de ordem em signedAttrs era invisível a um round trip de mesma origem por um motivo estruturalmente idêntico, o teste exercitava só entradas nas quais o código errado e o código certo concordam. O trecho abaixo chama o helper de fatia direto, o que significa exportá-lo de FPdfCms.pas para o build de teste; a mesma fronteira é alcançável pela superfície pública entregando a BuildSignedData um certificado de cadeia de cada tamanho e reanalisando o resultado com carimbo de tempo

// Fixa a fronteira: uma fatia por um cabeçalho de forma longa deve começar na tag
const
  Lens: array[0..6] of Integer= (127, 128, 255, 256, 1500, 65535, 65536);

procedure TCmsSliceTests.HeaderStart_LongFormLengths;
var
  W: TDerWriter;
  Content, Tlv: TBytes;
  I, Len: Integer;
begin
  W:= TDerWriter.Create;
  try
    for I:= Low(Lens) to High(Lens) do
    begin
      Len:= Lens[I];
      SetLength(Content, Len);
      Tlv:= W.Wrap($A0, Content);            // A0 7F / A0 81 80 / A0 82 05 DC ...
      W.Clear;
      // o conteúdo começa logo após o cabeçalho; a fatia deve ser o TLV inteiro
      Assert.AreEqual(Length(Tlv),
        Length(CmsSliceTlv(Tlv, Length(Tlv)- Len, Len)),
        'slice through header of a '+ IntToStr(Len)+ '-byte content');
    end;
  finally
    W.Free;
  end;
end;

Onde a reconstrução ainda traça suas linhas

AddSignatureTimestampToCms é escrito para o CMS que BuildSignedData emite, e seus limites decorrem disso. A travessia espera um único SignerInfo e reemite só esse, então um CMS estrangeiro com vários assinantes voltaria com um assinante; ela reconhece um conjunto opcional certificates [0], mas não um conjunto crls [1], e um CMS que carregue um falha em voz alta com a exceção signerInfos SET expected em vez de fatiar errado em silêncio. O novo unsignedAttrs carrega um atributo, então a regra de ordenação de SET OF da X.690 cláusula 11.6 é satisfeita trivialmente e não precisa de ordenação. E a parte assinada fica intocada por construção: o prefixo do SignerInfo até o OCTET STRING da assinatura é copiado verbatim, e é por isso que um validador que refaz o digest de signedAttrs vê os mesmos bytes antes e depois de o carimbo de tempo ser adicionado. Quando um deles ainda recusa o documento, as causas costumam estar em outro lugar e merecem a própria lista de verificação

O reader de DER, o writer, o construtor de CMS e esta injeção de carimbo de tempo vêm todos como código-fonte Pascal com o componente PDFium para Delphi, e um bug com essa cara é o argumento a favor disso: quando um SignedData reconstruído sai noventa bytes mais longo, você quer ler o helper que cortou a fatia e a cláusula da X.690 que ele leu errado, não um stack trace de uma caixa-preta