Skip to content
← Tous les articles
· Delta1 Labs ObfuscationNebula

Obfusquer une application MassTransit sans casser le routage des messages

MassTransit dérive l'identité d'un message — son MessageUrn, comme urn:message:Contoso.Ordering.Contracts:OrderSubmitted — de l'espace de noms et du nom du type de contrat .NET, puis l'utilise pour nommer l'exchange ou le topic du broker et l'estampille dans l'en-tête messageType de l'enveloppe. Le producteur et les consommateurs, souvent déployés séparément, doivent s'accorder dessus. Si vous obfusquez après la compilation, renommer le type de contrat ou son espace de noms change le URN, de sorte que le producteur publie vers un exchange différent et estampille un messageType différent, et les consommateurs existants cessent de recevoir en silence. Cet article explique exactement comment le URN est construit à partir du type, pourquoi l'assembly de contrats de messages est la surface fragile, et comment épingler les espaces de noms, noms et propriétés des contrats avec une keep-list Nebula.NET tout en renommant consommateurs, sagas et internes.

Une application MassTransit est une cible d’obfuscation tentante : les consommateurs, les sagas et les activités de routing-slip sont là où vit votre logique métier, et la quasi-totalité peut être renommée, chiffrée dans ses chaînes et virtualisée sans réfléchir. Mais MassTransit a une couture dans laquelle une passe naïve fonce tête baissée, et l’échec est du pire genre — complètement silencieux. La compilation réussit, le service démarre, le producteur publie, le broker ne signale aucune erreur, et le consommateur qui fonctionnait hier ne se déclenche tout simplement jamais. Pas d’exception, pas de trace de pile. Les messages vont quelque part ; ils ne vont simplement pas là.

La cause est spécifique à la façon dont MassTransit décide de ce qu’est un message, et une fois que vous la voyez, la solution est une keep-list courte. Cet article porte sur ce mécanisme : comment MassTransit dérive exactement l’identité d’un message de son type de contrat, pourquoi cette identité est écrite à la fois dans la topologie du broker et dans l’enveloppe du message, et comment protéger l’intérieur de valeur avec Nebula.NET sans déplacer les noms dont dépend le fil.

TL;DR

  • MassTransit dérive le MessageUrn d’un message — urn:message:Namespace:TypeName — de l’espace de noms et du nom du type de contrat .NET.
  • Ce URN nomme l’exchange/topic du broker vers lequel le producteur publie, et est estampillé dans l’en-tête messageType de l’enveloppe pour que les consommateurs puissent le faire correspondre.
  • Renommez le type de contrat ou son espace de noms et le URN change : le producteur publie vers un exchange différent et estampille un messageType différent, et les consommateurs existants cessent de recevoir en silence.
  • L’assembly de contrats de messages est la surface fragile — il est partagé entre des services déployés séparément. Les consommateurs, les sagas et les internes se renomment librement.
  • Mettez dans la keep-list les espaces de noms, les noms de type et les noms de propriété publics des contrats (le corps se lie aussi par nom de propriété) ; obfusquez tout le reste.

Pourquoi obfusquer une application MassTransit

Les contrats sont minces, mais les consommateurs et les sagas sont le produit : le scoring de fraude qui s’exécute quand une commande est soumise, la machine à états de règlement, le recalcul des prix, le flux de réconciliation. Ce code s’exécute sans interface sur un serveur — souvent un serveur administré par votre client — et l’artefact déployé est un DLL que quiconque ayant accès aux fichiers peut ouvrir dans un décompilateur et relire en C# propre. La couche de messagerie est aussi là où se concentrent les constantes sensibles : URLs d’endpoints en aval, identifiants de partenaires, noms de files et d’en-têtes, secrets de signature. Protéger votre propre code livré ici est exactement le cas légitime de l’obfuscation : élever le coût de la relecture de votre logique métier à partir du binaire que vous avez remis.

Comment MassTransit construit l’identité d’un message

Voici un contrat de message ordinaire et le consommateur qui le traite, chacun dans son propre projet :

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);
    }
}

Le producteur publie par type :

await bus.Publish(new OrderSubmitted(orderId, customerId, 149.95m));

Rien dans cet appel à Publish ne nomme un exchange ni une routing key. MassTransit le déduit du type. Pour le type Contoso.Ordering.Contracts.OrderSubmitted, il calcule un MessageUrn :

urn:message:Contoso.Ordering.Contracts:OrderSubmitted

La forme est fixe : urn:message: + l’espace de noms du type + : + le nom du type. Cette unique chaîne gouverne deux choses distinctes, sur deux déploiements distincts.

D’abord, la topologie du broker. Le formateur de noms d’entité par défaut prend le URN, retire le préfixe urn:message:, et utilise le reste comme nom d’exchange (RabbitMQ) ou de topic (Azure Service Bus) :

Contoso.Ordering.Contracts:OrderSubmitted

Le producteur publie vers cet exchange. Un consommateur de OrderSubmitted lie une file à cet exchange. Ils se trouvent uniquement parce que les deux côtés calculent le même nom à partir du même type.

Ensuite, l’enveloppe du message. MassTransit enveloppe votre message dans une enveloppe et écrit le URN dans l’en-tête messageType — sous forme de tableau, car un message peut aussi porter les interfaces qu’il implémente et ses types de 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
  }
}

Quand un consommateur reçoit l’enveloppe, il lit messageType et vérifie si un URN de ce tableau correspond à un contrat qu’il consomme. Si OrderSubmitted est dans la liste, il désérialise l’objet message vers sa copie du contrat et appelle Consume. Sinon, le message n’est pas pour lui : il part dans la file _skipped.

Remarquez la seconde dépendance cachée dans l’objet message : le corps est indexé par nom de propriété — orderId, customerId, total. Le consommateur relie ces champs vers OrderId, CustomerId, Total par nom. Le contrat a donc deux surfaces de noms qui traversent le fil : l’espace de noms + nom du type (le URN, pour le routage et la correspondance) et les noms de ses propriétés publiques (le corps, pour la liaison).

Service producteurbus.Publish(new OrderSubmitted(...))internes renommés · chiffrés · virtualisésURN depuis le type conservé →urn:message:Contoso.Ordering.Contracts:OrderSubmittedExchange du broker / messageTypeContoso.Ordering.Contracts:OrderSubmittedService consommateurIConsumer<OrderSubmitted>internes renommés · chiffrés · virtualisésmême URN depuis le même type conservé →urn:message:Contoso.Ordering.Contracts:OrderSubmittedConservez l'espace de noms + nom + propriétés du contratLes deux côtés calculent le URN identique → routage + liaison tiennentConsommateurs, sagas, services : renommés, chiffrés, virtualisésRenommez le contrat d'un côté → le URN diverge → le consommateur ne reçoit rien

Messages génériques et contrats d’interface

Deux variantes maintiennent la même règle. Si votre message est générique — disons une enveloppe Batch<T> — le URN imbrique les deux noms de type :

urn:message:Contoso.Ordering.Contracts:Batch[[Contoso.Ordering.Contracts:OrderSubmitted]]

Le Batch externe comme le OrderSubmitted interne apparaissent, donc les deux espaces de noms et noms doivent être conservés. Si les contrats MassTransit sont définis comme des interfaces — un motif courant, MassTransit générant l’implémentation concrète à l’exécution — le URN dérive de l’espace de noms et du nom de l’interface, et les noms de propriété proviennent des getters de l’interface. Conserver le type d’interface et ses membres le couvre ; le type d’implémentation généré est interne et ne traverse jamais le fil.

Pourquoi l’assembly de contrats est la surface fragile

L’asymétrie est tout l’enjeu. Un consommateur, une saga, une activité de routing-slip : MassTransit les résout au sein d’un processus, depuis son conteneur DI et les enregistrements que vous avez configurés au démarrage. Il les recherche par identité de type, non par une chaîne écrite dans un broker ou une base de données. Renommez OrderSubmittedConsumer en a et rien ne le remarque : l’enregistrement pointe toujours vers le même type, le conteneur le résout toujours, la file à laquelle il est lié est nommée d’après le message, non d’après le consommateur.

Le contrat est l’inverse. C’est la seule chose sur laquelle deux services déployés indépendamment doivent s’accorder, et ils s’accordent via une chaîne — le URN — que chacun calcule à partir de sa propre copie du type. Pas de registre partagé, pas de négociation ; l’accord est purement que les deux côtés nomment le type de la même façon. À l’instant où l’obfuscation renomme le contrat d’un seul côté — parce que vous avez redéployé le producteur mais pas le consommateur, ou exécuté l’obfuscateur sur une solution et pas sur l’autre — les URN divergent et les deux services cessent de se parler, sans aucune erreur nulle part, car du point de vue du broker tout fonctionne : il route un message vers un exchange, et une file différente est silencieusement liée à un exchange différent.

L’audit : ce qui se renomme, ce qui ne se renomme pas

Parcourez chaque nom qui traverse le fil, et la keep-list s’écrit toute seule.

  • Les types de contrat — Contoso.Ordering.Contracts.OrderSubmitted et tout autre record ou interface de message. Leur espace de noms et leur nom construisent le URN qui nomme l’exchange et remplit messageType. Conservez les espaces de noms et les noms de type.
  • Les propriétés du contrat — OrderId, CustomerId, Total. Le corps message de l’enveloppe est indexé par nom de propriété et le consommateur relie par nom. Conservez les noms des propriétés publiques. Une règle keep de type-and-members couvre le type et ses propriétés en une ligne.
  • Les types d’argument générique — si un message est Batch<OrderSubmitted>, Batch comme OrderSubmitted apparaissent dans le URN. Conservez tout type utilisé comme message ou comme argument générique d’un message.
  • Les consommateurs — OrderSubmittedConsumer, les implémentations de IConsumer<T>. Résolus par enregistrement et identité de type au sein du processus ; rien sur le fil ne les nomme. Renommez librement.
  • Les sagas et machines à états — la MassTransitStateMachine, le type d’instance de la saga. La saga corrèle sur une valeur de propriété (un GUID CorrelationId), qui est une donnée, non un symbole, et son état persisté est indexé par cette valeur, non par le nom de type. Renommez la logique. (Si la saga est persistée et que son type d’instance est sérialisé vers un magasin par nom, traitez ce magasin exactement comme un contrat et conservez les membres du type d’instance : le même raisonnement que toute persistance sérialisée, couvert dans nos articles sur EF Core et la sérialisation.)
  • Les noms d’endpoint/file — "orders-api", les noms de receive-endpoint que vous passez en littéraux de chaîne. Ce sont des données que vous reproduisez de la même façon à l’exécution ; le chiffrement de chaînes laisse leur valeur d’exécution intacte. Rien à faire.
  • Tout ce que les consommateurs et sagas appellent — IFulfillmentService, le scoring, la réconciliation, les helpers privés. Rien en dehors du processus ne les nomme. Renommez, chiffrez, virtualisez.

Une seule surface à conserver, donc — les types de contrat, leurs espaces de noms et leurs propriétés publiques — et tout le reste est libre.

La keep-list

Modélisez les contrats sous un unique espace de noms pour qu’une seule règle les couvre, et nebula.config.json se lit ainsi :

{
  "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 première règle épingle chaque type sous Contoso.Ordering.Contracts et ses membres, de sorte que chaque contrat conserve l’espace de noms et le nom à partir desquels le URN est construit et les noms de propriété publics par lesquels le corps se lie — scope: "type-and-members" est ce qui préserve OrderId, pas seulement OrderSubmitted. Tout le reste dans les assemblies — consommateurs, sagas, services, helpers — est renommé. Le chiffrement de chaînes et l’obfuscation de flux de contrôle s’appliquent partout, y compris à l’intérieur de l’assembly de contrats, car aucun ne touche aux noms.

Si vous préférez ne pas dépendre d’une convention d’espace de noms, annotez les contrats directement : l’attribut voyage avec le code et ne peut pas se désynchroniser d’un motif :

[Obfuscation(Exclude = true, ApplyToMembers = true)]   // keep the URN + property names for routing/binding
public record OrderSubmitted(Guid OrderId, Guid CustomerId, decimal Total);

Faut-il obfusquer l’assembly de contrats partagé tout court ?

Souvent la réponse la plus propre est : ne le faites pas, ou conservez-le entier. Un assembly de contrats est délibérément mince — des records et des interfaces, sans logique propriétaire — donc le renommer ne cache rien de valeur tandis que chaque nom renommé est une chance de casser le routage ou la liaison à travers un service que vous déployez selon un autre calendrier. Deux motifs sains :

  • Laissez le projet de contrats non obfusqué. Livrez-le tel quel et n’exécutez Nebula.NET que sur les assemblies de consommateurs/sagas. Les noms des contrats ne sont pas des secrets ; vos algorithmes le sont, et ils ne sont pas dans cet assembly.
  • Obfusquez-le en conservant toute l’API publique. Si vous préférez un pipeline unique et uniforme, conservez chaque type et membre public dans l’assembly de contrats (la règle type-and-members ci-dessus, ou preservePublicApi: true restreint à ce DLL) et laissez le chiffrement de chaînes et le flux de contrôle continuer de s’appliquer. Vous gagnez l’uniformité et ne perdez rien, car il n’y avait rien à cacher.

Dans tous les cas, la règle est la même sur chaque service qui référence les contrats : l’espace de noms, le nom de type et les propriétés publiques doivent être identiques octet pour octet partout où le URN est calculé. Si deux services livrent leur propre copie des contrats, les deux copies doivent être conservées de la même façon : obfusquer l’une et pas l’autre est exactement comment les URN divergent.

Intégrer la protection dans le build

Protégez la sortie publiée, et uniquement sur le serveur de build pour que le débogage local reste rapide et lisible :

dotnet publish Contoso.Ordering.Fulfillment -c Release -o publish
nebula protect --config nebula.config.json

Accrochez-la après le publish en CI pour que l’artefact déployé soit toujours le protégé :

<Target Name="NebulaProtect" AfterTargets="Publish"
        Condition="'$(CI)' == 'true'">
  <Exec Command="nebula protect --config nebula.config.json" />
</Target>

Livrez le dossier protected/, non le dossier de publish brut. nebula protect renvoie un code de sortie non nul en cas d’échec, de sorte qu’une étape de protection cassée fait échouer le build au lieu de livrer un assembly qui a renommé un contrat par lequel vos autres services routent encore.

Protéger l’intérieur

Conserver le nom d’un contrat ne signifie pas laisser quoi que ce soit de lisible. Le URN reste stable tandis que tout ce qui se trouve derrière le consommateur devient opaque. Le chiffrement de chaînes cache les URLs d’endpoints en aval, les identifiants et les noms d’en-tête que la couche de messagerie accumule. Les transformations les plus fortes — virtualisation de code et chiffrement de méthode complète, toutes deux des fonctionnalités Pro dans Nebula.NET — vont sur les joyaux de la couronne : la logique de scoring, de règlement ou de réconciliation qu’enveloppent les consommateurs et les sagas, appliquée à cette poignée de méthodes plutôt qu’à tout l’assembly :

public sealed class FulfillmentService : IFulfillmentService
{
    [Obfuscation(Feature = "virtualization")]   // Pro — the algorithm, not the plumbing
    public Task Reserve(Guid orderId, decimal total) { /* ... */ }
}

Le renommage et l’obfuscation de flux de contrôle s’exécutent largement sous les deux, transformant le reste des intérieurs de consommateurs et de sagas en symboles sans signification et branches enchevêtrées, tandis que le contrat par lequel le broker route reste exactement où il était.

Vérifier que cela fonctionne vraiment

Les tests unitaires ne détectent pas ceci : ils construisent un consommateur et appellent Consume avec un OrderSubmitted en mémoire, sans jamais router une véritable enveloppe à travers un véritable broker contre les DLL protégés. Deux vérifications le détectent. D’abord, exécutez-le de bout en bout contre un véritable broker : publiez depuis le producteur protégé et confirmez que le consommateur protégé (idéalement déployé depuis un artefact construit séparément, pour imiter le déploiement indépendant) reçoit et traite le message. Un message qui atterrit dans _skipped ou qui n’arrive simplement jamais, sans exception, est la signature exacte d’un URN divergent. La diagnostic propre à MassTransit aide ici : comparez le messageType d’une enveloppe publiée au URN auquel le consommateur est lié ; s’ils diffèrent par un espace de noms ou un type renommé, vous avez trouvé la règle keep manquante.

Ensuite, ouvrez l’assembly livré dans un décompilateur et lisez-le comme le fera un attaquant. Glass.NET est le compagnon gratuit, purement statique, pour cette passe : chargez le DLL protégé, confirmez que Contoso.Ordering.Contracts.OrderSubmitted et ses propriétés OrderId/CustomerId/Total sont toujours présents par nom — ils doivent l’être, pour correspondre au URN et au corps sur le fil — puis confirmez qu’il ne reste rien à lire à l’intérieur de OrderSubmittedConsumer ni du FulfillmentService qu’il appelle.

À retenir

MassTransit identifie un message par une chaîne — son MessageUrn, urn:message:Namespace:TypeName — dérivée de l’espace de noms et du nom du type de contrat .NET. Ce URN nomme l’exchange ou le topic du broker vers lequel le producteur publie et est estampillé dans l’en-tête messageType de l’enveloppe, de sorte qu’un producteur et ses consommateurs déployés séparément s’accordent sur le routage uniquement parce que les deux calculent le même URN à partir du même type. Renommez le type de contrat ou son espace de noms et le URN change d’un côté : le producteur publie vers un exchange différent, estampille un messageType différent, et le consommateur ne reçoit rien en silence — pas d’exception, juste des messages qui ne vont nulle part. Le corps se lie par nom de propriété, donc les propriétés publiques du contrat sont porteuses elles aussi. Conservez exactement ce qui traverse le fil — les espaces de noms, les noms de type et les propriétés publiques des contrats — décidez délibérément s’il faut obfusquer l’assembly de contrats partagé tout court, et confiez tout l’intérieur des consommateurs et des sagas à Nebula.NET pour renommer, chiffrer les chaînes et virtualiser. Puis publiez un véritable message à travers le build protégé, regardez le bon consommateur se déclencher, et lisez le DLL livré pour confirmer que la logique derrière lui a disparu.

Essayez Nebula.NET

Renforcez votre code .NET en quelques minutes — commencez avec l'édition gratuite.