O PDFium Component localiza o início de uma estrutura CMS aninhada pelo comprimento do seu conteúdo, nunca percorrendo os octetos de comprimento de trás para a frente, porque o byte imediatamente antes do conteúdo é o último octeto de comprimento e não diz nada sobre quantos o precedem. O CmsHeaderStart em FPdfCms.pas deriva antes o comprimento do cabeçalho a partir de ContentLen, que o DER torna exato, e é isso que impede o AddSignatureTimestampToCms de corromper todos os CMS cujo conjunto de certificados tenha mais de 127 bytes
O contexto é o upgrade PAdES B-T. Um atributo de carimbo temporal de assinatura, aquele 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, tem de ficar nos unsignedAttrs do SignerInfo descrito na cláusula 5.3 da RFC 5652, e por definição só pode ser acrescentado depois de o valor da assinatura existir, porque o token do carimbo temporal é calculado sobre esse valor. Ou seja, o CMS já está construído e já está assinado quando o token chega. Acrescentar um atributo altera o comprimento do SignerInfo, o que altera o comprimento do SET signerInfos, depois o do SignedData, depois o do invólucro EXPLICIT [0] e depois o do ContentInfo exterior. Todos os cabeçalhos que os envolvem têm de ser reemitidos, e tudo o que não esteja nesse caminho tem de ser transportado byte a byte. O percurso por B-LT e B-LTA explica o que o token lhe dá; este artigo é sobre os quatro bytes à frente do conjunto de certificados que a reconstrução teimava em errar
Porque é que acrescentar um carimbo temporal precisa do offset da tag de um irmão?
Porque a reconstrução reutiliza textualmente quatro irmãos do SET signerInfos, e o reader reporta onde está o seu conteúdo e não onde está a sua tag. O TDerReader.ReadTlv devolve o byte da tag, o offset do conteúdo, o comprimento do conteúdo e o offset do TLV seguinte. É a superfície certa para descer numa estrutura, mas para copiar um elemento inteiro é preciso o octeto onde assenta a sua tag, e a única coisa que quem chama tem é o ContentOffs. O CmsSliceTlv existe para fazer essa ponte: dado um offset e um comprimento de conteúdo, devolve a tag, os octetos de comprimento e o conteúdo como um único buffer, e o AddSignatureTimestampToCms chama-o para o OID contentType, o INTEGER version, o SET digestAlgorithms, o SEQUENCE encapContentInfo e, quando presente, o conjunto certificates [0]
// Dentro de AddSignatureTimestampToCms: descer, cortar os irmãos textualmente
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;
// certificates [0] opcional
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 comprimento + conteúdo
R.Position:= CN;
end;
Desses cinco cortes, quatro são minúsculos: um OID de onze bytes, um INTEGER de três bytes, um conjunto de algoritmos de digest de dezassete bytes, um encapContentInfo destacado de treze bytes. O conjunto de certificados é o que transporta o certificado do signatário e a sua cadeia, e um certificado X.509 real ocupa várias centenas de bytes, no mínimo. O conjunto de certificados é por isso o único corte cujos octetos de comprimento estão alguma vez na forma longa, e é o corte que o helper antigo não conseguia localizar
Porque é que os octetos de comprimento DER não se podem percorrer para trás?
Porque a contagem de octetos de comprimento está guardada no primeiro deles, e lendo do conteúdo para trás encontra-se o último primeiro. A cláusula 8.1.3.4 da X.690 define a forma curta: um octeto, bit 8 a zero, bits 7 a 1 a conter um comprimento de 0 a 127. A cláusula 8.1.3.5 define a forma longa: um octeto inicial com o bit 8 a um cujos bits 7 a 1 dão o número de octetos subsequentes, seguidos desses octetos que transportam o comprimento como um inteiro sem sinal big-endian. Nada na regra marca um octeto subsequente como subsequente. O seu bit 8 é um bit de magnitude como qualquer outro, pelo que um percurso para trás que teste o bit de topo de Buf[ContentOffs- 1] está a testar um bit de dados e depois a ler os seus sete bits baixos como contagem
// O helper antigo, ao qual só se dava o offset do conteúdo
function CmsHeaderStart(const Buf: TBytes; ContentOffs: Integer): Integer;
var
P, LenByte, LongLen: Integer;
begin
P:= ContentOffs- 1; // aterra no ÚLTIMO octeto de comprimento
if P< 0 then
Exit(ContentOffs);
LenByte:= Buf[P];
if (LenByte and $80)= 0 then // só faz sentido para o PRIMEIRO
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 a um, $DC and $7F= 92
// Result= ContentOffs- 94 (a tag está em ContentOffs- 4)
Tome o cabeçalho de um conjunto de certificados com 1500 bytes de certificados, A0 82 05 DC. O percurso aterra no DC, vê um bit de topo a um, extrai 92 dos sete bits baixos e reporta a tag 94 bytes antes do conteúdo, quando ela está 4 bytes antes. Num SignedData construído pelo BuildSignedData, o conteúdo do conjunto de certificados fica apenas umas dezenas de bytes dentro do CMS, pelo que o offset calculado não era apenas cedo, era negativo, e o código antigo protegia o ContentOffs- 1 de descer abaixo de zero e não o seu resultado final. O CmsSliceTlv fazia então um corte com noventa e tantos bytes a mais do que o elemento, a começar antes do buffer, e o SignedData reconstruído transportava esse corte onde devia estar o seu conjunto de certificados. Um comprimento de três octetos cujo último octeto calhasse a ficar abaixo de $80, digamos A0 82 05 10, falhava ao contrário: o percurso tomava-o por um octeto de forma curta e começava o corte em 05, dois bytes tarde e dentro dos octetos de comprimento, sem tag nenhuma. O resultado era errado em qualquer dos casos, só a direção variava
O que é que o DER garante que torna a derivação para a frente exata?
O DER garante que a codificação do comprimento é uma função pura do comprimento. A cláusula 10.1 da X.690 restringe o DER à forma definida e exige o número mínimo de octetos, o que elimina as duas liberdades que o BER permite: a forma indefinida e o preenchimento de um comprimento de forma longa com octetos zero à esquerda. Segundo essa regra, um comprimento de conteúdo abaixo de 128 tem exatamente um octeto de comprimento, e qualquer outro comprimento tem um octeto inicial mais exatamente tantos octetos subsequentes quantos os bytes significativos de que o comprimento precisa. Quem chama o CmsHeaderStart já tem o ContentLen, porque o ReadTlv acabou de o devolver, pelo que o comprimento do cabeçalho é calculável sem olhar para um único byte do buffer
// O helper que saiu: derivar o cabeçalho a partir do comprimento do conteúdo.
// Segundo a X.690 10.1 os octetos de comprimento são função do 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 pormenores tornam isto seguro e não apenas plausível. Primeiro, o pressuposto de que a entrada é DER é imposto a montante: o TDerReader.TryReadTlvAt, sobre o qual o ReadTlv é construído, rejeita a forma indefinida, rejeita um comprimento de forma longa cujo primeiro octeto subsequente seja zero e rejeita um único octeto subsequente abaixo de $80. Um TLV que chegue ao CmsSliceTlv já passou essas verificações, pelo que um comprimento não mínimo ao estilo BER não pode chegar à derivação e fazê-la mentir. Segundo, o recuo para um resultado negativo passa a proteger a resposta real e não um valor intermédio. Vale a pena dizer que o reader sabia o offset da tag desde o início: o TDerTlv transporta tanto o Offset como o HeaderLength, e só a superfície ReadTlv de quatro parâmetros de saída os descarta. Devolvê-los seria a interface mais limpa a longo prazo; a correção que saiu mantém essa superfície intacta e torna o helper correto nos seus próprios termos
Porque é que os testes do carimbo temporal passavam com o bug no lugar?
Porque todos os certificados das fixtures eram curtos o suficiente para usar a forma curta, e o percurso para trás é correto exatamente nesse caso. O Tests.PadesTimestamp.pas constrói o seu certificado de signatário com SetLength(SignerCertDer, 32) num teste e 64 noutro, preenchidos com uma rampa de bytes. Um conjunto de certificados de 32 bytes codifica-se como A0 20 e um de 64 bytes como A0 40, um único octeto de comprimento em cada. Percorrer para trás a partir do conteúdo aterra nesse único octeto, o seu bit de topo está a zero porque é o primeiro e único octeto de comprimento, e o helper responde corretamente pela razão errada. A suite de 1414 casos estava verde, o CMS com carimbo temporal era analisado, o validador de fase 1 reportava B-T, e todas essas verificações corriam contra um conjunto de certificados que nenhum documento real alguma vez teve
A regra geral é a parte útil. Sempre que um caminho de código depende de como um comprimento é codificado, a fixture tem de atravessar a fronteira de codificação, e para o DER isso significa conteúdo com mais de 127 bytes, o que força a forma longa, e de preferência também com mais de 255 bytes, o que força um segundo octeto subsequente. A mesma disciplina aplica-se ao outro caso daquela revisão em que a autoverificação não conseguia ver um desvio DER: o SET OF não ordenado nos signedAttrs era invisível a um round-trip da mesma origem por uma razão estruturalmente idêntica, o teste exercitava apenas entradas nas quais o código errado e o código certo concordam. O esboço abaixo chama diretamente o helper de corte, o que implica exportá-lo do FPdfCms.pas para a compilação de teste; a mesma fronteira é alcançável através da superfície pública entregando ao BuildSignedData um certificado de cadeia de cada tamanho e voltando a analisar o resultado com carimbo temporal
// Fixar a fronteira: um corte através de um cabeçalho de forma longa tem de 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 a seguir ao cabeçalho; o corte tem de 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 é que a reconstrução ainda traça as suas linhas
O AddSignatureTimestampToCms está escrito para o CMS que o BuildSignedData emite, e os seus limites decorrem daí. O percurso espera um único SignerInfo e reemite apenas esse, pelo que um CMS alheio com vários signatários voltaria com um só signatário; reconhece um conjunto opcional certificates [0] mas não um conjunto crls [1], e um CMS que transporte um falha ruidosamente com a exceção signerInfos SET expected em vez de cortar mal em silêncio. Os novos unsignedAttrs contêm um atributo, pelo que a regra de ordenação do SET OF da cláusula 11.6 da X.690 é trivialmente satisfeita e não precisa de ordenação. E a porção assinada fica intacta por construção: o prefixo do SignerInfo até ao OCTET STRING da assinatura é copiado textualmente, e é por isso que um validador que volte a calcular o digest dos signedAttrs vê os mesmos bytes antes e depois de o carimbo temporal ser acrescentado. Quando algum continua a rejeitar o documento, as causas estão normalmente noutro lado e merecem a sua própria lista de verificação
O reader DER, o writer, o construtor de CMS e esta injeção de carimbo temporal são todos distribuídos em código Pascal com o componente PDFium para Delphi, e um bug com esta forma é o argumento para isso: quando um SignedData reconstruído sai com noventa bytes a mais, quer ler o helper que fez o corte e a cláusula da X.690 que ele leu mal, não uma stack trace de uma caixa negra