Artículo técnico

Codificar RSASSA-PSS-params según RFC 4055 en PDFium Delphi

PDFium Component versión 3.114.20 arregla la codificación de RSASSA-PSS-params en los tres backends de firma PAdES, Windows CNG, el Keychain de macOS y PKCS#11. RFC 4055 §3.1 da a cada campo de RSASSA-PSS-params una etiqueta explícita de contexto, de [0] a [3], y los backends emitían saltLength como un INTEGER universal desnudo mientras escribían un trailerField igual a su valor por defecto. Los bytes de la firma fueron correctos todo el tiempo. El AlgorithmIdentifier que los describe no, y solo con eso ya basta para que un validador rechace la firma

Lo 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 validador cómo se realizó esa operación. Acierta la primera y falla la segunda, y el resultado es un documento que ninguna herramienta que siga la especificación puede distinguir de una falsificación. Este artículo va solo de esa segunda mitad: cómo deben etiquetarse los RSASSA-PSS-params, cómo tres backends se equivocaron de la misma forma y cómo se ve el DER corregido en términos de TDerWriter

¿Por qué rechaza un validador una firma RSASSA-PSS cuyos bytes son correctos?

Porque RSASSA-PSS es el único esquema RSA en el que el validador no puede recuperar los parámetros de la propia firma. El padding PKCS#1 v1.5 queda determinado por completo por el OID sha256WithRSAEncryption, así que sus parámetros son un NULL pelado y no hay nada que estropear. PSS está parametrizado por una función 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 las tres abiertas. El firmante las elige, el AlgorithmIdentifier las transporta, y el validador tiene que reproducirlas 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 de otra implementación. Un bloque de parámetros que el validador no puede parsear termina la verificación antes de que ocurra ninguna exponenciación modular. Un bloque que parsea de otra manera es peor, porque RFC 4055 §3.1 da a saltLength un valor por defecto de 20. Un decodificador que se salta un campo que no reconoce aterriza en ese default, ejecuta EMSA-PSS-VERIFY con un salt de 20 bytes contra una firma calculada con 32, e informa de una firma mala sin ninguna pista de que el problema son los metadatos y no la clave. Los dos desenlaces son los que producía la codificación de la 3.114.19, según lo estricto que fuera el validador, y ninguno apunta al AlgorithmIdentifier

Qué exige realmente RFC 4055 §3.1 a RSASSA-PSS-params

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:

// RSASSA-PSS-params ::= SEQUENCE {
//   hashAlgorithm      [0] HashAlgorithm      DEFAULT sha1,
//   maskGenAlgorithm   [1] MaskGenAlgorithm   DEFAULT mgf1SHA1,
//   saltLength         [2] INTEGER            DEFAULT 20,
//   trailerField       [3] TrailerField       DEFAULT trailerFieldBC
// }

Etiquetado explícito en DER significa que cada campo va envuelto en un TLV de contexto construido, 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 default. Sin etiquetas, un decodificador no podría decir si una SEQUENCE que contiene un único AlgorithmIdentifier lleva hashAlgorithm o maskGenAlgorithm, ya que ambos son tipos SEQUENCE; con ellas, el número de etiqueta identifica el campo independientemente de 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 plataforma: un BCRYPT_PSS_PADDING_INFO con cbSalt 32 para NCryptSignHash, un CK_RSA_PKCS_PSS_PARAMS con sLen 32 para el mecanismo PKCS#11, y el algoritmo PSS de firma de digest SHA-256 en el framework Security

Diagrama de PDFium Component de RSASSA-PSS-params según RFC 4055: hashAlgorithm A0, maskGenAlgorithm A1 y saltLength A2 llevan etiquetas explícitas de contexto con valores DEFAULT, el perfil ETSI emite SHA-256, MGF1 con SHA-256 y salt 32, y trailerField A3 es igual a trailerFieldBC, así que DER lo omite por completo
Cada campo está etiquetado precisamente porque cada campo es opcional a través de su default, así que el número de etiqueta identifica el campo sin importar qué vecinos deje fuera el codificador

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 desnudos, idénticamente 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 a partir de una sola plantilla. La plantilla era así:

// Antes de 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 desnudo donde se exigía [2] EXPLICIT
  W.IntegerOf(1)));     // trailerField, igual al DEFAULT, debe estar ausente

Un decodificador que recorra esa SEQUENCE ve A0, lee el algoritmo hash, ve A1, lee la función de generación de máscara y luego se topa con 02 01 20. Eso es un INTEGER universal, y RSASSA-PSS-params no tiene ningún miembro INTEGER sin etiquetar en ninguna parte. Un decodificador estricto se para ahí. Uno permisivo se salta el elemento que no reconoce, no encuentra ningún A2, asigna a saltLength su default de 20, y luego topa con un segundo INTEGER suelto, 02 01 01, y vuelve a tener el mismo problema. 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 arreglo entró en las tres unidades en un mismo 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 vez de volver a derivarlo, porque la derivación es exactamente donde se cometió el error

Diagrama de PDFium Component del bug DER de la 3.114.19: tras A0 y A1 los params se topaban con un 02 01 20 desnudo donde corresponde la etiqueta explícita A2, un decodificador estricto se paraba y uno permisivo firmaba con el salt por defecto de 20 bytes, mientras que la 3.114.20 envuelve el salt de 32 bytes en A2
RSASSA-PSS-params no tiene ningún miembro INTEGER sin etiquetar, así que los bytes sueltos eran o un fallo de parseo o una caída silenciosa a la longitud de salt por defecto, y ningún camino llegaba al 32 del firmante

¿Por qué se omite trailerField en lugar 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 antiguo, sustituir el W.IntegerOf(1) desnudo por W.ContextSpecific(3, W.IntegerOf(1), True), da 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 es la razón de que los otros tres campos sí estén: 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 reduciría los parámetros a una SEQUENCE vacía, 30 00, y esa SEQUENCE vacía y no NULL es lo que espera un validador. PDFium Component nunca emite esa forma porque nunca firma con esos valores, pero es el caso que atrapa a quien asume 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 a un codificador incluir un componente con el 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 discutible. Todo lo que hay dentro de los signedAttrs de CMS es DER por ese motivo, y el bloque de parámetros viaja dentro de signedAttrs tanto por el atributo cmsAlgorithmProtection como en el signatureAlgorithm externo, así que no recibe ninguna exención

Diagrama de PDFium Component de la regla X.690 11.5 en el arreglo de PSS: trailerField, igual a su DEFAULT trailerFieldBC, debe seguir ausente porque un envoltorio A3 etiquetado es la codificación que rechaza el DER estricto, mientras que saltLength 32 difiere del default 20 y debe estar presente como A2
DER existe para que un valor tenga exactamente una codificación, y un componente igual a su default ya tiene la codificación más corta que hay, que es no aparecer en absoluto

La codificación corregida con TDerWriter

PDFium Component construye ahora los parámetros con tres llamadas a TDerWriter.ContextSpecific de FPdfAsn1.pas, una por campo no default, cada una con el argumento Constructed puesto a 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 OID 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 desnudo 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 lo rodea importan. TDerWriter.AlgId produce un AlgorithmIdentifier con parámetros NULL, que es lo que RFC 4055 §2.1 dice a los codificadores que generen para el hashAlgorithm anidado y para el hash interno de MGF1. Y el constructor de CMS de FPdfCms.pas empareja el OID de firma con estos bytes mediante TDerWriter.AlgIdWithParams, que sustituye por 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 de verdad. Los bytes resultantes para el perfil SHA-256 son fijos y lo bastante cortos para comprobarlos a ojo: una SEQUENCE exterior 30 34 que contiene A0 0F alrededor del AlgorithmIdentifier de SHA-256 de 15 bytes, A1 1C alrededor del AlgorithmIdentifier de MGF1 de 28 bytes cuyos propios parámetros son ese mismo AlgorithmIdentifier de SHA-256, y A2 03 02 01 20 para el salt. Si un volcado de tu signatureAlgorithm muestra 02 01 20 en el nivel superior de la SEQUENCE de params en vez de dentro de un A2, estás mirando la codificación de la 3.114.19

¿Por qué no cazó la batería de tests un AlgorithmIdentifier mal formado?

Porque los tests de PAdES conducen el constructor de CMS a través de un firmante falso que informa de sha256WithRSAEncryption y devuelve nil desde GetSignatureAlgorithmParams, así que el bloque de parámetros PSS no se construyó nunca en ningún test. Es un diseño razonable para tests que tienen que correr sin 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 coloca también el AlgorithmIdentifier de la firma, parámetros incluidos, dentro del atributo firmado cmsAlgorithmProtection de RFC 6211, y un validador compara esa copia con el signatureAlgorithm externo. Las dos copias venían de la misma llamada, así que coincidían perfectamente y todas las comprobaciones de consistencia interna pasaban. La codificación era coherente consigo misma y errónea, la categoría de bug que ninguna cantidad de comparar una estructura consigo misma puede revelar, y la misma lección con otra estructura se cuenta en los signedAttrs de CMS y la ordenación DER SET OF, donde un SET hasheado en un orden y emitido en otro parecía bien hasta que un validador ajeno recalculó el hash

Lo que sí caza esta clase de bug es un decodificador que no haya escrito el autor del codificador, ejecutado contra la salida real del backend real. La ruta de verificación de Windows en 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. Cualquier ASN.1 que una implementación emita para que lo lean otras implementaciones merece al menos una ida y vuelta por un decodificador que no controle, y cuantos más defaults y etiquetas tenga la estructura, más vale esa ida y vuelta

Dónde encaja esto con el resto de la historia de PSS

Este arreglo es independiente de los otros dos sitios donde PSS puede salir mal en una firma PAdES, y mantenerlos separados acorta la depuración. El backend de macOS puede encontrarse con que una clave concreta o un sistema más antiguo rechaza PSS y degradar a PKCS#1 v1.5, y el AlgorithmIdentifier tiene que seguir a esa degradación; eso es una cuestión de capacidades, 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 cuya disposición el token lee de otra manera por un desajuste de anchura de entero; eso es una cuestión de ABI, cubierta en CK_ULONG y la trampa de empaquetado de PKCS#11. Este artículo va del tercer fallo, en el que la clave estaba dispuesta, el token calculó los bytes correctos y el DER que describía el resultado no cumplía 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 merece la pena elegir. Los tres backends del componente PDFium para Delphi se distribuyen como código fuente, así que el cuerpo de GetSignatureAlgorithmParams de arriba es el que puedes leer, volcar y comparar con tu propio validador en vez de darlo por bueno