Ofuscar una aplicación MassTransit sin romper el enrutamiento de mensajes
MassTransit deriva la identidad de un mensaje —su MessageUrn, como urn:message:Contoso.Ordering.Contracts:OrderSubmitted— del espacio de nombres y el nombre del tipo de contrato .NET, y luego la usa para nombrar el exchange o topic del broker y la estampa en la cabecera messageType del envoltorio. El productor y los consumidores, a menudo desplegados por separado, deben coincidir en ella. Si ofuscas después de compilar, renombrar el tipo de contrato o su espacio de nombres cambia el URN, de modo que el productor publica en un exchange distinto y estampa un messageType distinto, y los consumidores existentes dejan de recibir en silencio. Este artículo explica exactamente cómo se construye el URN a partir del tipo, por qué el ensamblado de contratos de mensajes es la superficie frágil, y cómo fijar los espacios de nombres, nombres y propiedades de los contratos con una keep-list de Nebula.NET renombrando consumidores, sagas e internos.
Una aplicación MassTransit es un objetivo de ofuscación tentador: los consumidores, las sagas y las actividades de routing-slip son donde vive tu lógica de negocio, y casi toda puede renombrarse, cifrarse en sus cadenas y virtualizarse sin pensarlo dos veces. Pero MassTransit tiene una costura en la que una pasada ingenua se mete de cabeza, y el fallo es del peor tipo: completamente silencioso. La compilación tiene éxito, el servicio arranca, el productor publica, el broker no reporta ningún error, y el consumidor que funcionaba ayer simplemente nunca se dispara. Sin excepción, sin traza de pila. Los mensajes van a alguna parte; simplemente no van ahí.
La causa es específica de cómo MassTransit decide qué es un mensaje, y en cuanto lo ves la solución es una keep-list corta. Este artículo trata de ese mecanismo: cómo deriva MassTransit exactamente la identidad de un mensaje a partir de su tipo de contrato, por qué esa identidad se escribe tanto en la topología del broker como en el envoltorio del mensaje, y cómo proteger el valioso interior con Nebula.NET sin mover los nombres de los que depende el cable.
TL;DR
- MassTransit deriva el MessageUrn de un mensaje —
urn:message:Namespace:TypeName— del espacio de nombres y el nombre del tipo de contrato .NET. - Ese URN nombra el exchange/topic del broker en el que publica el productor, y se estampa en la cabecera
messageTypedel envoltorio para que los consumidores puedan hacerlo coincidir. - Renombra el tipo de contrato o su espacio de nombres y el URN cambia: el productor publica en un exchange distinto y estampa un
messageTypedistinto, y los consumidores existentes dejan de recibir en silencio. - El ensamblado de contratos de mensajes es la superficie frágil: se comparte entre servicios desplegados por separado. Los consumidores, las sagas y los internos se renombran libremente.
- Pon en la keep-list los espacios de nombres, los nombres de tipo y los nombres de propiedad públicos de los contratos (el cuerpo también se enlaza por nombre de propiedad); ofusca todo lo demás.
Por qué ofuscar una aplicación MassTransit
Los contratos son delgados, pero los consumidores y las sagas son el producto: el scoring de fraude que se ejecuta cuando se envía un pedido, la máquina de estados de liquidación, el recálculo de precios, el flujo de reconciliación. Ese código se ejecuta sin interfaz en un servidor —a menudo uno que administra tu cliente— y el artefacto desplegado es un DLL que cualquiera con acceso a los archivos puede abrir en un decompilador y leer de vuelta como C# limpio. La capa de mensajería es además donde se concentran las constantes sensibles: URLs de endpoints descendentes, credenciales de socios, nombres de colas y cabeceras, secretos de firma. Proteger tu propio código distribuido aquí es exactamente el caso legítimo de la ofuscación: elevar el coste de leer tu lógica de negocio de vuelta desde el binario que entregaste.
Cómo construye MassTransit la identidad de un mensaje
Este es un contrato de mensaje corriente y el consumidor que lo maneja, cada uno en su propio proyecto:
namespace Contoso.Ordering.Contracts;
// The contract. Its namespace + name ARE the message's identity.
public record OrderSubmitted(Guid OrderId, Guid CustomerId, decimal Total);
namespace Contoso.Ordering.Fulfillment.Consumers;
public sealed class OrderSubmittedConsumer : IConsumer<OrderSubmitted>
{
private readonly IFulfillmentService _fulfillment;
public OrderSubmittedConsumer(IFulfillmentService fulfillment)
=> _fulfillment = fulfillment;
public async Task Consume(ConsumeContext<OrderSubmitted> context)
{
// proprietary — the reason this service exists
await _fulfillment.Reserve(context.Message.OrderId, context.Message.Total);
}
}
El productor publica por tipo:
await bus.Publish(new OrderSubmitted(orderId, customerId, 149.95m));
Nada en esa llamada a Publish nombra un exchange ni una routing key. MassTransit lo deduce del tipo. Para el tipo Contoso.Ordering.Contracts.OrderSubmitted calcula un MessageUrn:
urn:message:Contoso.Ordering.Contracts:OrderSubmitted
La forma es fija: urn:message: + el espacio de nombres del tipo + : + el nombre del tipo. Esa única cadena gobierna dos cosas distintas, en dos despliegues distintos.
Primero, la topología del broker. El formateador de nombres de entidad por defecto toma el URN, descarta el prefijo urn:message: y usa el resto como nombre del exchange (RabbitMQ) o del topic (Azure Service Bus):
Contoso.Ordering.Contracts:OrderSubmitted
El productor publica en ese exchange. Un consumidor de OrderSubmitted enlaza una cola a ese exchange. Se encuentran solo porque ambos lados calculan el mismo nombre a partir del mismo tipo.
Segundo, el envoltorio del mensaje. MassTransit envuelve tu mensaje en un envoltorio y escribe el URN en la cabecera messageType —como un array, porque un mensaje puede llevar también las interfaces que implementa y sus tipos base—:
{
"messageId": "7b1e...-...",
"conversationId": "9c22...-...",
"sourceAddress": "rabbitmq://broker/orders-api",
"messageType": [
"urn:message:Contoso.Ordering.Contracts:OrderSubmitted"
],
"message": {
"orderId": "3f2504e0-4f89-41d3-9a0c-0305e82c3301",
"customerId": "b8f9...",
"total": 149.95
}
}
Cuando un consumidor recibe el envoltorio, lee messageType y comprueba si algún URN de ese array coincide con un contrato que consume. Si OrderSubmitted está en la lista, deserializa el objeto message sobre su copia del contrato y llama a Consume. Si no, el mensaje no es para él: va a la cola _skipped.
Fíjate en la segunda dependencia escondida en el objeto message: el cuerpo está indexado por nombre de propiedad —orderId, customerId, total—. El consumidor enlaza esos campos de vuelta sobre OrderId, CustomerId, Total por nombre. Así que el contrato tiene dos superficies de nombre que cruzan el cable: el espacio de nombres + nombre del tipo (el URN, para enrutar y coincidir) y los nombres de sus propiedades públicas (el cuerpo, para el enlace).
Mensajes genéricos y contratos de interfaz
Dos variaciones mantienen la misma regla. Si tu mensaje es genérico —digamos un envoltorio Batch<T>— el URN anida ambos nombres de tipo:
urn:message:Contoso.Ordering.Contracts:Batch[[Contoso.Ordering.Contracts:OrderSubmitted]]
Aparecen tanto el Batch externo como el OrderSubmitted interno, así que deben conservarse ambos espacios de nombres y nombres. Si los contratos de MassTransit se definen como interfaces —un patrón común, con MassTransit generando la implementación concreta en tiempo de ejecución—, el URN deriva del espacio de nombres y el nombre de la interfaz, y los nombres de propiedad provienen de los getters de la interfaz. Conservar el tipo de interfaz y sus miembros lo cubre; el tipo de implementación generado es interno y nunca cruza el cable.
Por qué el ensamblado de contratos es la superficie frágil
La asimetría es la clave de todo. Un consumidor, una saga, una actividad de routing-slip: MassTransit los resuelve dentro de un proceso, desde su contenedor de DI y los registros que configuraste al arrancar. Los busca por identidad de tipo, no por ninguna cadena escrita en un broker o una base de datos. Renombra OrderSubmittedConsumer a a y nada se entera: el registro sigue apuntando al mismo tipo, el contenedor sigue resolviéndolo, la cola a la que está enlazado se nombra según el mensaje, no según el consumidor.
El contrato es lo contrario. Es lo único en lo que dos servicios desplegados de forma independiente deben coincidir, y coinciden a través de una cadena —el URN— que cada uno calcula a partir de su propia copia del tipo. No hay registro compartido, ni negociación; el acuerdo es puramente que ambos lados nombren el tipo igual. En el momento en que la ofuscación renombra el contrato en un solo lado —porque redesplegaste el productor pero no el consumidor, o ejecutaste el ofuscador sobre una solución y no sobre la otra— los URN divergen y los dos servicios dejan de hablarse, sin ningún error en ninguna parte, porque desde el punto de vista del broker todo funciona: está enrutando un mensaje a un exchange, y una cola distinta está enlazada silenciosamente a un exchange distinto.
La auditoría: qué se renombra y qué no
Recorre cada nombre que cruza el cable y la keep-list se escribe sola.
- Los tipos de contrato —
Contoso.Ordering.Contracts.OrderSubmittedy cualquier otro registro o interfaz de mensaje. Su espacio de nombres y su nombre construyen el URN que nombra el exchange y rellenamessageType. Conserva los espacios de nombres y los nombres de tipo. - Las propiedades del contrato —
OrderId,CustomerId,Total. El cuerpomessagedel envoltorio está indexado por nombre de propiedad y el consumidor enlaza de vuelta por nombre. Conserva los nombres de las propiedades públicas. Una regla keep detype-and-memberscubre el tipo y sus propiedades en una línea. - Los tipos de argumento genérico — si un mensaje es
Batch<OrderSubmitted>, tantoBatchcomoOrderSubmittedaparecen en el URN. Conserva todo tipo usado como mensaje o como argumento genérico de un mensaje. - Los consumidores —
OrderSubmittedConsumer, las implementaciones deIConsumer<T>. Se resuelven por registro e identidad de tipo dentro del proceso; nada en el cable los nombra. Renómbralos libremente. - Las sagas y máquinas de estados — la
MassTransitStateMachine, el tipo de instancia de la saga. La saga correlaciona por un valor de propiedad (un GUIDCorrelationId), que es dato, no símbolo, y su estado persistido se indexa por ese valor, no por el nombre de tipo. Renombra la lógica. (Si la saga se persiste y su tipo de instancia se serializa a un almacén por nombre, trata ese almacén exactamente como un contrato y conserva los miembros del tipo de instancia: el mismo razonamiento que cualquier persistencia serializada, cubierto en nuestros artículos sobre EF Core y serialización.) - Los nombres de endpoint/cola —
"orders-api", los nombres de receive-endpoint que pasas como literales de cadena. Son datos que reproduces igual en tiempo de ejecución; el cifrado de cadenas deja intacto su valor en ejecución. No hace falta nada. - Todo lo que los consumidores y sagas invocan —
IFulfillmentService, el scoring, la reconciliación, los ayudantes privados. Nada fuera del proceso los nombra. Renombra, cifra, virtualiza.
Una superficie que conservar, entonces —los tipos de contrato, sus espacios de nombres y sus propiedades públicas— y todo lo demás es terreno libre.
La keep-list
Modela los contratos bajo un único espacio de nombres para que una sola regla los cubra, y nebula.config.json queda así:
{
"schemaVersion": 1,
"inputs": [
"publish/Contoso.Ordering.Fulfillment.dll",
"publish/Contoso.Ordering.Contracts.dll"
],
"outputDirectory": "protected",
"preservePublicApi": false,
"encryptStrings": true,
"controlFlowObfuscation": true,
"renaming": {
"rules": [
{ "pattern": "Contoso.Ordering.Contracts.*", "action": "keep", "scope": "type-and-members" },
{ "pattern": "Contoso.Ordering.*", "action": "rename" }
]
}
}
La primera regla fija cada tipo bajo Contoso.Ordering.Contracts y sus miembros, de modo que cada contrato conserva el espacio de nombres y el nombre a partir de los que se construye el URN y los nombres de propiedad públicos por los que se enlaza el cuerpo —scope: "type-and-members" es lo que preserva OrderId, no solo OrderSubmitted—. Todo lo demás en los ensamblados —consumidores, sagas, servicios, ayudantes— se renombra. El cifrado de cadenas y la ofuscación de flujo de control se aplican en todas partes, incluido dentro del ensamblado de contratos, porque ninguno toca nombres.
Si prefieres no depender de una convención de espacios de nombres, anota los contratos directamente: el atributo viaja con el código y no puede desincronizarse de un patrón:
[Obfuscation(Exclude = true, ApplyToMembers = true)] // keep the URN + property names for routing/binding
public record OrderSubmitted(Guid OrderId, Guid CustomerId, decimal Total);
¿Deberías ofuscar el ensamblado de contratos compartido en absoluto?
A menudo la respuesta más limpia es: no lo hagas, o consérvalo entero. Un ensamblado de contratos es deliberadamente delgado —registros e interfaces, sin lógica propietaria— así que renombrarlo no oculta nada de valor mientras cada nombre renombrado es una oportunidad de romper el enrutamiento o el enlace a través de un servicio que despliegas con otra cadencia. Dos patrones sólidos:
- Deja el proyecto de contratos sin ofuscar. Distribúyelo tal cual y ejecuta Nebula.NET solo sobre los ensamblados de consumidores/sagas. Los nombres de los contratos no son secretos; tus algoritmos sí, y no están en este ensamblado.
- Ofúscalo conservando toda la API pública. Si prefieres un único pipeline uniforme, conserva cada tipo y miembro público en el ensamblado de contratos (la regla
type-and-membersde arriba, opreservePublicApi: trueacotado a ese DLL) y deja que el cifrado de cadenas y el flujo de control sigan aplicándose. Ganas uniformidad y no pierdes nada, porque no había nada que ocultar.
En cualquier caso, la regla es la misma en todos los servicios que referencian los contratos: el espacio de nombres, el nombre de tipo y las propiedades públicas deben ser idénticos byte a byte allí donde se calcule el URN. Si dos servicios distribuyen su propia copia de los contratos, ambas copias deben conservarse del mismo modo: ofuscar una y no la otra es exactamente como divergen los URN.
Plegar la protección en la compilación
Protege la salida publicada, y solo en el servidor de compilación para que la depuración local siga siendo rápida y legible:
dotnet publish Contoso.Ordering.Fulfillment -c Release -o publish
nebula protect --config nebula.config.json
Engánchalo tras el publish en CI para que el artefacto desplegado sea siempre el protegido:
<Target Name="NebulaProtect" AfterTargets="Publish"
Condition="'$(CI)' == 'true'">
<Exec Command="nebula protect --config nebula.config.json" />
</Target>
Distribuye la carpeta protected/, no la carpeta de publish en bruto. nebula protect devuelve un código de salida distinto de cero en caso de fallo, así que un paso de protección roto hace fallar la compilación en lugar de distribuir un ensamblado que renombró un contrato por el que tus otros servicios todavía enrutan.
Proteger el interior
Conservar el nombre de un contrato no significa dejar nada legible. El URN permanece estable mientras todo lo que hay detrás del consumidor se vuelve opaco. El cifrado de cadenas oculta las URLs de endpoints descendentes, las credenciales y los nombres de cabecera que acumula la capa de mensajería. Las transformaciones más fuertes —virtualización de código y cifrado de método completo, ambas funciones Pro en Nebula.NET— van sobre las joyas de la corona: la lógica de scoring, liquidación o reconciliación que envuelven los consumidores y las sagas, aplicada a ese puñado de métodos en lugar de a todo el ensamblado:
public sealed class FulfillmentService : IFulfillmentService
{
[Obfuscation(Feature = "virtualization")] // Pro — the algorithm, not the plumbing
public Task Reserve(Guid orderId, decimal total) { /* ... */ }
}
El renombrado y la ofuscación de flujo de control se ejecutan de forma amplia por debajo de ambas, convirtiendo el resto de los interiores de consumidores y sagas en símbolos sin sentido y ramas enredadas, mientras el contrato por el que enruta el broker se queda exactamente donde estaba.
Verificar que de verdad funciona
Las pruebas unitarias no detectan esto: construyen un consumidor y llaman a Consume con un OrderSubmitted en memoria, sin enrutar nunca un envoltorio real a través de un broker real contra los DLL protegidos. Dos comprobaciones sí lo detectan. Primero, ejecútalo de extremo a extremo contra un broker real: publica desde el productor protegido y confirma que el consumidor protegido (idealmente desplegado desde un artefacto construido por separado, para imitar el despliegue independiente) recibe y procesa el mensaje. Un mensaje que acaba en _skipped o que simplemente nunca llega, sin excepción, es la firma exacta de un URN divergente. La propia diagnosis de MassTransit ayuda aquí: compara el messageType de un envoltorio publicado con el URN al que está enlazado el consumidor; si difieren por un espacio de nombres o un tipo renombrado, has encontrado la regla keep que faltaba.
Segundo, abre el ensamblado distribuido en un decompilador y léelo como lo hará un atacante. Glass.NET es el compañero gratuito, solo estático, para esa pasada: carga el DLL protegido, confirma que Contoso.Ordering.Contracts.OrderSubmitted y sus propiedades OrderId/CustomerId/Total siguen presentes por nombre —deben estarlo, para coincidir con el URN y el cuerpo en el cable— y luego confirma que no queda nada que leer dentro de OrderSubmittedConsumer ni del FulfillmentService que invoca.
La conclusión
MassTransit identifica un mensaje por una cadena —su MessageUrn, urn:message:Namespace:TypeName— derivada del espacio de nombres y el nombre del tipo de contrato .NET. Ese URN nombra el exchange o topic del broker en el que publica el productor y se estampa en la cabecera messageType del envoltorio, así que un productor y sus consumidores desplegados por separado coinciden en el enrutamiento solo porque ambos calculan el mismo URN a partir del mismo tipo. Renombra el tipo de contrato o su espacio de nombres y el URN cambia en un lado: el productor publica en un exchange distinto, estampa un messageType distinto, y el consumidor no recibe nada en silencio —sin excepción, solo mensajes que no van a ninguna parte—. El cuerpo se enlaza por nombre de propiedad, así que las propiedades públicas del contrato también son de carga. Conserva exactamente lo que cruza el cable —los espacios de nombres, los nombres de tipo y las propiedades públicas de los contratos—, decide deliberadamente si ofuscar siquiera el ensamblado de contratos compartido, y entrega todo el interior de consumidores y sagas a Nebula.NET para renombrar, cifrar cadenas y virtualizar. Luego publica un mensaje real a través de la compilación protegida, mira cómo se dispara el consumidor correcto, y lee el DLL distribuido para confirmar que la lógica que hay detrás desapareció.
Prueba Nebula.NET
Endurece tu código .NET en minutos — empieza con la edición gratuita.