Artigo Técnico

Codificar RSASSA-PSS-params segundo a RFC 4055 em Delphi

O PDFium Component versão 3.114.20 corrige a codificação do RSASSA-PSS-params nos três backends de assinatura PAdES, Windows CNG, macOS Keychain e PKCS#11. A RFC 4055 §3.1 dá a cada campo do RSASSA-PSS-params uma tag explícita específica de contexto, de [0] a [3], e os backends emitiam o saltLength como um INTEGER universal nu ao mesmo tempo que escreviam um trailerField igual ao seu valor predefinido. Os bytes da assinatura estiveram corretos o tempo todo. O AlgorithmIdentifier que os descreve não estava, e só isso chega para um validador rejeitar a assinatura

A parte frustrante é onde o bug se esconde. Uma assinatura CMS tem duas metades, a operação criptográfica e o ASN.1 que diz ao validador como a operação foi realizada. Acerte na primeira e erre na segunda, e o resultado é um documento que nenhuma ferramenta que siga a especificação consegue distinguir de uma falsificação. Este artigo é só sobre essa segunda metade: como o RSASSA-PSS-params tem de ser etiquetado, como três backends o erraram da mesma forma, e como é o DER corrigido em termos de TDerWriter

Porque é que um validador rejeita uma assinatura RSASSA-PSS cujos bytes estão corretos?

Porque o RSASSA-PSS é o único esquema RSA em que o validador não consegue recuperar os parâmetros a partir da própria assinatura. O padding PKCS#1 v1.5 fica totalmente determinado pelo OID sha256WithRSAEncryption, pelo que os seus parâmetros são um NULL nu e não há nada para errar. O PSS é parametrizado por uma função de hash, uma função de geração de máscara com o seu próprio hash, e um comprimento de salt, e a RFC 8017 §A.2.3 deixa os três em aberto. O signatário escolhe-os, o AlgorithmIdentifier transporta-os, e o validador tem de os reproduzir exatamente antes de o EMSA-PSS-VERIFY sequer poder começar

Por isso, quando o PDFium Component assina com SHA-256, MGF1 sobre SHA-256 e um salt de 32 bytes, esses três factos têm de sobreviver à codificação DER aqui e à descodificação DER numa implementação diferente. Um bloco de parâmetros que o validador não consegue analisar termina a verificação antes de acontecer qualquer exponenciação modular. Um bloco que ele analisa de forma diferente é pior, porque a RFC 4055 §3.1 dá ao saltLength um valor predefinido de 20. Um descodificador que salte um campo que não reconhece aterra nesse predefinido, corre o EMSA-PSS-VERIFY com um salt de 20 bytes contra uma assinatura calculada com 32, e reporta uma assinatura inválida sem qualquer indício de que o problema é metadados e não a chave. Os dois desfechos são o que a codificação da 3.114.19 produzia, dependendo de quão estrito fosse o validador, e nenhum aponta para o AlgorithmIdentifier

O que é que a RFC 4055 §3.1 exige ao certo do RSASSA-PSS-params

A RFC 4055 §3.1 define o RSASSA-PSS-params como um SEQUENCE de quatro campos, cada um transportando uma tag explícita específica de contexto e um valor DEFAULT:

// RSASSA-PSS-params ::= SEQUENCE {
//   hashAlgorithm      [0] HashAlgorithm      DEFAULT sha1,
//   maskGenAlgorithm   [1] MaskGenAlgorithm   DEFAULT mgf1SHA1,
//   saltLength         [2] INTEGER            DEFAULT 20,
//   trailerField       [3] TrailerField       DEFAULT trailerFieldBC
// }

Etiquetagem explícita em DER significa que cada campo é embrulhado num TLV construído específico de contexto, A0 para [0], A1 para [1], A2 para [2] e A3 para [3], com a codificação universal do valor aninhada lá dentro. Todos os campos são etiquetados precisamente porque todos os campos são opcionais através do seu predefinido. Sem tags, um descodificador não conseguiria dizer se um SEQUENCE com um único AlgorithmIdentifier transportava hashAlgorithm ou maskGenAlgorithm, já que ambos são tipos SEQUENCE; com elas, o número da tag identifica o campo independentemente de que vizinhos estejam presentes. Os valores que o PDFium Component emite seguem o perfil na ETSI TS 119 312 §7, SHA-256, MGF1 com SHA-256 e um salt igual ao comprimento do digest, e espelham exatamente o que é dito a cada chamada de assinatura da plataforma: um BCRYPT_PSS_PADDING_INFO com cbSalt 32 para o NCryptSignHash, um CK_RSA_PKCS_PSS_PARAMS com sLen 32 para o mecanismo PKCS#11, e o algoritmo PSS de assinatura de digest SHA-256 na framework Security

Diagrama do PDFium Component do RSASSA-PSS-params segundo a RFC 4055: o hashAlgorithm A0, o maskGenAlgorithm A1 e o saltLength A2 transportam tags explícitas de contexto com valores DEFAULT, o perfil ETSI emite SHA-256, MGF1 com SHA-256 e salt 32, e o trailerField A3 é igual a trailerFieldBC, pelo que o DER o omite por completo
Todos os campos são etiquetados precisamente porque todos são opcionais através do seu predefinido, pelo que o número da tag identifica o campo seja quais forem os vizinhos que o encoder deixa de fora

Como é que três backends cometeram o mesmo erro

A codificação da 3.114.19 etiquetava os dois primeiros campos e deixava os dois últimos nus, de forma idêntica em TWinCmsSigner, TKeychainCmsSigner e TPkcs11CmsSigner. Essa simetria não é coincidência: os três implementam a interface ICmsSigner de FPdfCms.pas, e os seus corpos de GetSignatureAlgorithmParams foram escritos a partir de um único modelo. O modelo era assim:

// Antes da 3.114.20: [0] e [1] etiquetados, [2] e [3] não
Result := W.Sequence(Concat4(
  W.ContextSpecific(0, W.AlgId(OID_SHA256), True),
  W.ContextSpecific(1, W.Sequence(ConcatBytes(
    W.OID(OID_MGF1), W.AlgId(OID_SHA256))), True),
  W.IntegerOf(32),      // INTEGER nu onde o [2] EXPLICIT era exigido
  W.IntegerOf(1)));     // trailerField, igual ao DEFAULT, tem de estar ausente

Um descodificador a percorrer esse SEQUENCE vê A0, lê a função de hash, vê A1, lê a função de geração de máscara, e depois encontra 02 01 20. Isso é um INTEGER universal, e o RSASSA-PSS-params não tem nenhum membro INTEGER sem tag em lado nenhum. Um descodificador estrito para aí. Um tolerante salta o elemento que não reconhece, nunca encontra um A2, atribui ao saltLength o seu predefinido de 20 e depois topa com um segundo INTEGER perdido, 02 01 01, e tem o mesmo problema outra vez. Nenhum dos caminhos chega a um salt de 32 bytes. Um modelo partilhado é eficiente quando está certo e uma forma igualmente eficiente de errar três vezes quando não está, e é por isso que a correção entrou nos três módulos num único commit e por que os três corpos de método continuam estruturalmente idênticos depois disso. Um backend futuro deve copiar o bloco de um destes em vez de o rederivar, porque a derivação é exatamente onde o erro foi cometido

Diagrama do PDFium Component do bug DER da 3.114.19: depois do A0 e do A1 os parâmetros encontravam um 02 01 20 nu onde pertence a tag explícita A2, um descodificador estrito parava e um tolerante ficava com o salt predefinido de 20 bytes, enquanto a 3.114.20 embrulha o salt de 32 bytes num A2
O RSASSA-PSS-params não tem nenhum membro INTEGER sem tag, pelo que os bytes perdidos eram ou uma falha de análise ou um recuo silencioso para o comprimento de salt predefinido, e nenhum dos caminhos chegava ao 32 do signatário

Porque é que o trailerField é omitido em vez de etiquetado como [3]?

Porque a X.690 §11.5 diz que um encoder DER não deve codificar um componente cujo valor seja igual ao seu DEFAULT, e o trailerField tem DEFAULT trailerFieldBC, que é o inteiro 1. A correção óbvia ao código antigo, substituir o W.IntegerOf(1) nu por W.ContextSpecific(3, W.IntegerOf(1), True), produz um bloco que um descodificador BER permissivo aceita e que um descodificador DER estrito tem o direito de rejeitar. O valor não está errado. A sua presença está. É a mesma regra que faz com que os outros três campos estejam presentes: o SHA-256 não é o sha1 predefinido, o MGF1 com SHA-256 não é o mgf1SHA1 predefinido, e o 32 não é o 20 predefinido. Se o backend estivesse a assinar com SHA-1 e um salt de 20 bytes, a RFC 4055 §3.1 colapsaria os parâmetros num SEQUENCE vazio, 30 00, e é esse SEQUENCE vazio, e não um NULL, que um validador espera. O PDFium Component nunca emite essa forma porque nunca assina com esses valores, mas é o caso que apanha quem assuma que «sem parâmetros» se escreve sempre 05 00

É esta a distinção entre DER e BER que importa especificamente para assinaturas. O BER deixa um encoder incluir um componente com valor predefinido; o DER proíbe-o, porque o DER existe para que um valor tenha exatamente uma codificação, e uma assinatura sobre uma estrutura com duas codificações legais é uma assinatura sobre a qual se pode discutir. Tudo dentro dos signedAttrs de um CMS é DER por essa razão, e o bloco de parâmetros viaja dentro dos signedAttrs através do atributo cmsAlgorithmProtection, além de no signatureAlgorithm exterior, pelo que não tem isenção nenhuma

Diagrama do PDFium Component da regra X.690 11.5 na correção do PSS: o trailerField igual ao seu DEFAULT trailerFieldBC tem de ficar ausente porque um invólucro A3 etiquetado é a codificação que o DER estrito rejeita, enquanto o saltLength 32 difere do 20 predefinido e tem de estar presente como A2
O DER existe para que um valor tenha exatamente uma codificação, e um componente igual ao seu predefinido já tem a codificação mais curta que há, ou seja, não aparecer de todo

A codificação TDerWriter corrigida

O PDFium Component passa agora a construir os parâmetros com três chamadas a TDerWriter.ContextSpecific de FPdfAsn1.pas, uma por campo não predefinido, cada uma com o argumento Constructed a True para produzir o invólucro de tag explícita, e sem linha nenhuma para o campo trailer. Este é o corpo do TWinCmsSigner.GetSignatureAlgorithmParams com os OIDs escritos por extenso; os módulos Keychain e PKCS#11 escrevem os mesmos valores como OID_SHA256, OID_MGF1 e OID_RSASSA_PSS:

function TWinCmsSigner.GetSignatureAlgorithmParams: TBytes;
var
  W: TDerWriter;
begin
  if FPaddingScheme = psRsaPss then
  begin
    W := TDerWriter.Create;
    try
      // A RFC 4055 3.1 etiqueta os quatro campos. O saltLength é [2]; um
      // INTEGER nu aqui é lido como o início de outro campo. O trailerField
      // é [3] com DEFAULT 1, e a X.690 11.5 proíbe codificar um valor
      // igual ao predefinido, pelo que é omitido por completo
      Result := W.Sequence(Concat3(
        W.ContextSpecific(0, W.AlgId('2.16.840.1.101.3.4.2.1'), True),
        W.ContextSpecific(1, W.Sequence(ConcatBytes(
          W.OID('1.2.840.113549.1.1.8'),          // id-mgf1
          W.AlgId('2.16.840.1.101.3.4.2.1'))), True),
        W.ContextSpecific(2, W.IntegerOf(32), True)));
    finally
      W.Free;
    end;
  end
  else
    Result := nil;   // PKCS#1 v1.5 e ECDSA: o AlgIdWithParams escreve NULL
end;

Dois pormenores da maquinaria em volta importam. O TDerWriter.AlgId produz um AlgorithmIdentifier com parâmetros NULL, que é o que a RFC 4055 §2.1 diz aos encoders para gerarem para o hashAlgorithm aninhado e para o hash interno do MGF1. E o construtor de CMS em FPdfCms.pas emparelha o OID de assinatura com estes bytes através do TDerWriter.AlgIdWithParams, que substitui por NULL quando os parâmetros são nil; é por isso que psRsaPkcs1v15 e psEcdsa devolvem simplesmente nil e nunca foram afetados, e por que 1.2.840.113549.1.1.10, id-RSASSA-PSS, é o único OID de assinatura dos três que transporta um bloco de parâmetros a sério. Os bytes resultantes para o perfil SHA-256 são fixos e curtos o suficiente para se verificar a olho: um SEQUENCE 30 34 exterior com A0 0F em volta do AlgorithmIdentifier SHA-256 de 15 bytes, A1 1C em volta do AlgorithmIdentifier MGF1 de 28 bytes cujos próprios parâmetros são esse mesmo AlgorithmIdentifier SHA-256, e A2 03 02 01 20 para o salt. Se um dump do seu signatureAlgorithm mostrar 02 01 20 ao nível de topo do SEQUENCE de parâmetros em vez de dentro de um A2, está a olhar para a codificação da 3.114.19

Porque é que a suite de testes não apanhou um AlgorithmIdentifier malformado?

Porque os testes PAdES conduzem o construtor de CMS através de um signatário falso que reporta sha256WithRSAEncryption e devolve nil do GetSignatureAlgorithmParams, pelo que o bloco de parâmetros PSS nunca foi construído num teste. É um desenho razoável para testes que têm de correr sem um repositório de certificados, sem Keychain ou sem token, e tem um ponto cego com uma forma precisa: tudo o que só um backend real produz só é exercitado por um backend real. A segunda camada é mais interessante. O PDFium Component coloca também o AlgorithmIdentifier da assinatura, parâmetros incluídos, dentro do atributo assinado cmsAlgorithmProtection da RFC 6211, e um validador compara essa cópia com o signatureAlgorithm exterior. As duas cópias vinham da mesma chamada, pelo que coincidiam na perfeição e todas as verificações de consistência interna passavam. A codificação era autoconsistente e errada, a categoria de bug que nenhuma quantidade de comparação de uma estrutura consigo mesma consegue revelar, e a mesma lição com uma estrutura diferente é contada em signedAttrs do CMS e ordenação DER SET OF, onde um SET com hash calculado numa ordem e emitido noutra parecia bem até um validador alheio recalcular o hash

O que apanha esta classe de bug é um descodificador que não tenha sido escrito pelo autor do encoder, corrido contra a saída real do backend real. O caminho de verificação no Windows do PDFium Component passa pela CryptoAPI e não pelo próprio leitor da biblioteca, e foi uma assinatura PSS rejeitada ali que levou de volta aos parâmetros. Qualquer ASN.1 que uma implementação emita para outras implementações lerem merece pelo menos um round-trip por um descodificador que não controla, e quantos mais predefinidos e tags a estrutura tiver, mais vale esse round-trip

Onde é que isto encaixa no resto da história do PSS

Esta correção é independente dos outros dois sítios onde o PSS pode correr mal numa assinatura PAdES, e mantê-los separados encurta a depuração. O backend macOS pode descobrir que uma determinada chave ou um sistema mais antigo recusa PSS e recuar para PKCS#1 v1.5, e o AlgorithmIdentifier tem de acompanhar o recuo; isso é uma questão de capacidade, tratada em assinar PAdES com uma identidade do macOS Keychain. O backend PKCS#11 pode entregar ao token um CK_RSA_PKCS_PSS_PARAMS cuja disposição o token lê de forma diferente por causa de um desencontro de largura de inteiro; isso é uma questão de ABI, tratada em CK_ULONG e a armadilha de packing do PKCS#11. Este artigo é sobre a terceira falha, aquela em que a chave estava disposta, o token calculou os bytes certos, e o DER que descrevia o resultado não cumpria a RFC 4055 §3.1

Um signatário que declara PSS assume uma obrigação que o signatário v1.5 nunca teve: descrever os seus próprios parâmetros numa forma que outra implementação descodifique para os mesmos três valores. A RFC 4055 §3.1 fixa as tags, a X.690 §11.5 fixa que campos podem aparecer, e a ETSI TS 119 312 §7 fixa os valores que vale a pena escolher. Os três backends do componente PDFium para Delphi são distribuídos como código-fonte, pelo que o corpo do GetSignatureAlgorithmParams acima é aquele que pode ler, fazer dump e comparar com o seu próprio validador em vez de aceitar por confiança