Un volcado hexadecimal de un signatureAlgorithm RSASSA-PSS que termina en A0 0F … A1 1C … 02 01 20 02 01 01 se ve completo: SHA-256, MGF1, un salt de 32 bytes y un trailer de 1. Un decodificador estricto rechaza ese bloque de parámetros de plano, y uno permisivo lee la longitud del 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 explícita de contexto, así que el salt pertenece dentro de A2, y bajo DER un trailerField igual a su valor por omisión 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 equivoque la segunda, y un verificador que sigue la especificación no tiene forma de distinguir su documento de una falsificación. La mitad de ASN.1 es la que cubre este artículo
¿Por qué rechaza un verificador 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 completamente fijado por sha256WithRSAEncryption, así que sus parámetros son un NULL pelado. 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 las 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 que ocurra cualquier exponenciación modular. Un bloque que parsea distinto es peor. RFC 4055 §3.1 le da a saltLength un valor por omisión de 20, así que un decodificador que nunca encuentra el campo del salt corre EMSA-PSS-VERIFY con un salt de 20 bytes contra una firma calculada con 32. El resultado se reporta como una firma mala, sin nada que apunte a los metadatos. Qué tan estricto es el verificador decide cuál de las dos fallas 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, el etiquetado explícito envuelve cada campo en un TLV de contexto construido — de A0 a A3 — con la codificación universal del propio campo anidada adentro:
| Campo | Etiqueta (byte DER) | DEFAULT | Valor de 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, iguala el valor por omisión |
Las etiquetas están ahí porque cada campo es opcional a través de su valor por omisión. hashAlgorithm y maskGenAlgorithm son ambas SEQUENCEs de AlgorithmIdentifier; sin etiqueta, un decodificador que mira un bloque de parámetros que guarda una sola SEQUENCE no podría decir cuál de las dos era. Con etiquetas, el número de etiqueta nombra al campo sin importar qué vecinos dejó fuera el codificador
Elija un salt igual a la longitud del digest y déjelo ahí. Algunas APIs ofrecen "salt máximo" o "longitud de salt automática", lo que produce un valor que depende del tamaño de la clave; eso es legal bajo RFC 8017, pero el AlgorithmIdentifier entonces tiene que declarar un número que cambia de clave en clave, y un salt de 32 bytes para SHA-256 es lo que ETSI TS 119 312 §7 pide de todos modos. Los backends de firma integrados de PDFium Component todos lo fijan en 32 y pasan el mismo número a la llamada de plataforma: cbSalt en el BCRYPT_PSS_PADDING_INFO que se entrega 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 el 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 constructor de CMS empareja su resultado con el OID de GetSignatureAlgorithmOid a través de TDerWriter.AlgIdWithParams. Usted se topa con este método cuando conecta un firmante propio a través de ConfigurePadesSignerProvider — un HSM de red, un servicio de firma, un driver de smart card que los backends integrados no cubren. La codificación que la mayoría escribe primero etiqueta los dos AlgorithmIdentifiers, porque esos obviamente necesitan envoltorio, y deja los dos enteros pelados:
// Un primer intento común: [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 pelado 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 después se topa con 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 detiene. Uno permisivo se salta el elemento desconocido, jamás encuentra un A2, asigna el salt por omisión de 20, y después 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 en 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 de Windows CNG, Keychain de macOS, PKCS#11 y OpenSSL, escrito aquí como lo llevaría un firmante personalizado:
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 propia clase; 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 iguala 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 lo bastante cortos para revisarlos a simple vista: 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 el salt. El salt que usted escribe en GetSignatureAlgorithmParams debe coincidir con el que su HSM usa de verdad; si el servicio solo expone "PSS con SHA-256" y no le dice la longitud del salt, averigüelo antes de despachar, porque el AlgorithmIdentifier es una declaración sobre eso
¿Por qué se omite trailerField en vez de etiquetarlo como [3]?
X.690 §11.5 dice que un codificador DER no debe codificar un componente cuyo valor iguala su DEFAULT. trailerField tiene DEFAULT trailerFieldBC, el entero 1, y PSS tal como RFC 8017 lo define solo usa ese trailer. Así que la reparación obvia del primer intento — reemplazar W.IntegerOf(1) con W.ContextSpecific(3, W.IntegerOf(1), True) — produce A3 03 02 01 01, que un decodificador BER permisivo acepta y un decodificador DER estricto está en su derecho de 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 un salt de 20 bytes colapsaría el bloque entero en una SEQUENCE vacía, 30 00. Esa forma hace tropezar a quien asume 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 omisión", y NULL no es un valor válido de RSASSA-PSS-params
BER le permite a un codificador incluir un componente con valor por omisión; 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 puede ser objeto de disputa. El bloque de parámetros no puede escapar de DER aquí. Vive 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
Verificar un CMS que usted no construyó
Esa copia dentro de cmsAlgorithmProtection no atrapa 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 contra sí misma pasa. Lo que atrapa el problema es un decodificador que lee el campo de parámetros campo por campo. El InspectPadesSignatureAlgorithm de PDFium Component en FPdfPades.pas hace eso: solo acepta de A0 a A3, cada uno a lo sumo una vez y en orden ascendente, rellena los valores por omisión de RFC 4055 para lo ausente, y reporta los valores efectivos en TPadesRsaPssParameters. Un INTEGER pelado en el bloque deja SignaturePss.Valid en False, y EvaluatePadesSignatureAlgorithm entonces fija PolicyStatus en pcsInvalid. También rechaza un hash PSS que difiera del algoritmo de digest del SignerInfo, que es exactamente lo que produciría una caída permisiva a sha1
Esto importa más con la firma remota, donde un servicio externo devuelve un CMS terminado y CompletePadesRemoteSignature lo incrusta en el PDF preparado. Verifique el blob antes de incrustarlo. Rechazarlo en ese punto cuesta una llamada de API; enterarse desde el validador de un cliente cuesta una ronda de re-firma con quien opere el servicio:
uses
System.SysUtils, FPdfPades;
// Verificación sugerida que usted agrega 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, incluidos los por omisión: un A2 ausente aparece como 20 aquí
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 todavía guarda los valores decodificados para el log
Values := Why;
Result := VerifyCmsAlgorithmProtection(CmsDer, Why);
if Result then
Why := Values; // la verificación de protección limpia Why en caso de éxito
end;
Registre el string 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 una mirada rápida 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 ] para nada. Un INTEGER de nivel superior 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 salir mal en una firma PAdES, y mantenerlas aparte acorta la depuración. En macOS una clave en particular o un sistema más viejo puede no ofrecer PSS, y el backend entonces firma con PKCS#1 v1.5 y declara sha256WithRSAEncryption para que coincida; 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 al que escribió quien llama, 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 autoconsistencia, donde un SET hasheado en un orden y emitido en otro se ve bien hasta que un verificador ajeno recalcula el hash; vea signedAttrs de CMS y el ordenamiento de DER SET OF. Para el ciclo de preparar, firmar remotamente, completar en el que se enchufa la verificació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 PDFium para Delphi envía sus backends de firma como código fuente, así que los cuerpos de GetSignatureAlgorithmParams en las unidades de Windows, macOS, PKCS#11 y OpenSSL están ahí para leerse y volcarse junto a la salida de su propio firmante, byte a byte