Artículo técnico

Ordenación DER SET OF de signedAttrs CMS en PDFium Component

PDFium Component construye el bloque signedAttrs de CMS a través de TDerWriter.SetOfSorted, porque la cláusula 11.6 de X.690 exige que los miembros de un SET OF se emitan en orden ascendente de sus propias codificaciones DER. Móntalos en orden de construcción y obtienes firmas que tu propio verificador acepta y que rechaza cualquier validador PAdES de terceros

Esa última frase es la razón completa de que este defecto sobreviviera a una suite de regresión entera. El camino de firma estaba cubierto, el de verificación estaba cubierto, el round trip salía verde en todas las plataformas, y el resultado seguía sin ser DER válido. Una prueba que firma con tu escritor y verifica con tu lector no puede detectar una desviación de las reglas de codificación, porque las dos mitades comparten la desviación

Qué exige realmente la cláusula 11.6 de X.690

Los miembros de un valor SET OF deben estar ordenados, y la clave de ordenación es la codificación DER completa de cada miembro, comparada como cadena de octetos sin signo, con la codificación más corta primero cuando una es prefijo de la otra. Es una restricción canónica, no una sugerencia: DER admite exactamente una codificación por valor, así que un SET OF desordenado no es una codificación reordenada del mismo valor, es que no es una codificación DER en absoluto. Como los tags ASN.1 van primero en un TLV, la comparación suele resolverse dentro de los dos primeros octetos, y como cada miembro de una lista de atributos de CMS empieza con 30, el tag de SEQUENCE, el desempate cae en el octeto de longitud. Por eso el orden exigido parece casi ordenar los atributos por tamaño, algo que conviene saber cuando estás mirando un volcado hexadecimal preguntándote por qué el orden parece arbitrario

Diagrama del artículo de PDFium Component que muestra cómo la cláusula 11.6 de X.690 ordena el SET OF signedAttrs de CMS: BuildSignedAttrs añade content-type, message-digest, signing-certificate-v2 y cms-algorithm-protection en orden de construcción, mientras que el DER conforme ordena los cuatro miembros por sus codificaciones completas, 30 18, 30 2D, 30 2F, 30 37
El orden de construcción y el orden codificado discrepan desde la primera firma, porque los cuatro miembros llevan el tag de SEQUENCE 30 y el octeto de longitud decide la comparación

¿Por qué el conjunto mínimo de atributos ya sale desordenado?

Porque el orden natural de construcción y el orden codificado discrepan desde la primera firma que produces, sin que intervenga ningún atributo opcional. BuildSignedAttrs en FPdfCms.pas añade content-type, luego message-digest, luego signing-certificate-v2 y luego cms-algorithm-protection. Con digests SHA-256 y un algoritmo de firma RSA, los cuatro TLV de atributo empiezan con estos bytes

// Orden de construcción producido por BuildSignedAttrs
//   content-type              30 18   (24 octetos de contenido)
//   message-digest            30 2F   (47)
//   signing-certificate-v2    30 37   (55)
//   cms-algorithm-protection  30 2D   (45)
//
// Orden DER exigido por X.690 11.6
//   30 18  content-type
//   30 2D  cms-algorithm-protection
//   30 2F  message-digest
//   30 37  signing-certificate-v2

Dos de los cuatro atributos se mueven. cms-algorithm-protection en 30 2D tiene que preceder a signing-certificate-v2 en 30 37, y también tiene que preceder a message-digest en 30 2F. Así que el conjunto de atributos PAdES B-B conforme más pequeño que PDFium Component puede emitir ya salía mal ordenado con el código antiguo, antes de que nadie añada signing-time, commitment-type-indication o un signature-policy-identifier. No existía ninguna configuración en la que el bug no se disparara

¿Por qué pasan nuestras pruebas y fallan todos los validadores externos?

Porque las dos familias de verificadores calculan el digest del mensaje sobre bytes distintos. La cláusula 5.4 de RFC 5652 dice que la entrada de la operación de firma es la codificación DER del valor SignedAttributes, con el tag IMPLICIT [0] sustituido por el tag SET 31, y la frase que lo decide todo es la codificación DER, no los octetos que recibiste. El verificador de la plataforma Windows al que se llega a través de CryptVerifyDetachedMessageSignature toma los octetos recibidos, cambia el tag inicial y los hashea tal como llegaron, así que el orden nunca entra en juego y un SET OF desordenado produce un digest exactamente igual al que calculó el firmante. OpenSSL y BouncyCastle, sobre los que están construidos los validadores europeos más desplegados, parsean el conjunto de atributos a estructuras y re-codifican antes de digerir. La re-codificación aplica la ordenación, el digest ya no coincide con el que se firmó, y la firma se reporta como rota en lugar de como mal codificada, lo que te manda a buscar entre material de clave y cadenas de certificados un defecto que vive en una función de comparación

Diagrama de PDFium Component de la divergencia de verificadores detrás del bug del SET OF ordenado: el firmante hashea la codificación DER con el tag SET 31, el camino de Windows CryptVerifyDetachedMessageSignature digiere los octetos recibidos así que el orden nunca importa y una firma desordenada pasa, mientras que OpenSSL y BouncyCastle re-codifican, aplican la ordenación de X.690 y reportan la firma como rota
CryptVerifyDetachedMessageSignature digiere los octetos tal como llegaron, así que un conjunto desordenado verifica, mientras que OpenSSL y BouncyCastle re-codifican antes de digerir y reportan la firma como rota en lugar de como mal codificada

Generaliza más allá de este bug, porque esa es la parte cara. Un round trip del mismo origen es inmune a cualquier desviación de una especificación de interoperabilidad que compartan las dos mitades de tu código; demuestra que tu escritor y tu lector se ponen de acuerdo entre sí, que no es la propiedad que le importa a nadie. Para cualquier cosa que salga del proceso como formato de red, la única regresión con sentido es la que fija bytes contra la especificación o la que ejecuta una implementación ajena sobre tu salida. El backend de verificación CMS con OpenSSL se gana su sitio aquí por una segunda razón más allá de la portabilidad: es un lector genuinamente independiente de tu propio DER

El arreglo, y dónde ya existía

El escritor DER tenía la variante ordenada desde siempre. TDerWriter en FPdfAsn1.pas expone tanto SetOf, que envuelve un buffer de contenido pre-concatenado con un tag SET y preserva el orden que le dieron, como SetOfSorted, que recibe los miembros individualmente como un TDerByteArrays y los ordena por fusión según sus codificaciones antes de envolverlos. El código de MAC de PDF en FPdfMac.pas ya construía su conjunto de atributos autenticados con SetOfSorted; solo el firmante de CMS no lo hacía. Mantener los miembros como array hasta la llamada de envoltura es la mitad estructural del arreglo, porque en cuanto los atributos se concatenan en un solo buffer la información de orden desaparece y ningún cuidado posterior puede restaurarla. Una API de escritor que solo ofrece SetOf(Content: TBytes) para SET OF es una API que invita al bug; la sobrecarga que toma el array es lo que hace expresable lo correcto

Diagrama del par de API de TDerWriter en PDFium Component: SetOf envuelve un buffer TBytes pre-concatenado y preserva el orden de miembros que le dieron, produciendo una salida que no es DER, mientras que SetOfSorted mantiene los miembros como un array TDerByteArrays, los ordena por fusión según sus codificaciones completas y envuelve un SET OF conforme
En cuanto los atributos se concatenan en un solo buffer la información de orden desaparece, así que la sobrecarga SetOfSorted que toma el array es lo que hace expresable lo correcto en el punto de codificación
// Antes: los miembros conservan el orden en que AddAttr los añadió
Result:= W.SetOf(ConcatMany(AttrList));

// Después: ordenación de X.690 11.6 aplicada en el punto de codificación
Result:= W.SetOfSorted(AttrList);

El SET OF se ordena, el SEQUENCE OF no

Esta es la distinción que más se confunde, y darle la vuelta es tan dañino como ignorarla. Un SEQUENCE OF es una colección ordenada: su orden es parte del valor y nunca debes ordenarlo. Un SET OF es desordenado como valor, así que DER impone un orden canónico para que la codificación sea única. Ordenar un SEQUENCE OF corrompe datos; dejar un SET OF desordenado produce algo que no es DER

  • SET OF, hay que ordenarlo: SignedAttributes y UnsignedAttributes en SignerInfo, el conjunto digestAlgorithms, los conjuntos certificates y crls en SignedData, el conjunto signerInfos, el conjunto de valores dentro de cada Attribute individual, y el conjunto de atributos dentro de cada RelativeDistinguishedName de un Name X.501
  • SEQUENCE OF, nunca se ordena: la lista de RDN que forma un Name, el campo certs de SigningCertificateV2, las listas de extensiones y las listas de general-name que contienen

Un RDN es un SET OF metido dentro de un SEQUENCE OF un nivel más arriba, así que un nombre distinguido contiene ambas reglas en dos niveles de anidamiento. Esa disposición también explica por qué los conjuntos de un solo miembro son tan indulgentes: un conjunto con un elemento está trivialmente ordenado, que es por lo que los conjuntos de valores de atributo construidos con W.SetOf dentro de BuildSignedAttrs eran correctos tal como estaban y no necesitaron cambios. El bug vive solo donde un conjunto tiene de verdad dos o más miembros, y los RDN multivaluados son tan raros que la mayoría de codificadores de nombres se salen con la misma metedura durante años

¿Cómo evitas que esto vuelva?

Haz assert sobre bytes, no sobre veredictos. La comprobación duradera más barata es una auto-auditoría que recorre su propia salida: parsea los signedAttrs emitidos, compara cada miembro con el anterior con la misma comparación de cadenas de octetos sin signo que usa el escritor, y falla el build si algún par está desordenado. Se ejecuta en microsegundos, no necesita ninguna librería externa y, a diferencia de un round trip de verificación, prueba la propiedad que la especificación declara de verdad

// Recorre los miembros de un SET OF y confirma el orden de X.690 11.6.
// Members es la lista entregada a SetOfSorted, o el resultado de
// re-parsear un bloque 31 xx / A0 xx emitido
function SetOfIsSorted(const Members: TDerByteArrays): Boolean;
var
  I: Integer;
begin
  for I:= 1 to Length(Members)- 1 do
    if CompareDerBytes(Members[I- 1], Members[I])> 0 then
      Exit(False);
  Result:= True;
end;

// En la prueba de firma, no te quedes en "nuestro verificador dijo válido".
// Fija los bytes que hasheó el firmante
Attrs:= BuildSignedAttrs(W, Digest, CertHash, DigestOid, SigOid, nil, Options);
Assert(Attrs[0]= $31, 'signedAttrs must carry the SET tag when hashed');
Assert(SetOfIsSorted(SplitSetOfMembers(Attrs)), 'signedAttrs not in DER order');

Vale la pena enunciar dos límites sin rodeos. Ordenar signedAttrs no hace interoperable una firma por sí solo, porque todo lo demás del contenedor también tiene que aguantar y las razones por las que los validadores rechazan firmas PAdES por lo demás sólidas son variadas como para llenar su propia discusión. Y un SET OF ordenado es una propiedad de lo que escribes, no de lo que lees: PDFium Component sigue teniendo que aceptar una firma entrante cuyos signedAttrs llegan desordenados, porque esos archivos existen en la naturaleza y la cláusula 5.4 de RFC 5652 le dice al verificador que re-codifique en lugar de rechazar. Estricto a la salida, tolerante a la entrada, como siempre. El escritor DER, el builder de CMS y el camino de firma descrito en el paseo por PAdES B-B viajan todos como código Pascal legible con el componente PDFium de Delphi, que es de lo que se trata al distribuir el código fuente de una librería de firma: cuando un validador en algún lugar de Europa no está de acuerdo con tu documento, puedes leer los bytes exactos que emitió tu código y la cláusula exacta que violan, en lugar de abrir un ticket y esperar