Artigo Técnico

Ordenação DER SET OF de signedAttrs no PDFium Component

O PDFium Component monta o bloco CMS signedAttrs pelo TDerWriter.SetOfSorted, porque a cláusula 11.6 da X.690 exige que os membros de um SET OF sejam emitidos em ordem crescente das próprias codificações DER. Monte na ordem de construção e você ganha assinaturas que seu próprio verificador aceita e todo validador PAdES de terceiros rejeita

Essa última frase é o motivo inteiro de esse defeito ter sobrevivido a uma suíte de regressão completa. O caminho de assinatura era coberto, o caminho de verificação era coberto, o round-trip ficava verde em toda plataforma, e o output ainda assim não era DER válido. Um teste que assina com o seu writer e verifica com o seu reader não detecta um desvio das regras de codificação, porque as duas metades compartilham o desvio

O que a cláusula 11.6 da X.690 exige de verdade

Os membros de um valor SET OF precisam ser ordenados, e a chave de ordenação é a codificação DER completa de cada membro, comparada como string de octetos sem sinal, com a codificação mais curta primeiro quando uma é prefixo da outra. Isso é uma restrição canônica, não uma sugestão: a DER admite exatamente uma codificação para um dado valor, então um SET OF desordenado não é uma codificação do mesmo valor em outra ordem — não é codificação DER nenhuma. Como as tags ASN.1 vêm primeiro num TLV, a comparação costuma se resolver dentro dos dois primeiros octetos, e como todo membro de uma lista de atributos CMS começa com 30, a tag SEQUENCE, o desempate cai no octeto de comprimento. Por isso a ordem exigida parece quase uma ordenação dos atributos por tamanho, o que vale saber quando você está encarando um hex dump se perguntando por que a ordem parece arbitrária

Diagrama do artigo do PDFium Component mostrando como a cláusula 11.6 da X.690 ordena o SET OF de signedAttrs do CMS: BuildSignedAttrs acrescenta content-type, message-digest, signing-certificate-v2 e cms-algorithm-protection na ordem de construção, enquanto a DER conforme ordena os quatro membros pelas codificações completas, 30 18, 30 2D, 30 2F, 30 37
A ordem de construção e a ordem codificada discordam desde a primeira assinatura, porque os quatro membros carregam a tag SEQUENCE 30 e o octeto de comprimento decide a comparação

Por que o conjunto mínimo de atributos já nasce fora de ordem?

Porque a ordem natural de construção e a ordem codificada discordam desde a primeira assinatura que você produz, sem envolver nenhum atributo opcional. O BuildSignedAttrs do FPdfCms.pas acrescenta content-type, depois message-digest, depois signing-certificate-v2, depois cms-algorithm-protection. Com digests SHA-256 e 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 precisa vir antes do signing-certificate-v2 em 30 37, e também antes do message-digest em 30 2F. Ou seja, o menor conjunto de atributos PAdES B-B conforme que o PDFium Component consegue emitir já saía fora de ordem no código antigo, antes de qualquer signing-time, commitment-type-indication ou signature-policy-identifier. Nunca existiu uma configuração em que o bug não disparasse

Por que nossos próprios testes passam enquanto todo validador externo falha?

Porque as duas famílias de verificador calculam o message digest sobre bytes diferentes. A cláusula 5.4 da RFC 5652 diz que a entrada da operação de assinatura é a codificação DER do valor SignedAttributes, com a tag IMPLICIT [0] trocada pela tag SET 31, e a frase que decide tudo é a codificação DER, não os octetos que você recebeu. O verificador da plataforma Windows alcançado via CryptVerifyDetachedMessageSignature pega os octetos recebidos, troca a tag inicial e aplica hash na ordem em que chegaram, então a ordem nunca entra na conta e um SET OF fora de ordem faz digest exatamente do que o signatário calculou. OpenSSL e BouncyCastle — as bases dos validadores europeus amplamente implantados — fazem parse do conjunto de atributos em estruturas e re-encodam antes do digest. Re-encodar aplica a ordenação, o digest deixa de bater com o que foi assinado, e a assinatura é reportada como quebrada em vez de mal codificada, o que manda você caçar defeito no material de chave e nas cadeias de certificado quando ele mora numa função de comparação

Diagrama do PDFium Component sobre a divergência de verificadores por trás do bug do SET OF ordenado: o signatário aplica hash na codificação DER com a tag SET 31, o caminho do CryptVerifyDetachedMessageSignature do Windows faz digest dos octetos recebidos, então a ordem nunca importa e uma assinatura fora de ordem passa, enquanto OpenSSL e BouncyCastle re-encodam, aplicam a ordenação da X.690 e reportam a assinatura como quebrada
O CryptVerifyDetachedMessageSignature faz digest dos octetos como chegaram, então um conjunto fora de ordem verifica, enquanto OpenSSL e BouncyCastle re-encodam antes do digest e reportam a assinatura como quebrada em vez de mal codificada

Generalize para além desse bug, porque é aí que mora o caro. Um round-trip de mesma origem é imune a todo desvio de uma especificação de interoperabilidade que as duas metades do seu código compartilham; ele prova que seu writer e seu reader concordam entre si, o que não é a propriedade que interessa a ninguém. Para qualquer coisa que sai do processo como wire format, a única regressão que significa alguma coisa é a que prende os bytes à especificação ou roda uma implementação estrangeira sobre seu output. O backend de verificação CMS do OpenSSL merece um lugar aqui por um segundo motivo além de portabilidade: é um reader genuinamente independente do seu próprio DER

O fix, e onde ele já existia

O writer de DER tinha a variante ordenada desde sempre. O TDerWriter do FPdfAsn1.pas expõe tanto SetOf, que embrulha um buffer de conteúdo já concatenado numa tag SET e preserva qualquer ordem que receba, quanto SetOfSorted, que recebe os membros individualmente como um TDerByteArrays e faz merge sort das codificações antes de embrulhar. O código de MAC de PDF do FPdfMac.pas já montava o conjunto de atributos autenticados com SetOfSorted; só o signatário CMS não usava. Manter os membros como array até a chamada de embrulho é a metade estrutural do fix, porque uma vez que os atributos foram concatenados num buffer único, a informação de ordem se perdeu e nenhum cuidado a jusante restaura. Uma API de writer que só oferece SetOf(Content: TBytes) para SET OF é uma API que convida esse bug; a sobrecarga que recebe array é o que torna a coisa certa expressável

Diagrama do par de APIs do TDerWriter no PDFium Component: SetOf embrulha um buffer TBytes já concatenado e preserva a ordem de membros que recebeu, produzindo output que não é DER, enquanto SetOfSorted mantém os membros como array TDerByteArrays, faz merge sort pelas codificações completas e embrulha um SET OF conforme
Uma vez que os atributos foram concatenados num buffer único, a informação de ordem se perdeu, então a sobrecarga SetOfSorted que recebe array é o que torna a coisa certa expressável no ponto da codificação
// Antes: os membros mantêm a ordem em que AddAttr os adicionou
Result:= W.SetOf(ConcatMany(AttrList));

// Depois: ordenação X.690 11.6 aplicada no momento da codificação
Result:= W.SetOfSorted(AttrList);

SET OF ordena, SEQUENCE OF não

Essa é a distinção que mais confunde, e invertê-la é tão danoso quanto ignorá-la. Um SEQUENCE OF é uma coleção ordenada: a ordem faz parte do valor e você nunca deve ordená-lo. Um SET OF é desordenado como valor, então a DER impõe uma ordem canônica para a codificação ser única. Ordenar um SEQUENCE OF corrompe dados; deixar um SET OF desordenado produz algo que não é DER

  • SET OF, precisa ordenar: SignedAttributes e UnsignedAttributes do SignerInfo, o conjunto digestAlgorithms, os conjuntos certificates e crls do SignedData, o conjunto signerInfos, o conjunto de valores dentro de cada Attribute individual, e o conjunto de atributos dentro de cada RelativeDistinguishedName de um Name X.501
  • SEQUENCE OF, nunca ordenar: a lista de RDNs que forma um Name, o campo certs do SigningCertificateV2, listas de extensão, e as listas de general-name dentro delas

Um RDN é um SET OF dentro de um SEQUENCE OF um nível acima, então um distinguished name contém as duas regras em dois níveis de aninhamento. Esse layout também explica por que conjuntos de um membro só são tão tolerantes: um conjunto com um elemento está trivialmente ordenado, e é por isso que os conjuntos de valores de atributo montados com W.SetOf dentro do BuildSignedAttrs estavam certos como escritos e não precisaram de mudança. O bug só mora onde um conjunto tem de fato dois ou mais membros, e RDNs multivalorados são raros o bastante para a maioria dos encoders de nome se safar com o mesmo erro durante anos

Como evitar que isso volte?

Faça assert em bytes, não em veredictos. O check durável mais barato é uma autoverificação que percorre o próprio output: faça parse do 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 qualquer par estiver fora de ordem. Roda em microssegundos, não precisa de biblioteca externa, e diferente de um round-trip de verificação, testa a propriedade que a especificação de fato afirma

// Percorra os membros de um SET OF e confirme a ordenação X.690 11.6.
// Members é a lista entregue a SetOfSorted, ou o resultado de
// reanalisar um bloco emitido 31 xx / A0 xx
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 "nosso verificador disse válido".
// Fixe os bytes que o assinante passou pelo hash
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 ser ditos sem rodeio. Ordenar signedAttrs não torna uma assinatura interoperável sozinho — todo o resto do container também tem que se segurar, e os motivos pelos quais validadores recusam assinaturas PAdES que de resto são sólidas são variados o bastante para encher uma discussão própria. E um SET OF ordenado é uma propriedade do que você escreve, não do que você lê: o PDFium Component ainda precisa aceitar uma assinatura de entrada cujo signedAttrs chega desordenado, porque esses arquivos existem na natureza e a cláusula 5.4 da RFC 5652 manda o verificador re-encodar em vez de rejeitar. Estrito na saída, tolerante na entrada, como sempre. O writer de DER, o builder de CMS e o caminho de assinatura descritos no passo a passo do PAdES B-B todos vêm como código Pascal legível junto com o componente Delphi PDFium, que é o ponto de entregar source para uma biblioteca de assinatura: quando um validador em algum lugar da Europa discorda do seu documento, você pode ler os bytes exatos que seu código emitiu e a cláusula exata que eles violam, em vez de abrir um ticket e esperar