Un volcado hexadecimal de un signatureAlgorithm RSASSA-PSS que termina en A0 0F … A1 1C … 02 01 20 02 01 01 parece completo: SHA-256, MGF1, una salt de 32 bytes y un trailer de 1. Un decodificador estricto rechaza ese bloque de parámetros de plano, y uno flexible lee la longitud de salt como 20. En cualquier caso, una firma cuya aritmética RSA es correcta vuelve inválida. RFC 4055 §3.1 explica por qué: cada campo de RSASSA-PSS-params lleva una etiqueta de contexto explícita, así que la salt va dentro de A2, y bajo DER un trailerField igual a su valor por defecto no debe aparecer en absoluto
Eso le da a una firma CMS dos mitades que pueden fallar por separado. Una es la operación criptográfica; la otra es el ASN.1 que le dice al verificador cómo se realizó la operación. Acierte la primera y falle la segunda, y un verificador que sigue la especificación no tiene manera de distinguir su documento de una falsificación. La mitad ASN.1 es la que cubre este artículo
¿Por qué rechaza un validador una firma RSASSA-PSS cuyos bytes son correctos?
Porque RSASSA-PSS es el único esquema RSA donde el verificador no puede deducir los parámetros a partir del OID del algoritmo. PKCS#1 v1.5 queda totalmente fijado por sha256WithRSAEncryption, así que sus parámetros son un NULL desnudo. PSS está parametrizado por un hash de mensaje, una función de generación de máscara con su propio hash y una longitud de salt, y RFC 8017 §A.2.3 deja los tres al firmante. El AlgorithmIdentifier lleva las elecciones del firmante, y el verificador tiene que reproducirlas exactamente antes de que EMSA-PSS-VERIFY pueda correr
Un bloque que el verificador no puede parsear termina la verificación antes de cualquier exponenciación modular. Uno que parsea de otra manera es peor. RFC 4055 §3.1 da a saltLength un valor por defecto de 20, así que un decodificador que nunca encuentra el campo de salt corre EMSA-PSS-VERIFY con una salt de 20 bytes contra una firma calculada con 32. El resultado se reporta como firma errónea, sin nada que apunte a los metadatos. Lo estricto que sea el verificador decide cuál de los dos fallos usted ve, y ningún mensaje de error menciona el AlgorithmIdentifier
Los cuatro campos de RSASSA-PSS-params y sus etiquetas
RFC 4055 §3.1 define RSASSA-PSS-params como una SEQUENCE de cuatro campos, cada uno con una etiqueta explícita de contexto y un valor DEFAULT. En DER, la etiquetación explícita envuelve cada campo en un TLV de contexto construido — A0 a A3 — con la codificación universal del propio campo anidada dentro:
| Campo | Etiqueta (byte DER) | DEFAULT | Valor ETSI TS 119 312 §7 | ¿Codificado? |
|---|---|---|---|---|
hashAlgorithm | [0] (A0) | sha1 | SHA-256 | Sí |
maskGenAlgorithm | [1] (A1) | mgf1SHA1 | MGF1 con SHA-256 | Sí |
saltLength | [2] (A2) | 20 | 32 (la longitud del digest) | Sí |
trailerField | [3] (A3) | trailerFieldBC (1) | 1 | No, es igual al valor por defecto |
Las etiquetas están porque cada campo es opcional a través de su valor por defecto. hashAlgorithm y maskGenAlgorithm son ambas SEQUENCEs AlgorithmIdentifier; sin etiqueta, un decodificador que mirara un bloque de parámetros que contiene una única SEQUENCE no podría saber cuál de las dos era. Con etiquetas, el número de etiqueta nombra el campo sin importar qué vecinos dejara fuera el codificador
Elija una salt igual a la longitud del digest y déjela ahí. Algunas APIs ofrecen «maximum salt» o «salt length auto», que produce un valor que depende del tamaño de la clave; eso es legal bajo RFC 8017, pero entonces el AlgorithmIdentifier tiene que declarar un número que cambia de clave a clave, y una salt de 32 bytes para SHA-256 es lo que pide ETSI TS 119 312 §7 de todos modos. Los backends de firma incorporados de PDFium Component la fijan todos a 32 y pasan el mismo número a la llamada de plataforma: cbSalt en el BCRYPT_PSS_PADDING_INFO entregado a NCryptSignHash en Windows, sLen en CK_RSA_PKCS_PSS_PARAMS para un token PKCS#11, EVP_PKEY_CTX_set_rsa_pss_saltlen bajo OpenSSL, y la salt de longitud del digest que kSecKeyAlgorithmRSASignatureDigestPSSSHA256 implica en macOS
Escribir GetSignatureAlgorithmParams para su propio ICmsSigner
En PDFium Component, cada backend de firma implementa la interfaz ICmsSigner de FPdfCms.pas, y el bloque de parámetros sale de un método, GetSignatureAlgorithmParams. El builder CMS empareja su resultado con el OID de GetSignatureAlgorithmOid a través de TDerWriter.AlgIdWithParams. Usted se encuentra con este método cuando enchufa un firmante propio mediante ConfigurePadesSignerProvider — un HSM en red, un servicio de firma, un driver de tarjeta inteligente que los backends incorporados no cubren. La codificación que la mayoría escribe primero etiqueta los dos AlgorithmIdentifiers, porque obviamente necesitan envoltura, y deja los dos enteros desnudos:
// Un primer intento habitual: [0] y [1] etiquetados, [2] y [3] no
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 desnudo donde se exige [2] EXPLICIT
W.IntegerOf(1))); // trailerField igual al DEFAULT: debe estar ausente
Recórralo como lo haría un decodificador. Ve A0 y lee el hash, ve A1 y lee la función de generación de máscara, y luego se encuentra 02 01 20, un INTEGER universal. RSASSA-PSS-params no tiene ningún miembro INTEGER sin etiqueta en ninguna parte, así que un decodificador estricto se para. Uno flexible se salta el elemento desconocido, jamás encuentra un A2, asigna la salt por defecto de 20, y luego tropieza con un segundo INTEGER perdido, 02 01 01. Ningún camino termina en 32
La versión conforme usa tres llamadas a TDerWriter.ContextSpecific de FPdfAsn1.pas, una por campo no predeterminado, cada una con Constructed a True para que el resultado sea un envoltorio explícito. No hay línea para el campo trailer. Este es el mismo bloque que emiten los backends Windows CNG, Keychain de macOS, PKCS#11 y OpenSSL, escrito aquí como lo llevaría un firmante propio:
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;
// Su clase propia; los otros cuatro métodos hablan con su HSM y no se muestran
function TMyHsmSigner.GetSignatureAlgorithmParams: TBytes;
var
W: TDerWriter;
begin
if not FUsePss then
Exit(nil); // v1.5 y ECDSA: AlgIdWithParams escribe NULL para nil
W := TDerWriter.Create;
try
// RFC 4055 3.1: [0] hash, [1] MGF1 con su hash, [2] salt.
// [3] trailerField = 1 es igual a su DEFAULT, así que X.690 11.5 lo 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;
TDerWriter.AlgId escribe un AlgorithmIdentifier con parámetros NULL, que es lo que RFC 4055 §2.1 les dice a los codificadores que generen para los identificadores SHA-256 anidados. Los bytes resultantes son cortos para comprobarlos a ojo: un 30 34 exterior, luego A0 0F alrededor del AlgorithmIdentifier SHA-256 de 15 bytes, A1 1C alrededor del AlgorithmIdentifier MGF1 de 28 bytes, y A2 03 02 01 20 para la salt. La salt que usted escriba en GetSignatureAlgorithmParams tiene que coincidir con la que su HSM usa de verdad; si el servicio solo expone «PSS con SHA-256» y no le dice la longitud de salt, averigüelo antes de enviar, porque el AlgorithmIdentifier es una afirmación sobre ella
¿Por qué se omite trailerField en lugar de etiquetarlo como [3]?
X.690 §11.5 dice que un codificador DER no debe codificar un componente cuyo valor sea igual a su DEFAULT. trailerField tiene DEFAULT trailerFieldBC, el entero 1, y PSS tal como lo define RFC 8017 solo usa ese trailer. Así que el arreglo obvio del primer intento — reemplazar W.IntegerOf(1) por W.ContextSpecific(3, W.IntegerOf(1), True) — produce A3 03 02 01 01, que un decodificador BER permisivo acepta y un decodificador DER estricto tiene derecho a rechazar. El valor está bien. Su presencia, no
La misma regla explica por qué los otros tres campos sí aparecen: SHA-256 no es sha1, MGF1 con SHA-256 no es mgf1SHA1, y 32 no es 20. Un firmante que use SHA-1 con una salt de 20 bytes colapsaría el bloque entero a una SEQUENCE vacía, 30 00. Esa forma descoloca a cualquiera que asuma que «sin parámetros» siempre se escribe 05 00; para id-RSASSA-PSS en CMS el campo de parámetros es obligatorio, una SEQUENCE vacía significa «todos los valores por defecto», y NULL no es un valor válido de RSASSA-PSS-params
BER permite a un codificador incluir un componente con valor por defecto; DER lo prohíbe, porque DER existe para que un valor tenga exactamente una codificación, y una firma sobre una estructura con dos codificaciones legales da para discutir. El bloque de parámetros no puede escapar de DER aquí. Se sienta en el signatureAlgorithm exterior del SignerInfo, y se copia a los atributos firmados a través del atributo cmsAlgorithmProtection de RFC 6211, donde cada byte queda cubierto por la firma
Comprobar un CMS que usted no construyó
Esa copia dentro de cmsAlgorithmProtection no caza un bloque mal etiquetado. El atributo y el signatureAlgorithm exterior salen de la misma llamada, así que siempre coinciden, y una comparación de la estructura consigo misma pasa. Lo que caza el problema es un decodificador que lee los parámetros campo a campo. El InspectPadesSignatureAlgorithm de PDFium Component en FPdfPades.pas hace eso: acepta solo A0 a A3, cada uno como mucho una vez y en orden ascendente, rellena los valores por defecto de RFC 4055 para todo lo ausente y reporta los valores efectivos en TPadesRsaPssParameters. Un INTEGER desnudo en el bloque deja SignaturePss.Valid a False, y EvaluatePadesSignatureAlgorithm pone entonces PolicyStatus a pcsInvalid. También rechaza un hash PSS que difiera del algoritmo de digest del SignerInfo, que es exactamente lo que produciría una reserva flexible a sha1
Esto importa más con firma remota, donde un servicio externo devuelve un CMS terminado y CompletePadesRemoteSignature lo incrusta en el PDF preparado. Compruebe el blob antes de incrustarlo. Rechazarlo en ese momento cuesta una llamada a la API; enterarse por el validador de un cliente cuesta otra ronda de firma con quien gestione el servicio:
uses
System.SysUtils, FPdfPades;
// Comprobación sugerida que usted añade 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: deje que el inspector localice el certificado del firmante dentro del 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 efectivos, defaults incluidos: un A2 ausente se muestra aquí como 20
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 sigue con los valores decodificados para el log
Values := Why;
Result := VerifyCmsAlgorithmProtection(CmsDer, Why);
if Result then
Why := Values; // la comprobación de protección limpia Why en caso de éxito
end;
Registre la cadena Why incluso en caso de éxito. Una longitud de salt de 20 junto a un hash SHA-256 es la huella de un bloque de parámetros al que se le perdió el A2, digan lo que digan los logs del propio servicio. Para un vistazo rápido sin código, openssl asn1parse -inform DER -in signature.p7s imprime el árbol; bajo el OID id-RSASSA-PSS usted quiere ver un cont [ 2 ] envolviendo un INTEGER de 20 hex, y ningún cont [ 3 ] en absoluto. Un INTEGER de primer nivel dentro de esa SEQUENCE es la codificación mal etiquetada del primer bloque de código
Dónde encaja esto con el resto de la historia de PSS
La codificación de parámetros es una de tres maneras separadas en que PSS puede torcerse en una firma PAdES, y mantenerlas apartadas acorta la depuración. En macOS una clave concreta o un sistema más viejo puede no ofrecer PSS, y el backend firma entonces con PKCS#1 v1.5 y declara sha256WithRSAEncryption para que case; esa pregunta de capacidad está cubierta en firmar PAdES con una identidad del Keychain de macOS. Un token PKCS#11 puede leer CK_RSA_PKCS_PSS_PARAMS con un ancho de entero distinto del que escribió el llamador, una pregunta de ABI cubierta en CK_ULONG y la trampa de empaquetado de PKCS#11. La tercera es la de arriba: la clave está dispuesta, el token calcula los bytes correctos, y el DER que los describe no sigue RFC 4055 §3.1
El SET de atributos firmados tiene su propia versión del punto ciego de la autoconsistencia, donde un SET hasheado en un orden y emitido en otro parece fino hasta que un verificador ajeno recalcula el hash; vea CMS signedAttrs y la ordenación DER SET OF. Para el ciclo de preparar, firmar en remoto y completar en el que encaja la comprobación de arriba, la firma PAdES remota con un HSM cubre el protocolo de dos fases y lo que el paso de completado rechaza
El componente Delphi PDFium envía sus backends de firma como código fuente, así que los cuerpos GetSignatureAlgorithmParams de las unidades Windows, macOS, PKCS#11 y OpenSSL están ahí para leerlos y volcarlos junto a la salida de su propio firmante, byte a byte