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
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
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 é 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