O PDFium Component constrói o bloco CMS signedAttrs através do TDerWriter.SetOfSorted, porque a cláusula 11.6 da X.690 exige que os membros de um SET OF sejam emitidos por ordem ascendente das suas próprias codificações DER. Monte-os por ordem de construção em vez disso e obtém assinaturas que o seu próprio verifier aceita e que todos os validadores PAdES de terceiros rejeitam
Essa última frase é toda a razão pela qual este defeito sobreviveu a uma suite de regressão completa. O path de assinatura estava coberto, o path de verificação estava coberto, o round trip estava verde em todas as plataformas, e o output continuava a não ser DER válido. Um teste que assina com o seu writer e verifica com o seu reader não consegue detetar um desvio das regras de codificação, porque as duas metades partilham o desvio
O que a cláusula 11.6 da X.690 exige realmente
Os membros de um valor SET OF têm de estar ordenados, e a chave de ordenação é a codificação DER completa de cada membro, comparada como uma string de octetos sem sinal, com a codificação mais curta primeiro quando uma é prefixo da outra. Isto é uma restrição canónica e não uma sugestão: o DER admite exatamente uma codificação para um dado valor, pelo que um SET OF não ordenado não é uma codificação do mesmo valor por outra ordem, não é de todo uma codificação DER. Como as tags ASN.1 vêm primeiro num TLV, a comparação costuma resolver-se dentro dos primeiros dois octetos, e como cada membro de uma lista de atributos CMS começa com 30, a tag SEQUENCE, o desempate recai no octeto de comprimento. A ordem exigida parece portanto quase uma ordenação dos atributos por tamanho, o que vale a pena saber quando está a olhar para um hex dump a perguntar-se porque é que a ordem parece arbitrária
Porque é que o conjunto mínimo de atributos já sai fora de ordem?
Porque a ordem natural de construção e a ordem codificada discordam desde a primeira assinatura que produz, sem que haja atributos opcionais envolvidos. O BuildSignedAttrs em FPdfCms.pas anexa content-type, depois message-digest, depois signing-certificate-v2, depois cms-algorithm-protection. Com digests SHA-256 e um algoritmo de assinatura RSA, os quatro TLVs de atributo começam com estes bytes
// Ordem de construção produzida por BuildSignedAttrs
// content-type 30 18 (24 content octets)
// message-digest 30 2F (47)
// signing-certificate-v2 30 37 (55)
// cms-algorithm-protection 30 2D (45)
//
// Ordem DER exigida pela X.690 11.6
// 30 18 content-type
// 30 2D cms-algorithm-protection
// 30 2F message-digest
// 30 37 signing-certificate-v2
Dois dos quatro atributos mudam de lugar. O cms-algorithm-protection em 30 2D tem de preceder o signing-certificate-v2 em 30 37, e também tem de preceder o message-digest em 30 2F. Ou seja, o conjunto mínimo de atributos PAdES B-B conforme que o PDFium Component consegue emitir já saía mal ordenado com o código antigo, antes de alguém adicionar signing-time, commitment-type-indication ou um signature-policy-identifier. Nunca houve uma configuração em que o bug não disparasse
Porque é que os nossos testes passam e todos os validadores externos falham?
Porque as duas famílias de verifier calculam o message digest sobre bytes diferentes. A cláusula 5.4 da RFC 5652 diz que o input da operação de assinatura é a codificação DER do valor SignedAttributes, com a tag IMPLICIT [0] substituída pela tag SET 31, e a frase que decide tudo é a codificação DER, não os octetos que recebeu. O verifier da plataforma Windows, alcançado através de CryptVerifyDetachedMessageSignature, pega nos octetos recebidos, troca a tag inicial e hasheia-os tal como chegaram, pelo que a ordem nunca entra na equação e um SET OF mal ordenado digere exatamente o que o signer calculou. O OpenSSL e o BouncyCastle, que é o que está na base dos validadores europeus amplamente implementados, analisam o conjunto de atributos em estruturas e re-codificam antes de digerir. A re-codificação aplica a ordenação, o digest deixa de bater certo com o que foi assinado, e a assinatura é reportada como partida em vez de mal codificada, o que o manda à caça de key material e cadeias de certificados por um defeito que vive numa função de comparação
Generalize para além deste bug, porque é essa a parte cara. Um round trip da mesma origem é imune a qualquer desvio de uma especificação de interoperabilidade que as duas metades do seu código partilhem; prova que o seu writer e o seu reader concordam um com o outro, que não é a propriedade que interessa a alguém. Para qualquer coisa que saia do processo como wire format, a única regressão com significado é a que fixa bytes contra a especificação ou corre uma implementação estrangeira sobre o seu output. O backend de verificação CMS do OpenSSL ganha o seu lugar aqui por uma segunda razão além da portabilidade: é um reader genuinamente independente do seu próprio DER
A correção, e onde já existia
O writer DER tinha a variante ordenada desde sempre. O TDerWriter em FPdfAsn1.pas expõe tanto o SetOf, que envolve um buffer de conteúdo pré-concatenado numa tag SET e preserva seja qual for a ordem que lhe deram, como o SetOfSorted, que recebe os membros individualmente como um TDerByteArrays e faz merge-sort deles pelas suas codificações antes de envolver. O código de MAC de PDF em FPdfMac.pas já construía o seu conjunto de atributos autenticados com SetOfSorted; só o signer do CMS é que não. Manter os membros como um array até à chamada de envolvimento é a metade estrutural da correção, porque depois de os atributos serem concatenados num único buffer a informação de ordem desapareceu e nenhum cuidado a jusante a pode restaurar. Uma API de writer que oferece apenas SetOf(Content: TBytes) para SET OF é uma API que convida a este bug; o overload que aceita o array é o que torna a coisa certa expressível
// Antes: os membros mantêm a ordem em que o AddAttr os anexou
Result:= W.SetOf(ConcatMany(AttrList));
// Depois: ordenação da X.690 11.6 aplicada no ponto de codificação
Result:= W.SetOfSorted(AttrList);
SET OF ordena-se, SEQUENCE OF não
Esta é a distinção que mais vezes se confunde, e percebê-la ao contrário é tão danoso como ignorá-la. Uma SEQUENCE OF é uma coleção ordenada: a sua ordem faz parte do valor e nunca a deve ordenar. Um SET OF é desordenado como valor, por isso o DER impõe uma ordem canónica para tornar a codificação única. Ordenar uma SEQUENCE OF corrompe dados; deixar um SET OF por ordenar produz algo que não é DER
- SET OF, tem de ser ordenado:
SignedAttributeseUnsignedAttributesemSignerInfo, o conjuntodigestAlgorithms, os conjuntoscertificatesecrlsemSignedData, o conjuntosignerInfos, o conjunto de valores dentro de cadaAttributeindividual, e o conjunto de atributos dentro de cadaRelativeDistinguishedNamede um Name X.501 - SEQUENCE OF, nunca ordenar: a lista de RDN que compõe um
Name, o campocertsdeSigningCertificateV2, as listas de extensões e as listas de general names lá dentro
Um RDN é um SET OF sentado dentro de uma SEQUENCE OF um nível acima, pelo que um distinguished name contém as duas regras dentro de dois níveis de aninhamento. Esse layout também explica porque é que os conjuntos de um só membro são tão permissivos: um conjunto com um elemento está trivialmente ordenado, que é a razão pela qual os conjuntos de valores de atributos construídos com W.SetOf dentro de BuildSignedAttrs estavam corretos tal como escritos e não precisaram de mudança. O bug só vive onde um conjunto tem genuinamente dois ou mais membros, e RDNs multi-valor são raros o suficiente para a maioria dos encoders de nomes escapar com o mesmo erro durante anos
Como é que evita que isto volte?
Faça assert sobre bytes, não sobre veredictos. A verificação durável mais barata é uma auto-auditoria que percorre o próprio output: analise o signedAttrs emitido, compare cada membro com o anterior usando a mesma comparação de string de octetos sem sinal que o writer usa, e falhe o build se algum par estiver fora de ordem. Corre em microssegundos, não precisa de biblioteca externa e, ao contrário de um round trip de verificação, testa a propriedade que a especificação realmente afirma
// Percorre os membros de um SET OF e confirma a ordenação da X.690 11.6.
// Members é a lista entregue ao SetOfSorted, ou o resultado de
// reanalisar um bloco 31 xx / A0 xx emitido
function SetOfIsSorted(const Members: TDerByteArrays): Boolean;
var
I: Integer;
begin
for I:= 1 to Length(Members)- 1 do
if CompareDerBytes(Members[I- 1], Members[I])> 0 then
Exit(False);
Result:= True;
end;
// No teste de assinatura, não pare em «o nosso verifier disse válido».
// Fixe os bytes que o signer hasheou
Attrs:= BuildSignedAttrs(W, Digest, CertHash, DigestOid, SigOid, nil, Options);
Assert(Attrs[0]= $31, 'signedAttrs must carry the SET tag when hashed');
Assert(SetOfIsSorted(SplitSetOfMembers(Attrs)), 'signedAttrs not in DER order');
Dois limites valem a pena ser ditos claramente. Ordenar o signedAttrs não torna uma assinatura interoperável por si só, porque tudo o resto no container também tem de aguentar e as razões pelas quais os validadores recusam assinaturas PAdES de resto sólidas são variadas o suficiente para encher a sua própria discussão. E um SET OF ordenado é uma propriedade do que escreve, não do que lê: o PDFium Component ainda tem de aceitar uma assinatura recebida cujo signedAttrs chegue desordenado, porque esses ficheiros existem na natureza e a cláusula 5.4 da RFC 5652 diz a um verifier para re-codificar em vez de rejeitar. Estrito no output, tolerante no input, como sempre. O writer DER, o builder de CMS e o path de assinatura descritos em o passo a passo PAdES B-B vêm todos como código Pascal legível com o componente PDFium Delphi, que é o sentido de distribuir source para uma biblioteca de assinaturas: quando um validador em qualquer lado da Europa discorda do seu documento, pode ler os bytes exatos que o seu código emitiu e a cláusula exata que violam, em vez de abrir um ticket e esperar