O PDFium Component na versão 3.114.20 corrige a codificação de RSASSA-PSS-params nos três backends de assinatura PAdES: Windows CNG, Keychain do macOS e PKCS#11. A RFC 4055 §3.1 dá a todo campo de RSASSA-PSS-params uma tag explícita específica de contexto, [0] a [3], e os backends emitiam saltLength como um INTEGER universal cru enquanto escreviam um trailerField igual ao seu default. Os bytes da assinatura estavam corretos o tempo todo. O AlgorithmIdentifier que os descrevia não estava, e só isso já basta para um verificador 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 verificador como a operação foi realizada. Acerte a primeira e erre a 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 RSASSA-PSS-params precisa ser marcado, como três backends erraram do mesmo jeito, e com que cara o DER corrigido fica em termos de TDerWriter
Por que um verificador rejeita uma assinatura RSASSA-PSS cujos bytes estão corretos?
Porque RSASSA-PSS é o único esquema RSA em que o verificador não consegue recuperar os parâmetros da própria assinatura. O padding PKCS#1 v1.5 é totalmente determinado pelo OID sha256WithRSAEncryption, então seus parâmetros são um NULL cru 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 hash própria e um comprimento de salt, e a RFC 8017 §A.2.3 deixa os três em aberto. O assinante os escolhe, o AlgorithmIdentifier os carrega, e o verificador precisa reproduzi-los exatamente antes de o EMSA-PSS-VERIFY sequer poder começar
Então, quando o PDFium Component assina com SHA-256, MGF1 sobre SHA-256 e um salt de 32 bytes, esses três fatos precisam sobreviver à codificação DER aqui e à decodificação DER numa implementação diferente. Um bloco de parâmetros que o verificador não consegue analisar encerra a verificação antes de qualquer exponenciação modular acontecer. Um bloco que ele analisa de outra forma é pior, porque a RFC 4055 §3.1 dá a saltLength o default 20. Um decodificador que pula um campo que não reconhece cai nesse default, roda o EMSA-PSS-VERIFY com um salt de 20 bytes contra uma assinatura calculada com 32, e reporta uma assinatura ruim sem nenhuma pista de que o problema é metadado 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 verificador, e nenhum dos dois aponta para o AlgorithmIdentifier
O que a RFC 4055 §3.1 exige de fato de RSASSA-PSS-params
A RFC 4055 §3.1 define RSASSA-PSS-params como uma SEQUENCE de quatro campos, cada um carregando 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
// }
Marcação explícita em DER significa que cada campo fica 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 dentro. Todo campo é marcado precisamente porque todo campo é opcional por causa do seu default. Sem as tags, um decodificador não conseguiria dizer se uma SEQUENCE com um único AlgorithmIdentifier carregava o hashAlgorithm ou o maskGenAlgorithm, já que os dois são tipos SEQUENCE; com elas, o número da tag identifica o campo independentemente de quais vizinhos estão presentes. Os valores que o PDFium Component emite seguem o perfil da ETSI TS 119 312 §7, SHA-256, MGF1 com SHA-256 e um salt igual ao tamanho do digest, e espelham exatamente o que cada chamada de assinatura da plataforma recebe: um BCRYPT_PSS_PADDING_INFO com cbSalt 32 para 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 no framework Security
Como três backends cometeram o mesmo erro
A codificação da 3.114.19 marcava os dois primeiros campos e deixava os dois últimos crus, 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 seus corpos de GetSignatureAlgorithmParams foram escritos a partir de um mesmo template. O template era assim:
// Antes da 3.114.20: [0] e [1] marcados, [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 cru onde o [2] EXPLICIT era obrigatório
W.IntegerOf(1))); // trailerField, igual ao DEFAULT, deve estar ausente
Um decodificador percorrendo essa SEQUENCE vê A0, lê o algoritmo de hash, vê A1, lê a função de geração de máscara, e então encontra 02 01 20. Isso é um INTEGER universal, e RSASSA-PSS-params não tem membro INTEGER sem tag em lugar nenhum. Um decodificador estrito para aí. Um tolerante pula o elemento que não reconhece, nunca encontra um A2, atribui a saltLength o default 20, e então esbarra num segundo INTEGER perdido, 02 01 01, e tem o mesmo problema outra vez. Nenhum dos caminhos chega a um salt de 32 bytes. Um template compartilhado é 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 nas três units num único commit e que os três corpos de método continuam estruturalmente idênticos depois disso. Um backend futuro deveria copiar o bloco de um desses em vez de derivá-lo de novo, porque a derivação é exatamente onde o erro foi cometido
Por que trailerField é omitido em vez de marcado como [3]?
Porque a X.690 §11.5 diz que um codificador DER não deve codificar um componente cujo valor seja igual ao seu DEFAULT, e trailerField tem DEFAULT trailerFieldBC, que é o inteiro 1. A correção óbvia do código antigo, trocar o W.IntegerOf(1) cru por W.ContextSpecific(3, W.IntegerOf(1), True), produz um bloco que um decodificador BER permissivo aceita e que um decodificador DER estrito tem o direito de rejeitar. O valor não está errado. A presença dele está. A mesma regra é o motivo de os outros três campos estarem presentes: SHA-256 não é o default sha1, MGF1 com SHA-256 não é o mgf1SHA1 padrão, e 32 não é o 20 padrão. Se o backend estivesse assinando com SHA-1 e um salt de 20 bytes, a RFC 4055 §3.1 colapsaria os parâmetros numa SEQUENCE vazia, 30 00, e essa SEQUENCE vazia, e não NULL, é o que um verificador espera. O PDFium Component nunca emite essa forma porque nunca assina com aqueles valores, mas é o caso que pega quem assume que sem parâmetros sempre se escreve 05 00
É essa a distinção entre DER e BER que importa especificamente para assinaturas. O BER deixa um codificador incluir um componente com valor default; o DER proíbe, 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 do CMS é DER por esse motivo, e o bloco de parâmetros viaja dentro dos signedAttrs pelo atributo cmsAlgorithmProtection além de no signatureAlgorithm externo, então ele não ganha isenção
A codificação corrigida no TDerWriter
O PDFium Component agora monta os parâmetros com três chamadas a TDerWriter.ContextSpecific de FPdfAsn1.pas, uma por campo não default, cada uma com o argumento Constructed em True para produzir o invólucro de tag explícita, e nenhuma linha para o campo trailer. Este é o corpo de TWinCmsSigner.GetSignatureAlgorithmParams com os OIDs escritos por extenso; as units do Keychain e do 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 marca os quatro campos. saltLength é [2]; um
// INTEGER cru aqui é lido como o início de outro campo. trailerField
// é [3] com DEFAULT 1, e a X.690 11.5 proíbe codificar um valor
// igual ao default, então ele é 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: AlgIdWithParams escreve NULL
end;
Dois detalhes da maquinaria em volta importam. TDerWriter.AlgId produz um AlgorithmIdentifier com parâmetros NULL, que é o que a RFC 4055 §2.1 manda os codificadores gerarem para o hashAlgorithm aninhado e para o hash interno do MGF1. E o construtor de CMS em FPdfCms.pas casa o OID de assinatura com esses bytes por TDerWriter.AlgIdWithParams, que substitui por NULL quando os parâmetros são nil; é por isso que psRsaPkcs1v15 e psEcdsa simplesmente devolvem 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 carrega um bloco de parâmetros de verdade. Os bytes resultantes para o perfil SHA-256 são fixos e curtos o bastante para conferir a olho: uma SEQUENCE externa 30 34 segurando 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 aquele mesmo AlgorithmIdentifier SHA-256, e A2 03 02 01 20 para o salt. Se um dump do seu signatureAlgorithm mostrar 02 01 20 no nível de cima da SEQUENCE de parâmetros em vez de dentro de um A2, você está olhando a codificação da 3.114.19
Por que a suíte de testes não pegou um AlgorithmIdentifier malformado?
Porque os testes de PAdES conduzem o construtor de CMS por um assinante falso que reporta sha256WithRSAEncryption e devolve nil de GetSignatureAlgorithmParams, então o bloco de parâmetros PSS nunca era construído num teste. É um projeto razoável para testes que precisam rodar sem repositório de certificados, sem Keychain ou sem token, e tem um ponto cego com forma precisa: tudo o que só um backend de verdade produz só é exercitado por um backend de verdade. A segunda camada é mais interessante. O PDFium Component também coloca o AlgorithmIdentifier da assinatura, parâmetros incluídos, dentro do atributo assinado cmsAlgorithmProtection da RFC 6211, e um verificador compara essa cópia com o signatureAlgorithm externo. As duas cópias vinham da mesma chamada, então batiam perfeitamente e toda checagem interna de consistência passava. A codificação era autoconsistente e errada — a categoria de bug que nenhuma comparação de uma estrutura com ela mesma consegue revelar — e a mesma lição com outra estrutura está em signedAttrs do CMS e ordenação SET OF em DER, onde um SET com hash numa ordem e emitido em outra parecia certo até um verificador estrangeiro refazer o hash
O que pega essa classe de bug é um decodificador que não foi escrito pelo autor do codificador, rodado contra a saída real do backend real. O caminho de verificação do Windows no PDFium Component passa pela CryptoAPI e não pelo leitor da própria biblioteca, e foi uma assinatura PSS rejeitada ali que levou de volta aos parâmetros. Todo ASN.1 que uma implementação emite para outras implementações lerem merece pelo menos um round trip por um decodificador que ela não controla, e quanto mais defaults e tags a estrutura tiver, mais vale esse round trip
Onde isso se encaixa no resto da história do PSS
Esta correção é independente dos outros dois lugares em que o PSS pode dar errado numa assinatura PAdES, e mantê-los separados encurta a depuração. O backend do macOS pode descobrir que uma chave específica ou um sistema mais antigo recusa PSS e fazer downgrade para PKCS#1 v1.5, e o AlgorithmIdentifier precisa acompanhar o downgrade; isso é uma questão de capacidade, coberta em assinar PAdES com uma identidade do Keychain do macOS. O backend PKCS#11 pode entregar ao token um CK_RSA_PKCS_PSS_PARAMS cujo layout o token lê de outra forma por causa de uma incompatibilidade de largura de inteiro; isso é uma questão de ABI, coberta em CK_ULONG e a armadilha de empacotamento do PKCS#11. Este artigo é sobre a terceira falha, em que a chave estava disposta, o token calculou os bytes certos, e o DER que descrevia o resultado não estava em conformidade com a RFC 4055 §3.1
Um assinante que se declara PSS assume uma obrigação que o assinante v1.5 nunca teve: descrever os próprios parâmetros numa forma que outra implementação decodifique para os mesmos três valores. A RFC 4055 §3.1 fixa as tags, a X.690 §11.5 fixa quais campos podem aparecer, e a ETSI TS 119 312 §7 fixa os valores que vale escolher. Os três backends do componente PDFium para Delphi vêm como código-fonte, então o corpo de GetSignatureAlgorithmParams acima é aquele que você pode ler, dumpar e comparar com o seu próprio verificador em vez de aceitar por confiança