Um hex dump de um signatureAlgorithm RSASSA-PSS que termina em A0 0F … A1 1C … 02 01 20 02 01 01 parece completo: SHA-256, MGF1, um salt de 32 bytes e um trailer de 1. Um decodificador rigoroso rejeita aquele bloco de parâmetros de cara, e um tolerante lê o comprimento do salt como 20. De qualquer forma, uma assinatura cuja aritmética RSA está correta volta como inválida. A RFC 4055 §3.1 explica por quê: todo campo de RSASSA-PSS-params carrega uma tag de contexto explícita, então o salt pertence a dentro do A2, e sob DER um trailerField igual ao padrão dele não pode aparecer de forma nenhuma
Isso dá a uma assinatura CMS duas metades que podem falhar de forma independente. Uma é a operação criptográfica; a outra é o ASN.1 que diz ao verificador como a operação foi feita. Acerte a primeira e erre a segunda, e um verificador que segue a spec não tem como distinguir o seu documento de uma falsificação. A metade ASN.1 é a que este artigo cobre
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 deduzir os parâmetros a partir do OID do algoritmo. PKCS#1 v1.5 fica totalmente cravado por sha256WithRSAEncryption, então os parâmetros dele são um NULL puro. O PSS é parametrizado por um hash de mensagem, uma função de geração de máscara com o hash próprio dela, e um comprimento de salt, e o RFC 8017 §A.2.3 deixa os três a cargo do signatário. O AlgorithmIdentifier carrega as escolhas do signatário, e o verificador tem que reproduzi-las exatamente antes de o EMSA-PSS-VERIFY rodar
Um bloco que o verificador não consegue parsear encerra a verificação antes de qualquer exponenciação modular acontecer. Um bloco que ele parseia de forma diferente é pior. O RFC 4055 §3.1 dá ao saltLength um padrão de 20, então um decodificador que nunca encontra o campo de salt roda o EMSA-PSS-VERIFY com um salt de 20 bytes contra uma assinatura computada com 32. O resultado é reportado como assinatura ruim, sem nada apontando para os metadados. O quão rigoroso o verificador é decide qual das duas falhas você vê, e nenhuma das duas mensagens de erro menciona o AlgorithmIdentifier
Os quatro campos de RSASSA-PSS-params e as tags deles
O RFC 4055 §3.1 define RSASSA-PSS-params como uma SEQUENCE de quatro campos, cada um com uma tag de contexto explícita e um valor DEFAULT. Em DER, explicit tagging embrulha cada campo num TLV de contexto construído — A0 a A3 — com a codificação universal do próprio campo aninhada dentro:
| Campo | Tag (byte DER) | DEFAULT | Valor ETSI TS 119 312 §7 | Codificado? |
|---|---|---|---|---|
hashAlgorithm | [0] (A0) | sha1 | SHA-256 | Sim |
maskGenAlgorithm | [1] (A1) | mgf1SHA1 | MGF1 com SHA-256 | Sim |
saltLength | [2] (A2) | 20 | 32 (o comprimento do digest) | Sim |
trailerField | [3] (A3) | trailerFieldBC (1) | 1 | Não, iguala o padrão |
As tags estão lá porque todo campo é opcional através do padrão dele. hashAlgorithm e maskGenAlgorithm são ambas SEQUENCEs de AlgorithmIdentifier; sem tag, um decodificador olhando para um bloco de parâmetros que guarda uma única SEQUENCE não conseguiria dizer qual das duas era. Com tags, o número da tag nomeia o campo não importa quais vizinhos o codificador deixou de fora
Escolha um salt igual ao comprimento do digest e mantenha-o ali. Algumas APIs oferecem "salt máximo" ou "salt length auto", o que rende um valor que depende do tamanho da chave; isso é legal sob o RFC 8017, mas o AlgorithmIdentifier então precisa declarar um número que muda de chave para chave, e um salt de 32 bytes para SHA-256 é o que o ETSI TS 119 312 §7 pede de qualquer forma. Os backends de assinatura embutidos do PDFium Component todos o fixam em 32 e passam o mesmo número para a chamada de plataforma: o cbSalt na BCRYPT_PSS_PADDING_INFO entregue ao NCryptSignHash no Windows, o sLen na CK_RSA_PKCS_PSS_PARAMS para um token PKCS#11, o EVP_PKEY_CTX_set_rsa_pss_saltlen sob OpenSSL, e o salt de comprimento do digest que o kSecKeyAlgorithmRSASignatureDigestPSSSHA256 implica no macOS
Escrevendo GetSignatureAlgorithmParams para o seu próprio ICmsSigner
No PDFium Component, todo backend de assinatura implementa a interface ICmsSigner de FPdfCms.pas, e o bloco de parâmetros vem de um método, o GetSignatureAlgorithmParams. O construtor de CMS emparelha o resultado dele com o OID de GetSignatureAlgorithmOid através de TDerWriter.AlgIdWithParams. Você encontra esse método ao ligar um signatário seu próprio através do ConfigurePadesSignerProvider — um HSM de rede, um serviço de assinatura, um driver de smart card que os backends embutidos não cobrem. A codificação que a maioria escreve primeiro marca os dois AlgorithmIdentifiers, porque esses obviamente precisam de embrulho, e deixa os dois inteiros nus:
// Uma primeira tentativa comum: [0] e [1] com tag, [2] e [3] sem
Result := W.Sequence(Concat4(
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'),
W.AlgId('2.16.840.1.101.3.4.2.1'))), True),
W.IntegerOf(32), // INTEGER puro onde [2] EXPLICIT é exigido
W.IntegerOf(1))); // trailerField igual ao DEFAULT: precisa estar ausente
Percorra do jeito que um decodificador percorre. Ele vê A0 e lê o hash, vê A1 e lê a função de geração de máscara, então encontra 02 01 20, um INTEGER universal. RSASSA-PSS-params não tem membro INTEGER sem tag em lugar nenhum, então um decodificador rigoroso para. Um tolerante pula o elemento desconhecido, nunca encontra um A2, atribui o salt padrão de 20, e então tropeça num segundo INTEGER perdido, 02 01 01. Nenhum dos caminhos termina em 32
A versão em conformidade usa três chamadas a TDerWriter.ContextSpecific de FPdfAsn1.pas, uma por campo não padrão, cada uma com Constructed em True para que o resultado seja um embrulho explícito. Não há linha para o campo de trailer. Este é o mesmo bloco que os backends Windows CNG, macOS Keychain, PKCS#11 e OpenSSL emitem, escrito aqui como um signatário customizado o carregaria:
uses
System.SysUtils, FPdfSha256, FPdfAsn1, FPdfCms;
type
TMyHsmSigner = class(TInterfacedObject, ICmsSigner)
private
FUsePss: Boolean;
public
function GetDigestAlgorithmOid: AnsiString;
function GetSignatureAlgorithmOid: AnsiString;
function GetSignatureAlgorithmParams: TBytes;
function GetSignerId: TCmsSignerId;
function SignSignedAttrsDigest(const Digest: TSHA256Digest): TBytes;
end;
// Classe sua; os outros quatro métodos conversam com o seu HSM e não são mostrados
function TMyHsmSigner.GetSignatureAlgorithmParams: TBytes;
var
W: TDerWriter;
begin
if not FUsePss then
Exit(nil); // v1.5 e ECDSA: AlgIdWithParams escreve NULL para nil
W := TDerWriter.Create;
try
// RFC 4055 3.1: [0] hash, [1] MGF1 com o hash dele, [2] salt.
// [3] trailerField = 1 iguala o DEFAULT, então X.690 11.5 o omite
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;
O TDerWriter.AlgId escreve um AlgorithmIdentifier com parâmetros NULL, que é o que o RFC 4055 §2.1 manda os codificadores gerar para os identificadores SHA-256 aninhados. Os bytes resultantes são curtos o bastante para conferir a olho: um 30 34 externo, depois A0 0F em volta do AlgorithmIdentifier SHA-256 de 15 bytes, A1 1C em volta do AlgorithmIdentifier MGF1 de 28 bytes, e A2 03 02 01 20 para o salt. O salt que você escreve no GetSignatureAlgorithmParams tem que bater com o que o seu HSM de fato usa; se o serviço só expõe "PSS com SHA-256" e não lhe diz o comprimento do salt, descubra antes de colocar em produção, porque o AlgorithmIdentifier é uma declaração sobre ele
Por que trailerField é omitido em vez de marcado como [3]?
O X.690 §11.5 diz que um codificador DER não deve codificar um componente cujo valor iguala o DEFAULT dele. O trailerField tem DEFAULT trailerFieldBC, o inteiro 1, e o PSS como o RFC 8017 o define usa só esse trailer. Então o reparo óbvio da primeira tentativa — trocar W.IntegerOf(1) por W.ContextSpecific(3, W.IntegerOf(1), True) — produz A3 03 02 01 01, que um decodificador BER permissivo aceita e um decodificador DER rigoroso está no direito de rejeitar. O valor está certo. A presença dele, não
A mesma regra explica por que os outros três campos aparecem: SHA-256 não é sha1, MGF1 com SHA-256 não é mgf1SHA1, e 32 não é 20. Um signatário usando SHA-1 com um salt de 20 bytes colapsaria o bloco inteiro numa SEQUENCE vazia, 30 00. Essa forma derruba qualquer um que assuma que "sem parâmetros" é sempre soletrado 05 00; para id-RSASSA-PSS em CMS o campo de parâmetros é obrigatório, uma SEQUENCE vazia significa "todos os padrões", e NULL não é um valor válido de RSASSA-PSS-params
O BER permite que um codificador inclua um componente com valor padrão; o DER o 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 dá margem a discussão. O bloco de parâmetros não escapa do DER aqui. Ele senta no signatureAlgorithm externo do SignerInfo, e é copiado para os atributos assinados através do atributo cmsAlgorithmProtection do RFC 6211, onde todo byte é coberto pela assinatura
Checando um CMS que você não construiu
Essa cópia dentro do cmsAlgorithmProtection não captura um bloco mal marcado. O atributo e o signatureAlgorithm externo vêm da mesma chamada, então eles sempre batem, e uma comparação da estrutura com ela mesma passa. O que captura o problema é um decodificador que lê o campo de parâmetros campo a campo. O InspectPadesSignatureAlgorithm do PDFium Component em FPdfPades.pas faz isso: ele aceita só A0 a A3, cada um no máximo uma vez e em ordem crescente, preenche os padrões do RFC 4055 para o que estiver ausente, e reporta os valores efetivos em TPadesRsaPssParameters. Um INTEGER puro no bloco deixa SignaturePss.Valid em False, e o EvaluatePadesSignatureAlgorithm então define PolicyStatus como pcsInvalid. Ele também rejeita um hash PSS que difere do algoritmo de digest do SignerInfo, que é exatamente o que uma queda tolerante para sha1 produziria
Isso importa mais com assinatura remota, em que um serviço externo devolve um CMS pronto e o CompletePadesRemoteSignature o embute no PDF preparado. Confira o blob antes de embuti-lo. Rejeitá-lo nesse ponto custa uma chamada de API; descobrir pelo validador de um cliente custa uma rodada de re-assinatura com quem opera o serviço:
uses
System.SysUtils, FPdfPades;
// Checagem sugerida que você mesmo adiciona antes de CompletePadesRemoteSignature
function CheckRemotePssCms(const CmsDer: TBytes; out Why: string): Boolean;
const
OID_RSASSA_PSS = '1.2.840.113549.1.1.10';
var
Info: TPadesSignatureAlgorithmInfo;
Values: string;
begin
Result := False;
Why := '';
// nil: deixe o inspetor localizar o certificado do signatário dentro do CMS
if not InspectPadesSignatureAlgorithm(CmsDer, nil, Info) then
begin
Why := 'CMS SignerInfo could not be parsed';
Exit;
end;
if Info.SignatureAlgorithmOid = OID_RSASSA_PSS then
begin
if not Info.SignaturePss.Valid then
begin
Why := 'RSASSA-PSS-params does not decode as RFC 4055 3.1';
Exit;
end;
// Valores efetivos, padrões incluídos: um A2 ausente aparece como 20 aqui
Why := Format('hash %s, MGF hash %s, salt %d', [
string(Info.SignaturePss.HashAlgorithmOid),
string(Info.SignaturePss.MaskGenHashAlgorithmOid),
Info.SignaturePss.SaltLength]);
end;
if Info.PolicyStatus = pcsInvalid then
Exit; // Why ainda guarda os valores decodificados para o log
Values := Why;
Result := VerifyCmsAlgorithmProtection(CmsDer, Why);
if Result then
Why := Values; // a checagem de proteção limpa o Why no sucesso
end;
Registre a string Why mesmo no sucesso. Um comprimento de salt de 20 ao lado de um hash SHA-256 é a impressão digital de um bloco de parâmetros cujo A2 sumiu, digam o que disserem os logs do próprio serviço. Para uma olhada rápida sem código, o openssl asn1parse -inform DER -in signature.p7s imprime a árvore; sob o OID id-RSASSA-PSS você quer ver um cont [ 2 ] embrulhando um INTEGER de 20 hex, e nenhum cont [ 3 ] de forma nenhuma. Um INTEGER de nível de topo dentro daquela SEQUENCE é a codificação mal marcada do primeiro bloco de código
Onde isso se encaixa no resto da história do PSS
A codificação de parâmetros é um de três jeitos separados de o PSS dar errado numa assinatura PAdES, e mantê-los separados encurta o debugging. No macOS, uma chave em particular ou um sistema mais velho pode não oferecer PSS, e o backend então assina com PKCS#1 v1.5 e declara sha256WithRSAEncryption para bater; essa pergunta de capacidade está coberta em assinar PAdES com uma identidade do macOS Keychain. Um token PKCS#11 pode ler a CK_RSA_PKCS_PSS_PARAMS com uma largura de inteiro diferente da que o chamador escreveu, uma pergunta de ABI coberta em CK_ULONG e a armadilha de packing do PKCS#11. A terceira é a de cima: a chave está disposta, o token computa os bytes certos, e o DER que os descreve não segue o RFC 4055 §3.1
O SET de atributos assinados tem a própria versão dele do ponto cego de autoconsistência, em que um SET com hash numa ordem e emitido em outra parece bom até um verificador estrangeiro recomputar o hash; veja CMS signedAttrs e ordenação de DER SET OF. Para o ciclo de preparar, assinar remotamente, completar em que a checagem de cima se encaixa, a assinatura PAdES remota com um HSM cobre o protocolo de duas fases e o que o passo de completar recusa
O componente PDFium Delphi distribui os backends de assinatura dele como fonte, então os corpos de GetSignatureAlgorithmParams nas units Windows, macOS, PKCS#11 e OpenSSL estão lá para ler e despejar ao lado da saída do seu próprio signatário, byte por byte