DEV Community

Zero Heartbeat
Zero Heartbeat

Posted on Edited on Originally published at delta1labs.com

Obfuscating a gRPC service without breaking the contract on the wire

A gRPC assembly is full of names, and obfuscation has to sort them into two piles whose rules are exact opposites.

In one pile are the names that travel between processes: the service path a client dials, the method path within it, the field numbers that identify each field in a serialized message. These are a contract. Every other peer — a service written in Go, a mobile client, last year's build of your own gateway — depends on them being stable, and renaming one is indistinguishable from changing a published URL. In the other pile are the names that never leave the process: your C# method names, the properties on your message classes, the type you hand to MapGrpcService<T>. Those are labels the CLR resolves internally, and they can be flattened to a, b, c with no observable effect.

The entire question of obfuscating gRPC safely is which name is in which pile — and the answer flips completely depending on whether you generated your service from a .proto file or defined it in C# with protobuf-net.Grpc.

Contract-first: the wire identity is baked into generated code

Start with the common case — a .proto compiled by Grpc.Tools. The contract is declared once, in a file that is not even C#:

syntax = "proto3";
package greet;

service Greeter {
  rpc SayHello (HelloRequest) returns (HelloReply);
}

message HelloRequest {
  string name = 1;
}

message HelloReply {
  string greeting = 1;
}
Enter fullscreen mode Exit fullscreen mode

The generator turns this into a Greeter.g.cs whose job is to carry the wire identity as literals. The routing surface looks like this (abridged to what matters):

public static partial class Greeter
{
    static readonly string __ServiceName = "greet.Greeter";

    static readonly grpc::Method<HelloRequest, HelloReply> __Method_SayHello =
        new grpc::Method<HelloRequest, HelloReply>(
            grpc::MethodType.Unary,
            __ServiceName,                 // "greet.Greeter"
            "SayHello",                    // method name — a literal, not derived
            __Marshaller_HelloRequest,
            __Marshaller_HelloReply);
    // ...
}
Enter fullscreen mode Exit fullscreen mode

Read what that means for renaming. The route a client dials is /greet.Greeter/SayHello, and it is assembled from the string literals "greet.Greeter" and "SayHello" sitting in the Method descriptor. It is not derived from the name of any C# method at runtime. So your actual implementation override:

public override Task<HelloReply> SayHello(HelloRequest request, ServerCallContext ctx) =>
    Task.FromResult(new HelloReply { Greeting = $"Hello {request.Name}" });
Enter fullscreen mode Exit fullscreen mode

can have its C# identifier renamed to b and the dispatcher will still bind it to the literal "SayHello" in the descriptor, because the server's service-definition builder maps the descriptor's method name to the delegate — the identifier of the method is irrelevant to routing. The names you must keep are the ones inside the generated file: __ServiceName and the Method descriptors, because those literals are the wire contract.

Field numbers make message members free

Messages are the other half, and here protobuf does you a favour. The generated HelloRequest serializes by number, not by name:

public void WriteTo(pb::CodedOutputStream output)
{
    if (Name.Length != 0)
    {
        output.WriteRawTag(10);   // (field 1 << 3) | wiretype 2  ==  the tag for "name"
        output.WriteString(Name);
    }
}
Enter fullscreen mode Exit fullscreen mode

The tag 10 encodes field number 1 with wire type 2 (length-delimited). That tag is what a peer reads; the C# property Name is just a local accessor. Rename Name to a and WriteTo still emits tag 10, so every peer that reads field 1 is unaffected. Message property names are firmly in the free-to-rename pile — the field number carries the identity, and the number lives in the .proto, not in your identifiers.

Two carve-outs. First, if you expose gRPC-JSON transcoding or a JSON gateway, that surface keys off the camelCase field name, so the field names become wire-visible and must be preserved on the transcoded messages. Second, enum value names are serialized as strings by the JSON formatter and are visible through server reflection, so if you rely on either, pin the enum member names. Pure binary gRPC depends on neither.

Code-first flips the piles: your .NET names are the contract

Now the case that trips people. With protobuf-net.Grpc there is no .proto and no generated descriptor full of literals. You write the contract as C#:

[ServiceContract]
public interface IGreeter
{
    [OperationContract]
    Task<HelloReply> SayHelloAsync(HelloRequest request, CallContext ctx = default);
}

[ProtoContract]
public class HelloRequest
{
    [ProtoMember(1)] public string Name { get; set; } = "";
}
Enter fullscreen mode Exit fullscreen mode

At startup the library reflects over IGreeter and derives the wire identity from the .NET names: the service path comes from the interface name (IGreeter → service Greeter by its default naming convention), and each method path comes from the method name (SayHelloAsync). There are no literals to preserve because the names themselves are the literals. Rename IGreeter to a and you have renamed the service path; rename SayHelloAsync to b and you have moved the method. A blind rename pass silently changes the contract and every client fails to find the endpoint it used to call.

Messages are still safe — [ProtoMember(1)] pins field number 1 regardless of what the Name property is called, exactly as in contract-first — but the service surface has moved into the must-preserve pile. Two ways to handle it, in order of preference:

The robust fix is to stop depending on the identifiers at all by giving the contract explicit names:

[ServiceContract(Name = "greet.Greeter")]
public interface IGreeter
{
    [OperationContract(Name = "SayHello")]
    Task<HelloReply> SayHelloAsync(HelloRequest request, CallContext ctx = default);
}
Enter fullscreen mode Exit fullscreen mode

Now the wire identity is "greet.Greeter"/"SayHello" from the attributes, the IGreeter and SayHelloAsync identifiers are once again free to rename, and your contract no longer depends on what your code is called — which is good hygiene even if you never obfuscate. The alternative, when you cannot edit the contracts, is to pin the type and member names of the contract interfaces so reflection still reads the names it expects.

Telling Nebula which pile is which

Nebula does not guess. For contract-first it recognises the generated service surface from the descriptor shape and keeps the service-name constant, the Method descriptors, and (when configured) the enum value names, while renaming your implementations, message property identifiers, DI wiring, and interceptors. For code-first you point it at the contract attributes so it treats the [ServiceContract]/[OperationContract] types and members as the preserve set.

<Nebula>
  <!-- Contract-first: keep the wire identity the generator baked in. -->
  <!-- Service-name constants and Method<,> descriptors in generated gRPC code. -->
  <Preserve names="true" members="true"
            attribute="System.CodeDom.Compiler.GeneratedCodeAttribute"
            where="GrpcServices" />

  <!-- Code-first: the .NET names ARE the contract surface. -->
  <Preserve names="true" members="true" attribute="System.ServiceModel.ServiceContractAttribute" />
  <Preserve names="true" members="true" attribute="System.ServiceModel.OperationContractAttribute" />

  <!-- Enums whose value names hit the wire via JSON/reflection. -->
  <Preserve memberNames="true" where="ProtoEnums" />

  <!-- Everything else — service implementations, message property names,
       interceptors, private helpers — gets full renaming. -->
</Nebula>
Enter fullscreen mode Exit fullscreen mode

The DI registration needs no special handling. builder.Services.AddGrpc() and app.MapGrpcService<GreeterService>() bind by Type, not by name, so GreeterService can be renamed to d and the mapping still resolves — the generic argument is a metadata token, the same way all type references are, and renaming updates it consistently. Likewise the marshallers, the CallContext, and your interceptors are internal plumbing that renames cleanly.

The one rule that covers both styles

Strip away the specifics and gRPC obeys the same rule as every other framework that reaches across a boundary: a name is load-bearing exactly when something outside the CLR's token resolution reads it. On the binary wire, that is the service path, the method path, and the field numbers — and field numbers mean your message members are free. Through JSON transcoding or reflection, it additionally means field names and enum value names. And in code-first, reflection at startup pulls your .NET type and member names across that boundary, which is the whole reason code-first changes the answer.

Find that boundary, preserve precisely the names that cross it, and let Nebula rename everything that stays on your side. You keep protection on your service implementations and your business logic — the code actually worth protecting — and the contract on the wire never moves.

Top comments (0)