Errores en la validación de firmas XML en Peppol
Las respuestas SMP de Peppol son documentos XML firmados. Validar esas firmas debería ser sencillo: analizar el documento, localizar el elemento Signature, verificar el resumen de referencia y el valor de la firma. En la práctica, falla más a menudo de lo que debería, y el modo de fallo es lo suficientemente sutil como para que puedas pasarte días con él.
La causa raíz casi siempre es la interacción del XML canónico (C14N) con el manejo de prefijos de espacio de nombres. Este artículo explica exactamente qué falla y cómo solucionarlo.
Por qué existe el XML canónico
Un documento XML puede serializarse de múltiples formas que son semánticamente idénticas pero byte a byte diferentes. Los atributos pueden aparecer en cualquier orden. Las declaraciones de espacio de nombres pueden aparecer en cualquier elemento ancestro. El espacio en blanco en el contenido de los elementos puede variar. Cualquiera de estos cambios produce una cadena de bytes diferente y, por tanto, un hash diferente.
El XML canónico (C14N, estándar W3C) define un algoritmo de normalización que transforma cualquier documento XML semánticamente equivalente en una única forma canónica. XMLDSig usa C14N para calcular un hash estable sobre un documento o parte de él.
Si calculas un resumen sobre XML canonicalizado, y luego alguien reserializa el documento antes de que lo verifiques, la versión reserializada puede producir una forma canónica diferente, un hash diferente y un fallo de verificación, aunque el documento sea semánticamente idéntico.
El problema de la firma SMP de Peppol
Cuando un Access Point firma una respuesta SMP, calcula:
- Canonicalizar el elemento
ServiceInformation(o el documento completo, dependiendo de la URI de referencia) - Calcular SHA-256 sobre los bytes canónicos
- Almacenar el resumen en
Reference/DigestValue - Firmar el elemento
SignedInfocon la clave privada del Access Point
Cuando recuperas la respuesta SMP por HTTP, pueden haber ocurrido varias cosas desde la firma:
- El servidor SMP puede haber cargado el documento desde una base de datos y lo haberlo reserializado
- La capa HTTP puede haber reparsado y regenerado el XML
- El framework SOAP/HTTP puede haber normalizado el espacio en blanco
Cualquiera de estas cosas puede cambiar la forma canónica que calculas durante la verificación. Aunque el algoritmo de resumen y la URI de referencia sean correctos, los bytes que canonicalizas son diferentes de los que canonicalizó el firmante.
Promoción de prefijos de espacio de nombres
La causa más común es la promoción de prefijos de espacio de nombres.
Un servidor SMP puede firmar un documento que tiene declaraciones de espacio de nombres en elementos hijos:
<ServiceMetadata>
<ServiceInformation xmlns:bdxr="http://docs.oasis-open.org/bdxr/ns/SMP/2016/05">
...
</ServiceInformation>
<Signature>...</Signature>
</ServiceMetadata>
Cuando se aplica C14N a ServiceInformation, la declaración de espacio de nombres se incluye en la forma canónica porque está en ámbito sobre ese elemento.
Cuando el servidor SMP reserializa para la entrega, muchas bibliotecas XML mueven las declaraciones de espacio de nombres al elemento raíz:
<ServiceMetadata xmlns:bdxr="http://docs.oasis-open.org/bdxr/ns/SMP/2016/05">
<ServiceInformation>
...
</ServiceInformation>
<Signature>...</Signature>
</ServiceMetadata>
Ahora cuando aplicas C14N a ServiceInformation, el espacio de nombres está declarado en la raíz, no en ServiceInformation. Bajo C14N exclusivo (http://www.w3.org/2001/10/xml-exc-c14n#), las declaraciones de espacio de nombres que no se utilizan visiblemente en el elemento que se está canonicalizando se excluyen. El espacio de nombres ya no está en ServiceInformation; está en ServiceMetadata. La salida C14N cambia. El resumen ya no coincide.
Prefijos de espacio de nombres dinámicos (ns1, ns2, …)
Una segunda clase de problema: algunas implementaciones SMP usan prefijos de espacio de nombres generados dinámicamente. En lugar de xmlns:bdxr, emiten xmlns:ns1, xmlns:ns2, etc. Estos prefijos se asignan en el momento de la serialización y pueden diferir entre el paso de firma y el de entrega.
Bajo C14N inclusivo, los enlaces de prefijos de espacio de nombres se incluyen en la forma canónica. Si el prefijo cambia de bdxr: a ns1:, la forma canónica cambia y el resumen falla.
Bajo C14N exclusivo, los enlaces de prefijos de espacio de nombres para prefijos no usados en el elemento que se canonicaliza se excluyen. Pero si el propio prefijo cambia (el enlace en sí es diferente), los bytes canónicos de los elementos que usan ese prefijo cambian.
La solución es normalizar los prefijos de espacio de nombres antes de verificar. Esto requiere releer el subárbol firmado con enlaces de prefijos conocidos y estables. No es posible hacer esto y también verificar la firma original; hay que verificar contra lo que realmente fue firmado, lo que significa trabajar con los bytes brutos que se firmaron.
El problema de SignedXml en .NET
La clase SignedXml de .NET tiene una limitación específica: resuelve las referencias relativas al documento en memoria. Si cargas la respuesta SMP en un XmlDocument, las declaraciones de espacio de nombres pueden ser movidas por el analizador XML antes de que SignedXml las vea.
La solución en .NET:
var doc = new XmlDocument
{
PreserveWhitespace = true // critical
};
doc.Load(responseStream); // load from raw bytes, not from a re-serialized string
PreserveWhitespace = true impide que el analizador XML normalice los nodos de texto. Más importante aún, usar Load() desde el flujo bruto (en lugar de una cadena que ya fue analizada y reemitida) evita el paso de reserialización que promueve las declaraciones de espacio de nombres.
No hagas esto:
var xml = await response.Content.ReadAsStringAsync();
doc.LoadXml(xml); // string parsing may already have re-serialized
Haz esto en su lugar:
using var stream = await response.Content.ReadAsStreamAsync();
doc.Load(stream); // raw bytes, no intermediate string
Luego verifica con SignedXml:
var signedXml = new SignedXml(doc);
var sigElement = doc.GetElementsByTagName("Signature")[0] as XmlElement
?? throw new InvalidOperationException("No Signature element found");
signedXml.LoadXml(sigElement);
// Resolve the signing certificate from the SMP KeyInfo
var cert = GetCertificateFromKeyInfo(signedXml);
if (!signedXml.CheckSignature(cert, verifySignatureOnly: false))
throw new SecurityException("SMP response signature verification failed");
verifySignatureOnly: false significa que .NET también valida los resúmenes de referencia, no solo la firma sobre SignedInfo. Quieres ambas comprobaciones.
Cadena de confianza del certificado
Verificar que la firma es matemáticamente válida no es suficiente. También necesitas verificar que el certificado de firma fue emitido por el ancla de confianza Peppol PKI.
OpenPEPPOL publica un almacén de confianza (certificado SML, raíces de certificado SMP) en la PKI de producción y de prueba. Carga el almacén de confianza y verifica la cadena de certificados:
static bool IsInPeppolTrustChain(X509Certificate2 cert)
{
var chain = new X509Chain();
chain.ChainPolicy.TrustMode = X509ChainTrustMode.CustomRootTrust;
chain.ChainPolicy.CustomTrustStore.Add(peppolRootCert);
chain.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck;
return chain.Build(cert);
}
Una respuesta SMP con una firma válida de un certificado no confiable es un ataque, no una configuración incorrecta. Falla contundentemente.
Manejo de URI de referencia
Las firmas SMP de Peppol suelen usar una URI de referencia vacía (URI=""), lo que significa que la referencia cubre el documento completo. Bajo C14N, eso significa que se canonicaliza el documento completo.
Si la URI de referencia es una expresión XPointer (URI="#ID"), la referencia cubre solo el elemento con ese ID. Esto es más resistente a la promoción de espacios de nombres porque solo se canonicaliza un subárbol, no el documento completo.
Si una firma está fallando y puedes controlar el servidor SMP, prefiere las referencias XPointer sobre la URI vacía. Si no puedes controlar el servidor, trabaja con bytes brutos.
Enfoque de prueba
La forma más fiable de probar la verificación de firmas SMP es contra una respuesta SMP conocidamente correcta guardada como bytes brutos de un participante Peppol real de producción o de prueba. No construyas respuestas de prueba programáticamente; el manejo de espacios de nombres diferirá de lo que producen los servidores SMP reales.
La red de PRUEBA de Peppol (acc.edelivery.tech.ec.europa.eu SML) tiene participantes reales que puedes buscar y de los que puedes recuperar respuestas SMP firmadas reales. Úsalos para las pruebas de integración.
Resumen
Los modos de fallo, por orden de frecuencia:
- Promoción de espacio de nombres: el analizador XML mueve las declaraciones de espacio de nombres a la raíz al cargar, cambiando la salida C14N para el subárbol firmado
- Reserialización: conversión de la respuesta HTTP a una cadena y de vuelta antes de la verificación
- Prefijos de espacio de nombres dinámicos: el servidor SMP usa prefijos diferentes en la firma frente a la entrega
- Falta de
PreserveWhitespace: el analizador XML de .NET normaliza el espacio en blanco, cambiando los hashes de nodos de texto - Cadena de certificados no verificada: la firma es válida pero el certificado no es de la PKI de Peppol
Todos estos son solucionables sin modificar el servidor SMP. Carga desde bytes brutos, establece PreserveWhitespace = true y valida siempre la cadena de confianza del certificado.
SealDoc gestiona internamente el descubrimiento SMP y la verificación de firmas, incluyendo todos los casos extremos anteriores. Si estás construyendo una integración Peppol personalizada y encuentras fallos de firma que no puedes depurar, la API de SealDoc expone los resultados de búsqueda SMP incluyendo el estado de validez de la firma, que puedes comparar con la salida de tu propia implementación.