PDFium Component versión 3.114.20 corrige la codificación de RSASSA-PSS-params en los tres backends de firma PAdES: Windows CNG, macOS Keychain y PKCS#11. RFC 4055 §3.1 le da a cada campo de RSASSA-PSS-params una etiqueta explícita específica de contexto, de [0] a [3], y los backends emitían saltLength como un INTEGER universal pelado mientras escribían un trailerField igual a su valor por defecto. Los bytes de la firma estuvieron correctos todo el tiempo. El AlgorithmIdentifier que los describe no lo estaba, y eso solo ya alcanza para que un verificador rechace la firma
La parte frustrante es dónde se esconde el bug. Una firma CMS tiene dos mitades: la operación criptográfica y el ASN.1 que le dice al verificador cómo se realizó esa operación. Si la primera está bien y la segunda mal, el resultado es un documento que ninguna herramienta que siga la especificación puede distinguir de una falsificación. Este artículo trata solo de esa segunda mitad: cómo deben etiquetarse los RSASSA-PSS-params, cómo tres backends se equivocaron de la misma manera, y cómo se ve el DER corregido en términos de TDerWriter
¿Por qué rechaza un verificador una firma RSASSA-PSS cuyos bytes son correctos?
Porque RSASSA-PSS es el único esquema RSA en el que el verificador no puede recuperar los parámetros de la firma misma. El padding de PKCS#1 v1.5 queda completamente determinado por el OID sha256WithRSAEncryption, así que sus parámetros son un NULL pelado y no hay nada que hacer mal. PSS está parametrizado por una función de hash, 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 abiertos. Los elige el firmante, los lleva el AlgorithmIdentifier, y el verificador tiene que reproducirlos exactamente antes de que EMSA-PSS-VERIFY pueda siquiera empezar
Así que cuando PDFium Component firma con SHA-256, MGF1 sobre SHA-256 y un salt de 32 bytes, esos tres hechos tienen que sobrevivir a la codificación DER de aquí y a la decodificación DER en otra implementación. Un bloque de parámetros 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, porque RFC 4055 §3.1 le da a saltLength un valor por defecto de 20. Un decodificador que se salta un campo que no reconoce cae en ese valor por defecto, corre EMSA-PSS-VERIFY con un salt de 20 bytes contra una firma calculada con 32, y reporta una firma mala sin la menor pista de que el problema es de metadatos y no de la clave. Los dos desenlaces son los que producía la codificación de la 3.114.19, según cuán estricto fuera el verificador, y ninguno apunta al AlgorithmIdentifier
Qué exige en realidad RFC 4055 §3.1 de RSASSA-PSS-params
RFC 4055 §3.1 define RSASSA-PSS-params como una SEQUENCE de cuatro campos, cada uno con una etiqueta explícita específica de contexto y un 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
// }
El etiquetado explícito en DER significa que cada campo va envuelto en un TLV construido específico de contexto, A0 para [0], A1 para [1], A2 para [2] y A3 para [3], con la codificación universal del valor anidada dentro. Cada campo está etiquetado precisamente porque cada campo es opcional a través de su valor por defecto. Sin etiquetas, un decodificador no podría saber si una SEQUENCE que contiene un solo AlgorithmIdentifier lleva hashAlgorithm o maskGenAlgorithm, ya que ambos son tipos SEQUENCE; con ellas, el número de etiqueta identifica el campo sin importar qué vecinos estén presentes. Los valores que emite PDFium Component siguen el perfil de ETSI TS 119 312 §7, SHA-256, MGF1 con SHA-256 y un salt igual a la longitud del digest, y reflejan exactamente lo que se le dice a cada llamada de firma de la plataforma: un BCRYPT_PSS_PADDING_INFO con cbSalt 32 para NCryptSignHash, un CK_RSA_PKCS_PSS_PARAMS con sLen 32 para el mecanismo de PKCS#11, y el algoritmo PSS de firma de digest SHA-256 en el framework Security
Cómo tres backends cometieron el mismo error
La codificación de la 3.114.19 etiquetaba los dos primeros campos y dejaba los dos últimos pelados, de forma idéntica en TWinCmsSigner, TKeychainCmsSigner y TPkcs11CmsSigner. Esa simetría no es casualidad: los tres implementan la interfaz ICmsSigner de FPdfCms.pas, y sus cuerpos de GetSignatureAlgorithmParams se escribieron con la misma plantilla. La plantilla decía así:
// Antes de la 3.114.20: [0] y [1] etiquetados, [2] y [3] no
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 pelado donde se requería [2] EXPLICIT
W.IntegerOf(1))); // trailerField, igual al DEFAULT, debe estar ausente
Un decodificador que recorre esa SEQUENCE ve A0, lee el algoritmo de hash, ve A1, lee la función de generación de máscara y después se topa con 02 01 20. Eso es un INTEGER universal, y RSASSA-PSS-params no tiene ningún miembro INTEGER sin etiqueta en ninguna parte. Un decodificador estricto se detiene ahí. Uno permisivo se salta el elemento que no reconoce, nunca encuentra un A2, le asigna a saltLength su valor por defecto de 20, y después choca con un segundo INTEGER perdido, 02 01 01, y tiene el mismo problema otra vez. Ninguno de los dos caminos llega a un salt de 32 bytes. Una plantilla compartida es eficiente cuando es correcta y una forma igual de eficiente de equivocarse tres veces cuando no lo es, y por eso el fix aterrizó en las tres unidades en un solo commit y por eso los tres cuerpos de método siguen siendo estructuralmente idénticos después. Un backend futuro debería copiar el bloque de uno de estos en lugar de volver a deducirlo, porque la deducción es exactamente donde se cometió el error
¿Por qué se omite trailerField en vez de etiquetarlo como [3]?
Porque X.690 §11.5 dice que un codificador DER no debe codificar un componente cuyo valor sea igual a su DEFAULT, y trailerField tiene DEFAULT trailerFieldBC, que es el entero 1. La corrección obvia al código viejo, reemplazar el W.IntegerOf(1) pelado por W.ContextSpecific(3, W.IntegerOf(1), True), produce un bloque que un decodificador BER permisivo acepta y que un decodificador DER estricto tiene derecho a rechazar. El valor no está mal. Su presencia sí. La misma regla explica por qué los otros tres campos sí están presentes: SHA-256 no es el sha1 por defecto, MGF1 con SHA-256 no es el mgf1SHA1 por defecto, y 32 no es el 20 por defecto. Si el backend hubiera estado firmando con SHA-1 y un salt de 20 bytes, RFC 4055 §3.1 colapsaría los parámetros a una SEQUENCE vacía, 30 00, y esa SEQUENCE vacía, y no NULL, es lo que espera un verificador. PDFium Component nunca emite esa forma porque nunca firma con esos valores, pero es el caso que atrapa a quien asuma que "sin parámetros" siempre se escribe 05 00
Esta es la distinción entre DER y BER que importa específicamente para las firmas. BER permite que un codificador incluya 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 es una firma que se puede discutir. Todo lo que va dentro de los signedAttrs de CMS es DER por ese motivo, y el bloque de parámetros viaja dentro de signedAttrs a través del atributo cmsAlgorithmProtection además de en el signatureAlgorithm externo, así que no recibe ninguna excepción
La codificación corregida con TDerWriter
PDFium Component ahora arma los parámetros con tres llamadas a TDerWriter.ContextSpecific de FPdfAsn1.pas, una por campo no predeterminado, cada una con el argumento Constructed puesto en True para producir el envoltorio de etiqueta explícita, y ninguna línea para el campo trailer. Este es el cuerpo de TWinCmsSigner.GetSignatureAlgorithmParams con los OIDs escritos; las unidades de Keychain y PKCS#11 escriben los mismos valores como OID_SHA256, OID_MGF1 y OID_RSASSA_PSS:
function TWinCmsSigner.GetSignatureAlgorithmParams: TBytes;
var
W: TDerWriter;
begin
if FPaddingScheme = psRsaPss then
begin
W := TDerWriter.Create;
try
// RFC 4055 3.1 etiqueta los cuatro campos. saltLength es [2]; un
// INTEGER pelado aquí se lee como el inicio de otro campo. trailerField
// es [3] con DEFAULT 1, y X.690 11.5 prohíbe codificar un valor
// igual al default, así que se omite 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 y ECDSA: AlgIdWithParams escribe NULL
end;
Dos detalles de la maquinaria que rodea esto importan. TDerWriter.AlgId produce un AlgorithmIdentifier con parámetros NULL, que es lo que RFC 4055 §2.1 les dice a los codificadores que generen para el hashAlgorithm anidado y para el hash interno de MGF1. Y el constructor de CMS en FPdfCms.pas empareja el OID de firma con estos bytes a través de TDerWriter.AlgIdWithParams, que sustituye NULL cuando los params son nil; por eso psRsaPkcs1v15 y psEcdsa simplemente devuelven nil y nunca se vieron afectados, y por eso 1.2.840.113549.1.1.10, id-RSASSA-PSS, es el único OID de firma de los tres que lleva un bloque de parámetros real. Los bytes resultantes para el perfil SHA-256 son fijos y lo bastante cortos para verificarlos a ojo: una SEQUENCE externa 30 34 que contiene A0 0F alrededor del AlgorithmIdentifier SHA-256 de 15 bytes, A1 1C alrededor del AlgorithmIdentifier MGF1 de 28 bytes cuyos propios parámetros son ese mismo AlgorithmIdentifier SHA-256, y A2 03 02 01 20 para el salt. Si un dump de su signatureAlgorithm muestra 02 01 20 en el nivel superior de la SEQUENCE de params en vez de dentro de un A2, está mirando la codificación de la 3.114.19
¿Por qué la suite de pruebas no atrapó un AlgorithmIdentifier malformado?
Porque las pruebas de PAdES manejan el constructor de CMS a través de un firmante falso que reporta sha256WithRSAEncryption y devuelve nil desde GetSignatureAlgorithmParams, así que el bloque de parámetros de PSS no se construyó nunca en ninguna prueba. Es un diseño razonable para pruebas que deben correr sin un almacén de certificados, sin Keychain y sin token, y tiene un punto ciego con una forma precisa: todo lo que solo produce un backend real, solo lo ejercita un backend real. La segunda capa es más interesante. PDFium Component también coloca el AlgorithmIdentifier de la firma, parámetros incluidos, dentro del atributo firmado cmsAlgorithmProtection de RFC 6211, y un verificador compara esa copia contra el signatureAlgorithm externo. Las dos copias salían de la misma llamada, así que coincidían a la perfección y toda comprobación de consistencia interna pasaba. La codificación era coherente consigo misma y equivocada, la categoría de bug que ninguna cantidad de comparar una estructura contra sí misma puede revelar, y la misma lección con otra estructura se cuenta en signedAttrs de CMS y el ordenamiento DER SET OF, donde un SET hasheado en un orden y emitido en otro parecía bien hasta que un verificador ajeno recalculó el hash
Lo que sí atrapa esta clase de bug es un decodificador que no haya escrito el autor del codificador, corrido contra la salida real del backend real. El camino de verificación en Windows de PDFium Component pasa por CryptoAPI y no por el lector propio de la librería, y una firma PSS rechazada ahí es lo que llevó de vuelta a los parámetros. Todo ASN.1 que una implementación emita para que lo lean otras implementaciones merece al menos un round-trip por un decodificador que no controle, y cuantos más defaults y etiquetas tenga la estructura, más vale ese round-trip
Dónde encaja esto con el resto de la historia de PSS
Este fix es independiente de los otros dos lugares donde PSS puede salir mal en una firma PAdES, y mantenerlos separados acorta el debugging. El backend de macOS puede descubrir que una clave en particular o un sistema más viejo rechaza PSS y bajar a PKCS#1 v1.5, y el AlgorithmIdentifier tiene que seguir esa degradación; eso es una cuestión de capacidad, cubierta en firmar PAdES con una identidad del Keychain de macOS. El backend de PKCS#11 puede entregarle al token un CK_RSA_PKCS_PSS_PARAMS cuyo diseño el token lee distinto por un desajuste de ancho de entero; eso es una cuestión de ABI, cubierta en CK_ULONG y la trampa del empaquetado en PKCS#11. Este artículo trata del tercer fallo, donde la clave estaba dispuesta, el token calculó los bytes correctos, y el DER que describe el resultado no cumplía con RFC 4055 §3.1
Un firmante que declara PSS asume una obligación que el firmante v1.5 nunca tuvo: describir sus propios parámetros en una forma que otra implementación decodifique a los mismos tres valores. RFC 4055 §3.1 fija las etiquetas, X.690 §11.5 fija qué campos pueden aparecer, y ETSI TS 119 312 §7 fija los valores que vale la pena elegir. Los tres backends del componente PDFium Delphi vienen como código fuente, así que el cuerpo de GetSignatureAlgorithmParams de arriba es el que usted puede leer, volcar y comparar contra su propio verificador en vez de aceptarlo por fe