Artículo técnico

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

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

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

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

Los miembros de un SET OF deben estar ordenados, y la clave de orden es la codificación DER completa de cada miembro, comparada como una 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 para un valor dado, así que un SET OF sin ordenar no es una codificación del mismo valor con otro orden, no es una codificación DER en absoluto. Como los tags de ASN.1 van primero en un TLV, la comparación suele resolverse dentro de los primeros dos octetos, y como todo miembro de una lista de atributos CMS empieza con 30, el tag SEQUENCE, el desempate recae en el octeto de longitud. Por eso el orden requerido casi parece un ordenamiento de atributos por tamaño, algo que conviene saber cuando estás mirando un hex dump 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 agrega 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 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 agrega content-type, luego message-digest, luego signing-certificate-v2 y por último cms-algorithm-protection. Con resúmenes SHA-256 y un algoritmo de firma RSA, los cuatro TLV de atributos empiezan con estos bytes

// Orden de construcción producido por BuildSignedAttrs
//   content-type              30 18   (24 content octets)
//   message-digest            30 2F   (47)
//   signing-certificate-v2    30 37   (55)
//   cms-algorithm-protection  30 2D   (45)
//
// Orden DER requerido 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 ir antes que signing-certificate-v2 en 30 37, y también antes que 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 viejo, antes de que nadie agregue signing-time, commitment-type-indication o un signature-policy-identifier. Nunca existió una configuración en la que el bug no se disparara

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

Porque las dos familias de verificadores calculan el resumen 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] reemplazado 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 por CryptVerifyDetachedMessageSignature toma los octetos recibidos, cambia el tag inicial y aplica el hash tal como llegaron, así que el orden nunca entra en juego y un SET OF mal ordenado produce exactamente el resumen que calculó el firmante. OpenSSL y BouncyCastle, que es sobre lo que están construidos los validadores europeos más desplegados, parsean el conjunto de atributos a estructuras y re-codifican antes de calcular el resumen. La re-codificación aplica la ordenación, el resumen ya no coincide con el que se firmó, y la firma se reporta como rota en lugar de mal codificada, lo que te manda a revisar material de claves y cadenas de certificados buscando un defecto que vive en una función de comparación

Diagrama de PDFium Component sobre la divergencia de verificadores detrás del bug del SET OF ordenado: el firmante aplica hash a la codificación DER con el tag SET 31, el camino de CryptVerifyDetachedMessageSignature de Windows digiere los octetos recibidos así que el orden nunca importa y una firma mal ordenada 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 mal ordenado verifica, mientras que OpenSSL y BouncyCastle re-codifican antes de digerir y reportan la firma como rota en lugar de mal codificada

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

La corrección, y dónde ya existía

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

Diagrama del par de API de TDerWriter en PDFium Component: SetOf envuelve un buffer TBytes ya concatenado y preserva el orden de miembros que recibió, produciendo una salida que no es DER, mientras que SetOfSorted mantiene los miembros como un arreglo TDerByteArrays, los ordena por fusión según sus codificaciones completas y envuelve un SET OF conforme
Una vez que los atributos se concatenan en un solo buffer la información de orden desaparece, así que la sobrecarga SetOfSorted que recibe un arreglo es lo que hace expresable lo correcto en el punto de codificación
// Antes: los miembros mantienen el orden en que AddAttr los agregó
Result:= W.SetOf(ConcatMany(AttrList));

// Después: el orden X.690 11.6 aplicado 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 darla vuelta es tan dañino como ignorarla. Un SEQUENCE OF es una colección ordenada: su orden es parte del valor y jamás 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 sin ordenar produce algo que no es DER

  • SET OF, debe ordenarse: 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 compone 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 que vive dentro de un SEQUENCE OF un nivel más arriba, así que un distinguished name contiene ambas reglas en dos niveles de anidamiento. Ese diseño también explica por qué los conjuntos de un solo miembro son tan permisivos: un conjunto con un elemento está ordenado de forma trivial, que es por lo que los conjuntos de valores de atributo construidos con W.SetOf dentro de BuildSignedAttrs estaban bien tal como estaban escritos y no necesitaron cambios. El bug vive solo donde un conjunto realmente tiene dos o más miembros, y los RDN multivaluados son lo bastante raros como para que la mayoría de los codificadores de nombres se salgan con la misma equivocación durante años

¿Cómo evitas que esto vuelva?

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 usando la misma comparación de cadena de octetos sin signo que usa el writer, y falla el build si algún par está fuera de orden. Corre 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 realmente declara

// Recorre los miembros de un SET OF y confirma el orden X.690 11.6.
// Members es la lista entregada a SetOfSorted, o el resultado de
// reanalizar un bloque emitido 31 xx / A0 xx
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 detengas en "nuestro verificador dijo válido".
// Fija los bytes que el firmante hasheó
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');

Dos límites merecen decirse con claridad. Ordenar signedAttrs no hace una firma interoperable 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 lo bastante 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 igual tiene que aceptar una firma entrante cuyos signedAttrs lleguen sin ordenar, 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 en la salida, tolerante en la entrada, como siempre. El writer DER, el constructor CMS y el camino de firma descritos en el recorrido de PAdES B-B viajan como código Pascal legible con el componente PDFium Delphi, que es el punto de distribuir fuente para 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