Artigo Técnico

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

Os quatro bytes A0 82 05 DC abrem um elemento context-specific [0] carregando 1500 bytes de conteúdo. Lidos da esquerda para a direita eles não têm ambiguidade: tag A0, um octet inicial de tamanho 82 anunciando mais dois, então 05 DC. Lidos da direita para a esquerda, a partir do primeiro byte de conteúdo, o primeiro octet que você encontra é DC. O bit alto dele está ligado e os sete bits baixos dizem 92, então um helper que confia nele reporta a tag 94 bytes antes do conteúdo. A distância real é 4

Ninguém anda para trás de propósito. Acontece porque a maioria dos leitores DER devolve o offset e o comprimento do conteúdo de cada elemento, que é exatamente o que você precisa para descer dentro dele, enquanto copiar um elemento irmão sem alteração precisa do offset da tag dele. Subtrair a partir do offset de conteúdo parece um job de duas linhas, e continua correto enquanto todo comprimento couber num octet único. O comprimento de um conjunto de certificados de verdade nunca cabe em um

O que um timestamp de assinatura faz com um CMS assinado

Acrescentar um timestamp PAdES B-T força todo header que envolve o CMS a ser re-codificado enquanto cinco elementos irmãos precisam atravessar byte a byte. O atributo signature-time-stamp que a cláusula 5.3 da ETSI EN 319 122-1 define sob o OID 1.2.840.113549.1.9.16.2.14 mora no unsignedAttrs do SignerInfo da cláusula 5.3 da RFC 5652, e o token RFC 3161 dentro dele é computado sobre o valor da assinatura. Então o CMS já está construído e já assinado quando o token chega da TSA. Um atributo extra alonga o SignerInfo, o que alonga o SET signerInfos, depois o SignedData, depois o wrapper [0] EXPLICIT, depois o ContentInfo externo

No PDFium Component essa sequência são três chamadas exportadas em FPdfCms.pas e FPdfTsa.pas. SignPadesBytes as roda para você quando TPadesSignOptions.Level é plB_T ou maior e TsaUrl está definido, e ele levanta EPadesCrypto quando um nível B-T é pedido com um TsaUrl vazio em vez de escrever uma assinatura B-B simples. Se você constrói o CMS você mesmo com BuildSignedData, a cadeia fica assim

// CmsDer guarda o ContentInfo devolvido pelo BuildSignedData.
// O SignPadesBytes executa os mesmos três passos quando Options.Level >= plB_T
var
  SigValue: TBytes;
  Ts: TPadesTimestamp;
begin
  if not ExtractSignatureValue(CmsDer, SigValue) then
    raise Exception.Create('CMS carries no signature value');
  // O messageImprint da TSA é computado sobre os octets do valor da assinatura
  Ts:= RequestTimestamp(TsaUrl, SigValue, Nonce);
  // Re-codifica SignerInfo até ContentInfo, copia os irmãos sem alteração
  AddSignatureTimestampToCms(CmsDer, Ts.TokenDer);
end;

AddSignatureTimestampToCms faz cinco elementos atravessarem intactos: o OID contentType, o INTEGER de versão do SignedData, o SET digestAlgorithms, o SEQUENCE encapContentInfo e, quando presente, o conjunto certificates [0]. No CMS que o BuildSignedData emite com SHA-256, quatro deles são minúsculos: 11 bytes para o OID, 3 para a versão, 17 para o conjunto de algoritmo de digest, 13 para o encapContentInfo destacado. O conjunto de certificados guarda o certificado do signatário seguido de todo certificado de cadeia que você passou. Um certificado X.509 de verdade vai a várias centenas de bytes. Esta é a única fatia cujos octets de tamanho sempre usam a forma longa

O que acrescentar um timestamp PAdES B-T reconstrói num CMS: AddSignatureTimestampToCms copia contentType, versão, digestAlgorithms, encapContentInfo e o conjunto de certificados byte a byte, o conjunto de certificados é a única fatia longa o bastante para deixar a forma curta de tamanho, e todo header envolvente de SignerInfo até ContentInfo é re-emitido
A porção assinada fica intacta por construção porque o prefixo do SignerInfo até a 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 timestamp pousar

Por que octets de tamanho DER não podem ser lidos para trás?

A contagem de octets de tamanho está armazenada no primeiro deles, e uma caminhada para trás encontra o último primeiro. A cláusula 8.1.3.4 do X.690 define a forma curta: um octet, bit 8 zerado, bits 7 a 1 guardando um comprimento de 0 a 127. A cláusula 8.1.3.5 define a forma longa. O octet inicial dela tem o bit 8 ligado, e os bits 7 a 1 contam os octets subsequentes que guardam o comprimento como um inteiro big-endian sem sinal. Nada marca um octet subsequente como subsequente. O bit 8 dele é um bit de magnitude como os outros sete. Então um helper que testa o bit alto de Buf[ContentOffs- 1] está testando dados e depois lendo dados como contagem

// O helper tentador, recebendo só o offset do conteúdo. Não embarque isto
function NaiveHeaderStart(const Buf: TBytes; ContentOffs: Integer): Integer;
var
  P, LenByte: Integer;
begin
  P:= ContentOffs- 1;            // cai no ÚLTIMO octet de tamanho
  if P< 0 then
    Exit(ContentOffs);
  LenByte:= Buf[P];
  if (LenByte and $80)= 0 then   // só tem sentido para o PRIMEIRO
    Result:= P- 1
  else
    Result:= P- (LenByte and $7F)- 1;
end;

// A0 82 05 DC (1500 bytes): Buf[ContentOffs- 1]= $DC, $DC and $7F= 92
//   Result= ContentOffs- 94, a tag está em ContentOffs- 4
// A0 82 05 10 (1296 bytes): $10 tem o bit 8 zerado, lido como forma curta
//   Result= ContentOffs- 2, que aponta para 05 dentro dos octets de tamanho

As duas direções de falha aparecem com tamanhos comuns. Um conjunto de certificados de 1500 bytes termina o header dele em DC, e o helper começa a fatia 90 bytes cedo demais. Um conjunto de 1296 bytes termina em 10, o helper o toma por um comprimento de forma curta, e a fatia começa dois bytes tarde sem tag nenhuma. Se uma cadeia dada cai no primeiro ou no segundo caso depende de nada mais que o byte baixo do comprimento total dela

O caso adiantado também pode escapar pela frente do buffer. Para um CMS disposto do jeito que o BuildSignedData o dispõe, com todo comprimento envolvente ocupando dois octets, o conteúdo do conjunto de certificados começa 60 bytes adentro: 4 para o header do ContentInfo, 11 para o OID, 4 cada para os headers [0] e SignedData, então 3, 17 e 13 para os irmãos pequenos e 4 para o header do conjunto de certificados. Subtraia 94 de 60 e o helper devolve -34. A única guarda dele senta em ContentOffs- 1, então o resultado negativo vai direto para a cópia

Por que octets de tamanho DER não podem ser lidos para trás: ler A0 82 05 DC a partir do fim pousa no último octet DC, cujo bit alto ligado rende uma contagem falsa de 92 e põe a tag 94 octets adiantada, enquanto A0 82 05 10 falha pelo outro lado e começa a fatia dois octets tarde dentro dos octets de tamanho
Uma caminhada para trás que protege a subtração intermediária em vez do resultado final dela pode até começar uma fatia antes do buffer, deixando o SignedData reconstruído com essa fatia onde o conjunto de certificados dele pertence

O que a cláusula 10.1 do X.690 prega

O DER torna o encoding de comprimento uma função pura do comprimento. A cláusula 10.1 do X.690 restringe o DER à forma definida e exige o número mínimo de octets, o que remove as duas liberdades que o BER permite: a forma indefinida, e um comprimento de forma longa acolchado com octets de zero à esquerda. Sob essa regra um comprimento de conteúdo abaixo de 128 sempre leva exatamente um octet de tamanho. Qualquer outro comprimento leva um octet inicial mais exatamente tantos octets subsequentes quanto o valor tem bytes significativos. Isso dá 81 80 para 128. Em 256 o header cresce para 82 01 00, e 65536 precisa de 83 01 00 00. Código que já segura o comprimento do conteúdo pode computar o tamanho do header sem ler 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 comprimento do header segue só do ContentLen e o TryReadTlvAt já rejeitou toda forma BER não mínima
Conteúdo com menos de 128 bytes fica dentro da forma curta, onde uma caminhada para trás responde certo pelo motivo errado, então testes de fronteira precisam atravessar 127, 128, 255 e 256 bytes

A derivação é boa apenas quanto à afirmação de que a entrada realmente é DER. TDerReader.TryReadTlvAt impõe essa afirmação antes de qualquer fatiamento acontecer. Ele rejeita um octet inicial de tamanho $80 (a forma indefinida), um comprimento de forma longa cujo primeiro octet subsequente é zero, um único octet subsequente abaixo de $80 que devia ter sido forma curta, e mais de quatro octets subsequentes. ReadTlv é construído sobre o TryReadTlvAt, então todo elemento que o AddSignatureTimestampToCms percorre já passou por essas checagens. Um comprimento acolchado estilo BER nunca chega à aritmética

Dentro do AddSignatureTimestampToCms o offset de tag de cada irmão é derivado do offset e do comprimento de conteúdo dele desse jeito. Quando o início derivado cai antes do começo do buffer, o que significa que os offsets não podem pertencer um ao outro, a procedure levanta uma exceção com a mensagem CMS: TLV header precedes the buffer start e não produz CMS nenhum. Uma reconstrução malformada que ainda faz parsing é muito mais difícil de rastrear que uma exceção no ponto em que os offsets deixaram de bater

A derivação carrega mais uma suposição: o identificador ocupa um único octet. O X.690 permite identificadores de múltiplos octets para números de tag 31 e acima, e o TryReadTlvAt os faz parsing. O SignedData de CMS nunca usa um, já que as tags dele são SEQUENCE, SET, INTEGER e OID universais mais o [0] e o [1] context-specific, então a suposição vale para todo elemento que a reconstrução do timestamp toca

Copiando elementos irmãos no seu próprio código DER

Se você escreve código contra o TDerReader, pule a derivação de vez. PeekTlv e ReadNextTlv preenchem um record TDerTlv cujo Offset já aponta para a tag e cujo HeaderLength é o tamanho medido de tag mais octets de tamanho, e EncodedBytes devolve o elemento do Offset ao NextOffset. Esse caminho também lida com identificadores de múltiplos octets, que aritmética de comprimento nenhuma consegue recuperar. O ReadTlv de quatro out-parameters é conveniente para descer. Ele reporta só o lado do conteúdo, porém, então use-o quando precisa do conteúdo e a forma de record quando precisa do elemento inteiro

// Todo filho de SignedData como um TLV completo: tag, octets de tamanho, conteúdo.
// TDerTlv.Offset aponta para a tag, então nenhuma aritmética de header entra
function SignedDataChildren(const CmsDer: TBytes): TDerByteArrays;
var
  Root, Ci, Explicit0, Sd: TDerReader;
  Tlv: TDerTlv;
begin
  Result:= nil;
  Root.Initialize(CmsDer);
  if not Root.ReadNextTlv(Tlv) or not Root.EnterConstructed(Tlv, Ci) then
    raise Exception.Create('ContentInfo SEQUENCE expected');
  if not Ci.SkipTlv then                            // contentType OID
    raise Exception.Create('contentType OID expected');
  if not Ci.ReadNextTlv(Tlv) or not Ci.EnterConstructed(Tlv, Explicit0) then
    raise Exception.Create('[0] EXPLICIT content expected');
  if not Explicit0.ReadNextTlv(Tlv) or not Explicit0.EnterConstructed(Tlv, Sd) then
    raise Exception.Create('SignedData SEQUENCE expected');
  while not Sd.AtEnd do
  begin
    if not Sd.ReadNextTlv(Tlv) then
      raise Exception.Create('SignedData child malformed');
    SetLength(Result, Length(Result)+ 1);
    Result[High(Result)]:= Sd.EncodedBytes(Tlv);   // e.g. A0 82 05 DC ...
  end;
end;

Para os casos em que alguma outra API lhe dá nada além de um offset e um comprimento de conteúdo, a derivação para frente é curta. DerHeaderStart, mostrado com a checagem de fronteira dele na próxima seção, é código seu; ele espelha a regra da cláusula 10.1 e devolve -1 para offsets que não podem ser consistentes, então o chamador recebe um valor que pode recusar em vez de um byte de início de aparência plausível

Por que certificados de teste curtos escondem o erro?

Um certificado placeholder pequeno mantém o conjunto de certificados na forma curta, e a caminhada para trás está correta exatamente nesse caso. Um conjunto de 32 bytes codifica como A0 20 e um de 64 bytes como A0 40. Andar para trás a partir do conteúdo pousa nesse octet único de tamanho, e o bit alto dele está zerado porque é o primeiro e único. Resposta certa, motivo errado. Um teste de timestamp construído sobre certificados tão pequenos pode re-fazer o parsing do resultado, achar o atributo, reportar B-T e nunca empurrar um conjunto de certificados de tamanho realista pela fatia

Qualquer caminho de código que dependa de como um comprimento é codificado precisa de entrada de teste que atravesse a fronteira do encoding. Para DER isso significa conteúdo de 128 bytes ou mais, que força a forma longa, e de 256 bytes ou mais, que força um segundo octet subsequente. A checagem abaixo compara DerHeaderStart com o header que o leitor mediu durante o parsing, nas duas fronteiras e no caso de 1500 bytes da abertura. Um SET OF não ordenado em signedAttrs escapa de round trips de mesma origem pelo mesmo motivo estrutural: o teste só alimenta entradas em que código errado e código certo concordam

// Seu próprio helper para APIs que reportam só offset e comprimento de conteúdo.
// Assume um identificador de um octet, verdade para toda tag em CMS SignedData
function DerHeaderStart(ContentOffs, ContentLen: Integer): Integer;
var
  LengthOctets, Remaining: Integer;
begin
  LengthOctets:= 1;                  // forma curta, X.690 8.1.3.4
  if ContentLen>= 128 then
  begin
    Remaining:= ContentLen;          // forma longa, X.690 8.1.3.5
    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:= -1;                     // offsets inconsistentes: deixe o chamador levantar
end;

// Checagem de fronteira, escrita como código de teste seu: o offset de tag derivado
// precisa igualar o offset que o TDerReader mediu ao fazer o parsing do header
procedure CheckHeaderDerivation;
const
  Lens: array[0..6] of Integer= (127, 128, 255, 256, 1500, 65535, 65536);
var
  W: TDerWriter;
  R: TDerReader;
  Tlv: TDerTlv;
  Content: TBytes;
  I: Integer;
begin
  W:= TDerWriter.Create;
  try
    for I:= Low(Lens) to High(Lens) do
    begin
      SetLength(Content, Lens[I]);
      R.Initialize(W.Wrap($A0, Content));   // A0 7F / A0 81 80 / A0 82 05 DC ...
      W.Clear;
      if not R.PeekTlv(Tlv) then
        raise Exception.CreateFmt('unreadable TLV for %d bytes', [Lens[I]]);
      if DerHeaderStart(Tlv.ContentOffset, Tlv.ContentLength)<> Tlv.Offset then
        raise Exception.CreateFmt('header start wrong for %d bytes', [Lens[I]]);
    end;
  finally
    W.Free;
  end;
end;

A mesma fronteira é alcançável de ponta a ponta pelas funções exportadas. Dê ao BuildSignedData certificados de cadeia cujo tamanho combinado ponha o conjunto de certificados logo abaixo e logo acima de 128 e 256 bytes, rode AddSignatureTimestampToCms sobre cada resultado, então percorra a saída com SignedDataChildren e compare o elemento do conjunto de certificados byte a byte com o do CMS antes de o timestamp ser acrescentado

O que o AddSignatureTimestampToCms aceita

AddSignatureTimestampToCms é escrito para o CMS que o BuildSignedData emite: um SignerInfo, um conjunto certificates [0] opcional, nenhum crls [1] e nenhum unsignedAttrs ainda. Alimente-o com um SET signerInfos guardando mais de um SignerInfo e ele levanta exceção em vez de manter o primeiro signatário e descartar o resto. Um CMS que carrega um conjunto crls [1] para na exceção CMS: signerInfos SET expected, porque o elemento depois dos certificados não é o SET que a caminhada procura. As duas recusas acontecem antes de qualquer coisa ser escrita de volta no CmsDer

O novo unsignedAttrs guarda exatamente um atributo, então a regra de ordenação de SET OF da cláusula 11.6 do X.690 é satisfeita sem sort. A parte assinada do SignerInfo, da versão dele até a OCTET STRING da assinatura, é copiada como um bloco, o que significa que um validador refazendo o digest de signedAttrs vê os mesmos bytes antes e depois de o token ser acrescentado. Quando um validador ainda rejeita o documento, as causas costumam sentar em outro lugar e são cobertas em esta checklist de motivos de rejeição PAdES. O que o token lhe compra ao longo do tempo, e como B-LT e B-LTA se constroem sobre ele, está no passo a passo de timestamps RFC 3161 e do DSS

O leitor e o escritor DER em FPdfAsn1.pas, o construtor de CMS e o passo de timestamp em FPdfCms.pas são todos código Pascal que vem com o PDFium Component para Delphi. Quando um SignedData reconstruído se recusa a fazer parsing num validador, você pode percorrer o código que cortou cada elemento e conferi-lo com a cláusula X.690 que ele cita